From 29beec385c0b37181aba5475e173b66261551911 Mon Sep 17 00:00:00 2001 From: Alex Hambley <33315205+alexhambley@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:04:56 +0100 Subject: [PATCH 01/16] refactor: load and validate Settings on startup and add StorageBackend - Replace the import-time Config classes with a validated Settings dataclass built in create_app(). - Fails early with an aggregated error when storage is enabled but the required S3/Celery vars are missing. - Gate storage routes on settings.storage_enabled; Settings injected in tests instead of monkeypatching. - Add app/storage as a "vendor-neutral" abstraction (so, works with MinIO, RustFS, or any other S3 service) - Closes #171, #172 --- app/__init__.py | 45 +++-- app/storage/__init__.py | 11 ++ app/storage/base.py | 48 +++++ app/storage/errors.py | 13 ++ app/storage/memory.py | 45 +++++ app/utils/config.py | 121 ++++++++----- tests/test_api_routes.py | 367 +++++++++++++++++++++++--------------- tests/test_app_factory.py | 61 +++++++ tests/test_config.py | 96 ++++++++++ tests/test_storage.py | 61 +++++++ 10 files changed, 663 insertions(+), 205 deletions(-) create mode 100644 app/storage/__init__.py create mode 100644 app/storage/base.py create mode 100644 app/storage/errors.py create mode 100644 app/storage/memory.py create mode 100644 tests/test_app_factory.py create mode 100644 tests/test_config.py create mode 100644 tests/test_storage.py diff --git a/app/__init__.py b/app/__init__.py index dc3d67b..4ad12bc 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,18 +1,12 @@ """Initialises and configures Flask, integrates Celery, and registers application blueprints.""" -# Author: Alexander Hambley -# License: MIT -# Copyright (c) 2025 eScience Lab, The University of Manchester - import logging -import os from apiflask import APIFlask from app.ro_crates.routes import v1_post_bp, v1_minio_post_bp, v1_minio_get_bp from app.utils.config import ( - DevelopmentConfig, - ProductionConfig, + Settings, InvalidAPIUsage, make_celery, ) @@ -21,34 +15,39 @@ logger = logging.getLogger(__name__) -def create_app() -> APIFlask: +def create_app(settings: Settings | None = None) -> APIFlask: """ - Creates and configures Flask application. + Creates and configures the Flask application. + + Configuration is loaded and validated up front via :class:`Settings`, so a + misconfigured deployment fails at startup with a clear error rather than at + the first request. A ``settings`` object may be injected for testing. - :return: Flask: A configured Flask application instance. + :param settings: Pre-built settings; if omitted, loaded from the environment. + :return: A configured Flask application instance. + :raises ConfigError: If required configuration is missing or invalid. """ + if settings is None: + settings = Settings.from_env() + app = APIFlask(__name__) - # Load config before registering blueprints, so MINIO_ENABLED can - # decide whether the backed endpoints are exposed. - if os.getenv("FLASK_ENV") == "production": - app.config.from_object(ProductionConfig) - else: - # Development environment: - app.debug = True - app.config.from_object(DevelopmentConfig) + app.debug = settings.debug + app.config["SETTINGS"] = settings + app.config["STORAGE_ENABLED"] = settings.storage_enabled + app.config["PROFILES_PATH"] = settings.profiles_path # Always available: app.register_blueprint(v1_post_bp, url_prefix="/v1/ro_crates") - # MinIO is optional and disabled by default. Only register - # the MinIO ID routes when enabled: - if app.config.get("MINIO_ENABLED"): + # Object storage is optional and disabled by default. Only register the + # ID-based, store-backed routes when storage is enabled. + if settings.storage_enabled: app.register_blueprint(v1_minio_post_bp, url_prefix="/v1/ro_crates") app.register_blueprint(v1_minio_get_bp, url_prefix="/v1/ro_crates") - logger.info("MinIO storage enabled: ID-based validation endpoints registered.") + logger.info("Storage enabled: ID-based validation endpoints registered.") else: - logger.info("MinIO storage disabled: only metadata validation is available.") + logger.info("Storage disabled: only metadata validation is available.") if app.debug: print("URL Map:") diff --git a/app/storage/__init__.py b/app/storage/__init__.py new file mode 100644 index 0000000..d7b591f --- /dev/null +++ b/app/storage/__init__.py @@ -0,0 +1,11 @@ +"""Object-storage abstraction. + +The rest of the application depends only on the :class:`StorageBackend` +protocol, rather than a specific client, so backends (S3/MinIO/RustFS) and tests +are interchangeable. +""" + +from app.storage.base import StorageBackend, ObjectStat +from app.storage.errors import StorageError, ObjectNotFound + +__all__ = ["StorageBackend", "ObjectStat", "StorageError", "ObjectNotFound"] diff --git a/app/storage/base.py b/app/storage/base.py new file mode 100644 index 0000000..c06e84e --- /dev/null +++ b/app/storage/base.py @@ -0,0 +1,48 @@ +"""The storage backend protocol and shared value types.""" + +from dataclasses import dataclass +from typing import List, Optional, Protocol, runtime_checkable + + +@dataclass(frozen=True) +class ObjectStat: + """Lightweight metadata for a stored object.""" + + key: str + size: int + + +@runtime_checkable +class StorageBackend(Protocol): + """Minimal object-storage interface the application depends on. + + Implementations translate backend-specific failures into + :class:`~app.storage.errors.StorageError` (and ``ObjectNotFound`` for a + missing key), so the caller never handles specific exceptions. + """ + + def stat(self, key: str) -> ObjectStat: + """Return metadata for ``key`` or raise ``ObjectNotFound``.""" + ... + + def get_bytes(self, key: str) -> bytes: + """Return the object's bytes or raise ``ObjectNotFound``.""" + ... + + def put_bytes( + self, key: str, data: bytes, content_type: Optional[str] = None + ) -> None: + """Store ``data`` at ``key``, overwriting any existing object.""" + ... + + def list(self, prefix: str) -> List[str]: + """Return the keys whose names start with ``prefix``, sorted.""" + ... + + def download_tree(self, prefix: str, dest_dir: str) -> None: + """Download every object under ``prefix`` into ``dest_dir``. + + Keys are written relative to ``prefix``, recreating their directory + structure beneath ``dest_dir``. + """ + ... diff --git a/app/storage/errors.py b/app/storage/errors.py new file mode 100644 index 0000000..64dde9e --- /dev/null +++ b/app/storage/errors.py @@ -0,0 +1,13 @@ +"""Storage-layer exceptions, decoupled from any specific client.""" + + +class StorageError(Exception): + """Base class for object-storage failures.""" + + +class ObjectNotFound(StorageError): + """Raised when a requested object key does not exist.""" + + def __init__(self, key: str): + super().__init__(f"Object not found: {key}") + self.key = key diff --git a/app/storage/memory.py b/app/storage/memory.py new file mode 100644 index 0000000..8653b6b --- /dev/null +++ b/app/storage/memory.py @@ -0,0 +1,45 @@ +"""An in-memory storage backend for tests and local use.""" + +import os + +from typing import Dict, List, Optional + +from app.storage.base import ObjectStat +from app.storage.errors import ObjectNotFound + + +class InMemoryStorage: + """A dict-backed :class:`StorageBackend` implementation. + + Works as a dependency-free test double and for local development without full S3 object store. + """ + + def __init__(self) -> None: + self._objects: Dict[str, bytes] = {} + + def stat(self, key: str) -> ObjectStat: + if key not in self._objects: + raise ObjectNotFound(key) + return ObjectStat(key=key, size=len(self._objects[key])) + + def get_bytes(self, key: str) -> bytes: + try: + return self._objects[key] + except KeyError: + raise ObjectNotFound(key) + + def put_bytes( + self, key: str, data: bytes, content_type: Optional[str] = None + ) -> None: + self._objects[key] = data + + def list(self, prefix: str) -> List[str]: + return sorted(key for key in self._objects if key.startswith(prefix)) + + def download_tree(self, prefix: str, dest_dir: str) -> None: + for key in self.list(prefix): + relative_path = key[len(prefix) :] + local_path = os.path.join(dest_dir, *relative_path.split("/")) + os.makedirs(os.path.dirname(local_path), exist_ok=True) + with open(local_path, "wb") as handle: + handle.write(self._objects[key]) diff --git a/app/utils/config.py b/app/utils/config.py index 465ca60..a4116a0 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -1,54 +1,94 @@ """Configuration module for the Flask application.""" -# Author: Alexander Hambley -# License: MIT -# Copyright (c) 2025 eScience Lab, The University of Manchester - import os +from dataclasses import dataclass +from typing import Mapping, Optional + from celery import Celery from flask import Flask -def get_env(name: str, default=None, required=False): - value = os.environ.get(name, default) - if required and value is None: - raise RuntimeError(f"Missing required environment variable: {name}") - return value - - -def get_bool_env(name: str, default: bool = False) -> bool: - value = get_env(name) - if value is None: - return default - return value.strip().lower() in ("true", "1", "yes", "on") - - -class Config: - """Base configuration class for the Flask application.""" - - # Celery configuration: - CELERY_BROKER_URL = get_env("CELERY_BROKER_URL", required=False) - CELERY_RESULT_BACKEND = get_env("CELERY_RESULT_BACKEND", required=False) - - # rocrate validator configuration: - PROFILES_PATH = get_env("PROFILES_PATH", required=False) - - # Optional MinIO storage. Disabled by default - when False the - # ID validation endpoints are not registered: - MINIO_ENABLED = get_bool_env("MINIO_ENABLED", default=False) +class ConfigError(RuntimeError): + """Raised at startup when required configuration is missing or invalid.""" -class DevelopmentConfig(Config): - """Development configuration class.""" +_TRUE_VALUES = ("true", "1", "yes", "on") - DEBUG = True +def _parse_bool(value: Optional[str], default: bool = False) -> bool: + if value is None: + return default + return value.strip().lower() in _TRUE_VALUES -class ProductionConfig(Config): - """Production configuration class.""" - DEBUG = False +def _clean(value: Optional[str]) -> Optional[str]: + """Return a stripped value. Blank strings are treated as absent.""" + if value is None: + return None + value = value.strip() + return value or None + + +@dataclass(frozen=True) +class Settings: + """Validated application configuration loaded once at startup.""" + + flask_env: str + debug: bool + storage_enabled: bool + profiles_path: Optional[str] + celery_broker_url: Optional[str] + celery_result_backend: Optional[str] + s3_endpoint: Optional[str] + s3_access_key: Optional[str] + s3_secret_key: Optional[str] + s3_region: Optional[str] + s3_bucket: Optional[str] + s3_use_ssl: bool + + @classmethod + def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "Settings": + """Build Settings from an environment mapping, failing fast on bad config.""" + if env is None: + env = os.environ + + flask_env = _clean(env.get("FLASK_ENV")) or "development" + storage_enabled = _parse_bool(env.get("STORAGE_ENABLED")) + + # S3 validation needs both: (1) an object store, and (2) a broker; + # require them up front so misconfiguration fails when starting, not + # at the first request. + if storage_enabled: + required = ( + "S3_ENDPOINT", + "S3_ACCESS_KEY", + "S3_SECRET_KEY", + "S3_BUCKET", + "CELERY_BROKER_URL", + "CELERY_RESULT_BACKEND", + ) + missing = [name for name in required if _clean(env.get(name)) is None] + if missing: + raise ConfigError( + "STORAGE_ENABLED is true but these required variables are " + f"missing or blank: {', '.join(missing)}" + ) + + return cls( + flask_env=flask_env, + debug=flask_env != "production", + storage_enabled=storage_enabled, + profiles_path=_clean(env.get("PROFILES_PATH")), + celery_broker_url=_clean(env.get("CELERY_BROKER_URL")), + celery_result_backend=_clean(env.get("CELERY_RESULT_BACKEND")), + s3_endpoint=_clean(env.get("S3_ENDPOINT")), + s3_access_key=_clean(env.get("S3_ACCESS_KEY")), + s3_secret_key=_clean(env.get("S3_SECRET_KEY")), + s3_region=_clean(env.get("S3_REGION")), + s3_bucket=_clean(env.get("S3_BUCKET")), + s3_use_ssl=_parse_bool(env.get("S3_USE_SSL")), + ) class InvalidAPIUsage(Exception): @@ -74,13 +114,12 @@ def make_celery(app: Flask = None) -> Celery: :param app: The Flask application to use. :return: The Celery instance. """ - env = os.environ.get("FLASK_ENV", "development") - config_cls = ProductionConfig if env == "production" else DevelopmentConfig + settings: Optional[Settings] = app.config.get("SETTINGS") if app else None celery = Celery( app.import_name if app else __name__, - broker=config_cls.CELERY_BROKER_URL, - backend=config_cls.CELERY_RESULT_BACKEND, + broker=settings.celery_broker_url if settings else None, + backend=settings.celery_result_backend if settings else None, ) if app: diff --git a/tests/test_api_routes.py b/tests/test_api_routes.py index 2f1071b..c826476 100644 --- a/tests/test_api_routes.py +++ b/tests/test_api_routes.py @@ -2,115 +2,151 @@ import pytest from unittest.mock import patch from app import create_app +from app.utils.config import Settings + + +def _storage_env() -> dict: + """Returns complete storage-enabled environment for building a storage-backed app.""" + return { + "STORAGE_ENABLED": "true", + "S3_ENDPOINT": "localhost:9000", + "S3_ACCESS_KEY": "minioadmin", + "S3_SECRET_KEY": "minioadmin", + "S3_BUCKET": "test_bucket", + "CELERY_BROKER_URL": "redis://localhost:6379/0", + "CELERY_RESULT_BACKEND": "redis://localhost:6379/1", + } @pytest.fixture def client(): - """Client with MinIO disabled (the default): only metadata validation is exposed.""" - app = create_app() + """Client with storage disabled (the default): only metadata validation is exposed.""" + app = create_app(settings=Settings.from_env({})) return app.test_client() @pytest.fixture -def minio_client(monkeypatch): - """Client with MinIO enabled, so the ID-based validation endpoints are registered.""" - # MINIO_ENABLED is resolved on the Config class at import time, so override - # the class attribute before building the app rather than the env var. - monkeypatch.setattr("app.utils.config.DevelopmentConfig.MINIO_ENABLED", True) - app = create_app() +def minio_client(): + """Client with storage enabled, so the ID-based validation endpoints are registered.""" + app = create_app(settings=Settings.from_env(_storage_env())) return app.test_client() # Test POST API: /v1/ro_crates/{crate_id}/validation + @pytest.mark.parametrize( - "crate_id, payload, profiles_path, status_code, response_json", - [ - ( - "crate-123", { - "minio_config": { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket" - }, - "root_path": "base_path", - "webhook_url": "https://webhook.example.com", - "profile_name": "default" + "crate_id, payload, profiles_path, status_code, response_json", + [ + ( + "crate-123", + { + "minio_config": { + "endpoint": "localhost:9000", + "accesskey": "admin", + "secret": "password123", + "ssl": False, + "bucket": "test_bucket", }, - None, - 202, {"message": "Validation in progress"} - ), - ( - "crate-123", { - "minio_config": { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket" - }, - "root_path": "base_path", - "webhook_url": "https://webhook.example.com", + "root_path": "base_path", + "webhook_url": "https://webhook.example.com", + "profile_name": "default", + }, + None, + 202, + {"message": "Validation in progress"}, + ), + ( + "crate-123", + { + "minio_config": { + "endpoint": "localhost:9000", + "accesskey": "admin", + "secret": "password123", + "ssl": False, + "bucket": "test_bucket", }, - None, - 202, {"message": "Validation in progress"} - ), - ( - "crate-123", { - "minio_config": { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket" - }, - "root_path": "base_path", - "profile_name": "default" + "root_path": "base_path", + "webhook_url": "https://webhook.example.com", + }, + None, + 202, + {"message": "Validation in progress"}, + ), + ( + "crate-123", + { + "minio_config": { + "endpoint": "localhost:9000", + "accesskey": "admin", + "secret": "password123", + "ssl": False, + "bucket": "test_bucket", }, - None, - 202, {"message": "Validation in progress"} - ), - ( - "crate-123", { - "minio_config": { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket" - }, - "webhook_url": "https://webhook.example.com", - "profile_name": "default" + "root_path": "base_path", + "profile_name": "default", + }, + None, + 202, + {"message": "Validation in progress"}, + ), + ( + "crate-123", + { + "minio_config": { + "endpoint": "localhost:9000", + "accesskey": "admin", + "secret": "password123", + "ssl": False, + "bucket": "test_bucket", }, - None, - 202, {"message": "Validation in progress"} - ), - ( - "crate-123", { - "minio_config": { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket" - }, + "webhook_url": "https://webhook.example.com", + "profile_name": "default", + }, + None, + 202, + {"message": "Validation in progress"}, + ), + ( + "crate-123", + { + "minio_config": { + "endpoint": "localhost:9000", + "accesskey": "admin", + "secret": "password123", + "ssl": False, + "bucket": "test_bucket", }, - None, - 202, {"message": "Validation in progress"} - ), - ], - ids=["validate_by_id", "validate_with_missing_profile_name", - "validate_with_missing_webhook_url", "validate_with_missing_root_path", - "validate_with_missing_root_path_and_profile_name_and_webhook_url"] + }, + None, + 202, + {"message": "Validation in progress"}, + ), + ], + ids=[ + "validate_by_id", + "validate_with_missing_profile_name", + "validate_with_missing_webhook_url", + "validate_with_missing_root_path", + "validate_with_missing_root_path_and_profile_name_and_webhook_url", + ], ) -def test_validate_by_id_success(minio_client: FlaskClient, crate_id: str, payload: dict, - profiles_path: str, status_code: int, response_json: dict): - with patch("app.ro_crates.routes.post_routes.queue_ro_crate_validation_task") as mock_queue: +def test_validate_by_id_success( + minio_client: FlaskClient, + crate_id: str, + payload: dict, + profiles_path: str, + status_code: int, + response_json: dict, +): + with patch( + "app.ro_crates.routes.post_routes.queue_ro_crate_validation_task" + ) as mock_queue: mock_queue.return_value = (response_json, status_code) - response = minio_client.post(f"/v1/ro_crates/{crate_id}/validation", json=payload) + response = minio_client.post( + f"/v1/ro_crates/{crate_id}/validation", json=payload + ) minio_config = payload["minio_config"] if "minio_config" in payload else None root_path = payload["root_path"] if "root_path" in payload else None @@ -118,40 +154,46 @@ def test_validate_by_id_success(minio_client: FlaskClient, crate_id: str, payloa webhook_url = payload["webhook_url"] if "webhook_url" in payload else None assert response.status_code == status_code assert response.json == response_json - mock_queue.assert_called_once_with(minio_config, crate_id, root_path, profile_name, webhook_url, profiles_path) + mock_queue.assert_called_once_with( + minio_config, crate_id, root_path, profile_name, webhook_url, profiles_path + ) @pytest.mark.parametrize( "crate_id, payload, status_code", [ ( - "", { + "", + { "minio_bucket": "test_bucket", "root_path": "base_path", "webhook_url": "https://webhook.example.com", - "profile_name": "default" - }, 404 + "profile_name": "default", + }, + 404, ), ( - "crate-123", { + "crate-123", + { "root_path": "base_path", "webhook_url": "https://webhook.example.com", - "profile_name": "default" - }, 422 + "profile_name": "default", + }, + 422, ), ], - ids=[ - "missing_crate_id_returns_404", - "missing_minio_bucket_returns_422" - ] + ids=["missing_crate_id_returns_404", "missing_minio_bucket_returns_422"], ) -def test_validate_fails_missing_elements(minio_client: FlaskClient, crate_id: str, payload: dict, status_code: int): +def test_validate_fails_missing_elements( + minio_client: FlaskClient, crate_id: str, payload: dict, status_code: int +): response = minio_client.post(f"/v1/ro_crates/{crate_id}/validation", json=payload) assert response.status_code == status_code # Test POST API: /v1/ro_crates/validate_metadata + # TODO: Write tests for profiles_path environment variable. This will require a refactoring of the create_app function. @pytest.mark.parametrize( "payload, status_code, response_json, profiles_path", @@ -159,20 +201,33 @@ def test_validate_fails_missing_elements(minio_client: FlaskClient, crate_id: st ( { "crate_json": '{"@context": "https://w3id.org/ro/crate/1.1/context"}', - "profile_name": "default" - }, 200, {"status": "success"}, None + "profile_name": "default", + }, + 200, + {"status": "success"}, + None, ), ( { "crate_json": '{"@context": "https://w3id.org/ro/crate/1.1/context"}', - }, 200, {"status": "success"}, None + }, + 200, + {"status": "success"}, + None, ), ], - ids=["success_with_all_fields", "success_without_profile_name"] + ids=["success_with_all_fields", "success_without_profile_name"], ) -def test_validate_metadata_success(client: FlaskClient, payload: dict, status_code: int, - response_json: dict, profiles_path: str): - with patch("app.ro_crates.routes.post_routes.queue_ro_crate_metadata_validation_task") as mock_queue: +def test_validate_metadata_success( + client: FlaskClient, + payload: dict, + status_code: int, + response_json: dict, + profiles_path: str, +): + with patch( + "app.ro_crates.routes.post_routes.queue_ro_crate_metadata_validation_task" + ) as mock_queue: mock_queue.return_value = (response_json, status_code) response = client.post("/v1/ro_crates/validate_metadata", json=payload) @@ -180,7 +235,9 @@ def test_validate_metadata_success(client: FlaskClient, payload: dict, status_co crate_json = payload["crate_json"] if "crate_json" in payload else None profile_name = payload["profile_name"] if "profile_name" in payload else None - mock_queue.assert_called_once_with(crate_json, profile_name, profiles_path=profiles_path) + mock_queue.assert_called_once_with( + crate_json, profile_name, profiles_path=profiles_path + ) assert response.status_code == status_code assert response.json == response_json @@ -188,31 +245,39 @@ def test_validate_metadata_success(client: FlaskClient, payload: dict, status_co @pytest.mark.parametrize( "payload, status_code, response_text", [ + ({"profile_name": "default"}, 422, "Missing data for required field"), ( { - "profile_name": "default" - }, 422, "Missing data for required field" - ), - ( - { - "crate_json": '', - }, 422, "Missing required parameter" + "crate_json": "", + }, + 422, + "Missing required parameter", ), ( { - "crate_json": '{', - }, 422, "not valid JSON" + "crate_json": "{", + }, + 422, + "not valid JSON", ), ( { - "crate_json": '{}', - }, 422, "Required parameter crate_json is empty" + "crate_json": "{}", + }, + 422, + "Required parameter crate_json is empty", ), ], - ids=["failure_missing_crate", "failure_empty_crate", - "failure_malformed_crate", "failure_empty_crate"] + ids=[ + "failure_missing_crate", + "failure_empty_crate", + "failure_malformed_crate", + "failure_empty_crate", + ], ) -def test_validate_metadata_failure(client: FlaskClient, payload: dict, status_code: int, response_text: str): +def test_validate_metadata_failure( + client: FlaskClient, payload: dict, status_code: int, response_text: str +): response = client.post("/v1/ro_crates/validate_metadata", json=payload) assert response.status_code == status_code assert response_text in response.get_data(as_text=True) @@ -220,36 +285,43 @@ def test_validate_metadata_failure(client: FlaskClient, payload: dict, status_co # Test GET API: /v1/ro_crates/{crate_id}/validation + @pytest.mark.parametrize( "crate_id, payload, status_code", [ ( - "", { + "", + { "minio_config": { "endpoint": "localhost:9000", "accesskey": "admin", "secret": "password123", "ssl": False, - "bucket": "test_bucket" + "bucket": "test_bucket", }, - "root_path": "base_path" - }, 404 + "root_path": "base_path", + }, + 404, ), ( - "crate-123", { + "crate-123", + { "minio_config": { "endpoint": "localhost:9000", "accesskey": "admin", "secret": "password123", "ssl": False, }, - "root_path": "base_path" - }, 422 + "root_path": "base_path", + }, + 422, ), ], - ids=["failure_missing_crate_id", "failure_missing_minio_bucket"] + ids=["failure_missing_crate_id", "failure_missing_minio_bucket"], ) -def test_get_validation_by_id_failures(minio_client: FlaskClient, crate_id: str, payload: dict, status_code: int): +def test_get_validation_by_id_failures( + minio_client: FlaskClient, crate_id: str, payload: dict, status_code: int +): response = minio_client.get(f"/v1/ro_crates/{crate_id}/validation", json=payload) assert response.status_code == status_code @@ -262,19 +334,25 @@ def test_get_validation_by_id_success(minio_client): "accesskey": "admin", "secret": "password123", "ssl": False, - "bucket": "test_bucket" + "bucket": "test_bucket", }, - "root_path": "base_path" + "root_path": "base_path", } - with patch("app.ro_crates.routes.get_routes.get_ro_crate_validation_task") as mock_get: + with patch( + "app.ro_crates.routes.get_routes.get_ro_crate_validation_task" + ) as mock_get: mock_get.return_value = ({"status": "valid"}, 200) - response = minio_client.get(f"/v1/ro_crates/{crate_id}/validation", json=payload) + response = minio_client.get( + f"/v1/ro_crates/{crate_id}/validation", json=payload + ) assert response.status_code == 200 assert response.json == {"status": "valid"} - mock_get.assert_called_once_with(payload["minio_config"], "crate-123", "base_path") + mock_get.assert_called_once_with( + payload["minio_config"], "crate-123", "base_path" + ) def test_get_validation_by_id_missing_root_path(minio_client): @@ -285,14 +363,18 @@ def test_get_validation_by_id_missing_root_path(minio_client): "accesskey": "admin", "secret": "password123", "ssl": False, - "bucket": "test_bucket" + "bucket": "test_bucket", } } - with patch("app.ro_crates.routes.get_routes.get_ro_crate_validation_task") as mock_get: + with patch( + "app.ro_crates.routes.get_routes.get_ro_crate_validation_task" + ) as mock_get: mock_get.return_value = ({"status": "valid"}, 200) - response = minio_client.get(f"/v1/ro_crates/{crate_id}/validation", json=payload) + response = minio_client.get( + f"/v1/ro_crates/{crate_id}/validation", json=payload + ) assert response.status_code == 200 assert response.json == {"status": "valid"} @@ -301,6 +383,7 @@ def test_get_validation_by_id_missing_root_path(minio_client): # Test MinIO-backed endpoints are unavailable when MinIO is disabled (the default) + def test_minio_post_route_not_registered_when_disabled(client: FlaskClient): payload = { "minio_config": { @@ -308,7 +391,7 @@ def test_minio_post_route_not_registered_when_disabled(client: FlaskClient): "accesskey": "admin", "secret": "password123", "ssl": False, - "bucket": "test_bucket" + "bucket": "test_bucket", } } response = client.post("/v1/ro_crates/crate-123/validation", json=payload) @@ -322,7 +405,7 @@ def test_minio_get_route_not_registered_when_disabled(client: FlaskClient): "accesskey": "admin", "secret": "password123", "ssl": False, - "bucket": "test_bucket" + "bucket": "test_bucket", } } response = client.get("/v1/ro_crates/crate-123/validation", json=payload) @@ -331,7 +414,9 @@ def test_minio_get_route_not_registered_when_disabled(client: FlaskClient): def test_metadata_route_available_when_minio_disabled(client: FlaskClient): payload = {"crate_json": '{"@context": "https://w3id.org/ro/crate/1.1/context"}'} - with patch("app.ro_crates.routes.post_routes.queue_ro_crate_metadata_validation_task") as mock_queue: + with patch( + "app.ro_crates.routes.post_routes.queue_ro_crate_metadata_validation_task" + ) as mock_queue: mock_queue.return_value = ({"status": "success"}, 200) response = client.post("/v1/ro_crates/validate_metadata", json=payload) diff --git a/tests/test_app_factory.py b/tests/test_app_factory.py new file mode 100644 index 0000000..9482a43 --- /dev/null +++ b/tests/test_app_factory.py @@ -0,0 +1,61 @@ +"""Tests for the application factory's configuration.""" + +import pytest + +from app import create_app +from app.utils.config import Settings, ConfigError + + +def _storage_env() -> dict: + return { + "STORAGE_ENABLED": "true", + "S3_ENDPOINT": "minio:9000", + "S3_ACCESS_KEY": "minioadmin", + "S3_SECRET_KEY": "minioadmin", + "S3_BUCKET": "ro-crates", + "CELERY_BROKER_URL": "redis://redis:6379/0", + "CELERY_RESULT_BACKEND": "redis://redis:6379/1", + } + + +def _route_paths(app) -> set: + return {rule.rule for rule in app.url_map.iter_rules()} + + +def test_create_app_fails_fast_on_invalid_storage_config(monkeypatch): + """The default startup path validates config and refuses to start when broken.""" + monkeypatch.setenv("STORAGE_ENABLED", "true") + for var in ( + "S3_ENDPOINT", + "S3_ACCESS_KEY", + "S3_SECRET_KEY", + "S3_BUCKET", + "CELERY_BROKER_URL", + "CELERY_RESULT_BACKEND", + ): + monkeypatch.delenv(var, raising=False) + + with pytest.raises(ConfigError): + create_app() + + +def test_storage_routes_absent_when_disabled(): + app = create_app(settings=Settings.from_env({})) + + paths = _route_paths(app) + assert "/v1/ro_crates/validate_metadata" in paths + assert not any("validation" in p for p in paths) + assert app.config["STORAGE_ENABLED"] is False + + +def test_storage_routes_registered_when_enabled(): + app = create_app(settings=Settings.from_env(_storage_env())) + + paths = _route_paths(app) + assert any(p.endswith("/validation") for p in paths) + assert app.config["STORAGE_ENABLED"] is True + + +def test_profiles_path_exposed_to_app_config(): + app = create_app(settings=Settings.from_env({"PROFILES_PATH": "/custom/profiles"})) + assert app.config["PROFILES_PATH"] == "/custom/profiles" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..ebe9226 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,96 @@ +"""Tests for the validated Settings configuration object.""" + +import pytest + +from app.utils.config import Settings, ConfigError + + +def test_defaults_when_storage_disabled(): + """With storage off, S3/Celery vars are not required and sensible defaults apply.""" + settings = Settings.from_env({}) + + assert settings.storage_enabled is False + assert settings.flask_env == "development" + assert settings.debug is True + assert settings.profiles_path is None + + +def test_storage_enabled_requires_s3_and_broker_config(): + """Enabling storage without the needed vars fails early and naming every missing var.""" + with pytest.raises(ConfigError) as exc_info: + Settings.from_env({"STORAGE_ENABLED": "true"}) + + message = str(exc_info.value) + for var in ( + "S3_ENDPOINT", + "S3_ACCESS_KEY", + "S3_SECRET_KEY", + "S3_BUCKET", + "CELERY_BROKER_URL", + "CELERY_RESULT_BACKEND", + ): + assert var in message + + +def test_blank_required_value_is_treated_as_missing(): + """A whitespace-only required var counts as missing, not as a valid value.""" + env = _storage_env() + env["S3_BUCKET"] = " " + + with pytest.raises(ConfigError) as exc_info: + Settings.from_env(env) + + assert "S3_BUCKET" in str(exc_info.value) + + +def test_valid_storage_config_populates_fields(): + """A complete storage config loads cleanly and parses booleans.""" + env = _storage_env() + env["S3_USE_SSL"] = "true" + + settings = Settings.from_env(env) + + assert settings.storage_enabled is True + assert settings.s3_endpoint == "minio:9000" + assert settings.s3_bucket == "ro-crates" + assert settings.s3_use_ssl is True + assert settings.celery_broker_url == "redis://redis:6379/0" + + +def test_flask_env_production_disables_debug(): + settings = Settings.from_env({"FLASK_ENV": "production"}) + assert settings.flask_env == "production" + assert settings.debug is False + + +@pytest.mark.parametrize( + "raw, expected", + [ + ("true", True), + ("1", True), + ("yes", True), + ("on", True), + ("false", False), + ("no", False), + ("", False), + ("anything", False), + ], +) +def test_storage_enabled_boolean_parsing(raw, expected): + # Truthy values require a complete storage config; falsy values need nothing. + env = _storage_env() if expected else {} + env["STORAGE_ENABLED"] = raw + assert Settings.from_env(env).storage_enabled is expected + + +def _storage_env() -> dict: + """Returns a complete, valid storage-enabled environment for tests to mutate.""" + return { + "STORAGE_ENABLED": "true", + "S3_ENDPOINT": "minio:9000", + "S3_ACCESS_KEY": "minioadmin", + "S3_SECRET_KEY": "minioadmin", + "S3_BUCKET": "ro-crates", + "CELERY_BROKER_URL": "redis://redis:6379/0", + "CELERY_RESULT_BACKEND": "redis://redis:6379/1", + } diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..99244f9 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,61 @@ +"""Tests for the storage abstraction and its in-memory fake.""" + +import pytest + +from app.storage.base import StorageBackend, ObjectStat +from app.storage.errors import ObjectNotFound +from app.storage.memory import InMemoryStorage + + +@pytest.fixture +def storage() -> InMemoryStorage: + return InMemoryStorage() + + +def test_put_then_get_round_trips_bytes(storage): + storage.put_bytes("crates/foo.zip", b"payload") + assert storage.get_bytes("crates/foo.zip") == b"payload" + + +def test_get_missing_key_raises_object_not_found(storage): + with pytest.raises(ObjectNotFound): + storage.get_bytes("crates/missing.zip") + + +def test_stat_returns_size_for_existing_object(storage): + storage.put_bytes("crates/foo.zip", b"12345") + stat = storage.stat("crates/foo.zip") + assert isinstance(stat, ObjectStat) + assert stat.key == "crates/foo.zip" + assert stat.size == 5 + + +def test_stat_missing_key_raises_object_not_found(storage): + with pytest.raises(ObjectNotFound): + storage.stat("crates/missing.zip") + + +def test_list_returns_only_keys_under_prefix(storage): + storage.put_bytes("crates/a/ro-crate-metadata.json", b"{}") + storage.put_bytes("crates/a/data.csv", b"x") + storage.put_bytes("crates/b.zip", b"y") + storage.put_bytes("results/a.json", b"z") + + assert storage.list("crates/a/") == [ + "crates/a/data.csv", + "crates/a/ro-crate-metadata.json", + ] + + +def test_download_tree_preserves_relative_structure(storage, tmp_path): + storage.put_bytes("crates/a/ro-crate-metadata.json", b"{}") + storage.put_bytes("crates/a/sub/data.csv", b"col\n1\n") + + storage.download_tree("crates/a/", str(tmp_path)) + + assert (tmp_path / "ro-crate-metadata.json").read_bytes() == b"{}" + assert (tmp_path / "sub" / "data.csv").read_bytes() == b"col\n1\n" + + +def test_in_memory_storage_satisfies_protocol(storage): + assert isinstance(storage, StorageBackend) From a3aa2e7c36ec103020c142ee8dccdc4e9e15ae6f Mon Sep 17 00:00:00 2001 From: Alex Hambley <33315205+alexhambley@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:39:26 +0100 Subject: [PATCH 02/16] feat(storage): add boto3 S3Backend Implement S3Backend using boto3. Supports MinIO SDK features such as pagination. Includes tests Refs #172 --- .github/workflows/unit_tests.yml | 2 +- app/storage/s3.py | 107 +++++++++++++++++++++++++++++++ requirements.in | 1 + requirements.txt | 19 +++++- tests/test_storage_s3.py | 97 ++++++++++++++++++++++++++++ 5 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 app/storage/s3.py create mode 100644 tests/test_storage_s3.py diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 7c0a996..8db5c6a 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -21,7 +21,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt - pip install pytest pytest-mock + pip install pytest pytest-mock "moto[s3]" - name: Run tests (excluding integration tests) run: | diff --git a/app/storage/s3.py b/app/storage/s3.py new file mode 100644 index 0000000..4f755c4 --- /dev/null +++ b/app/storage/s3.py @@ -0,0 +1,107 @@ +"""A boto3-backed StorageBackend for any S3-compatible object store. + +Works against AWS S3, MinIO, RustFS, Ceph, and similar via an explicit +``endpoint_url``. Backend-specific failures are translated into the storage +error vocabulary so callers never see botocore exceptions. +""" + +import os + +from typing import List, Optional + +import boto3 +from botocore.exceptions import BotoCoreError, ClientError + +from app.storage.base import ObjectStat +from app.storage.errors import ObjectNotFound, StorageError + +# botocore error codes that mean "this key isn't here", as opposed to a +# transport/auth/bucket failure. +_NOT_FOUND_CODES = {"404", "NoSuchKey"} + + +class S3Backend: + """StorageBackend implementation over a boto3 S3 client.""" + + def __init__(self, client, bucket: str) -> None: + self._client = client + self.bucket = bucket + + @classmethod + def from_settings(cls, settings) -> "S3Backend": + """Build a backend from validated :class:`Settings`. + + The endpoint is taken verbatim and prefixed with the scheme implied by + ``s3_use_ssl`` so the same config drives AWS or a self-hosted store. + """ + scheme = "https" if settings.s3_use_ssl else "http" + client = boto3.client( + "s3", + endpoint_url=f"{scheme}://{settings.s3_endpoint}", + aws_access_key_id=settings.s3_access_key, + aws_secret_access_key=settings.s3_secret_key, + region_name=settings.s3_region or "us-east-1", + use_ssl=settings.s3_use_ssl, + ) + return cls(client, settings.s3_bucket) + + def stat(self, key: str) -> ObjectStat: + try: + response = self._client.head_object(Bucket=self.bucket, Key=key) + except ClientError as error: + raise self._translate(error, key) + except BotoCoreError as error: + raise StorageError(f"Storage error for {key}: {error}") from error + return ObjectStat(key=key, size=response["ContentLength"]) + + def get_bytes(self, key: str) -> bytes: + try: + response = self._client.get_object(Bucket=self.bucket, Key=key) + return response["Body"].read() + except ClientError as error: + raise self._translate(error, key) + except BotoCoreError as error: + raise StorageError(f"Storage error for {key}: {error}") from error + + def put_bytes( + self, key: str, data: bytes, content_type: Optional[str] = None + ) -> None: + kwargs = {"Bucket": self.bucket, "Key": key, "Body": data} + if content_type: + kwargs["ContentType"] = content_type + try: + self._client.put_object(**kwargs) + except (ClientError, BotoCoreError) as error: + raise StorageError(f"Failed to store {key}: {error}") from error + + def list(self, prefix: str) -> List[str]: + keys: List[str] = [] + try: + paginator = self._client.get_paginator("list_objects_v2") + for page in paginator.paginate(Bucket=self.bucket, Prefix=prefix): + keys.extend(obj["Key"] for obj in page.get("Contents", [])) + except (ClientError, BotoCoreError) as error: + raise StorageError(f"Failed to list {prefix}: {error}") from error + return sorted(keys) + + def download_tree(self, prefix: str, dest_dir: str) -> None: + for key in self.list(prefix): + relative_path = key[len(prefix) :] + local_path = os.path.join(dest_dir, *relative_path.split("/")) + os.makedirs(os.path.dirname(local_path), exist_ok=True) + with open(local_path, "wb") as handle: + handle.write(self.get_bytes(key)) + + @staticmethod + def _translate(error: ClientError, key: str) -> StorageError: + """Map a botocore ClientError to the storage error vocabulary. + + Only a missing *object* becomes ``ObjectNotFound``; a missing bucket or + an auth failure is an infrastructure problem and stays a ``StorageError``. + ``head_object`` reports a missing key with code ``"404"`` (no error body); + ``get_object`` reports ``"NoSuchKey"``. + """ + code = error.response.get("Error", {}).get("Code", "") + if code in _NOT_FOUND_CODES: + return ObjectNotFound(key) + return StorageError(f"Storage error for {key}: {error}") diff --git a/requirements.in b/requirements.in index df00c22..13c8cf3 100644 --- a/requirements.in +++ b/requirements.in @@ -1,4 +1,5 @@ celery==5.6.3 +boto3==1.43.29 minio==7.2.20 requests==2.33.1 Flask==3.1.3 diff --git a/requirements.txt b/requirements.txt index b8f8d34..a77a518 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,8 @@ argon2-cffi==25.1.0 # via minio argon2-cffi-bindings==25.1.0 # via argon2-cffi +async-timeout==5.0.1 + # via redis attrs==25.3.0 # via # cattrs @@ -24,6 +26,12 @@ billiard==4.2.1 # via celery blinker==1.9.0 # via flask +boto3==1.43.29 + # via -r requirements.in +botocore==1.43.29 + # via + # boto3 + # s3transfer cattrs==25.1.1 # via requests-cache celery==5.6.3 @@ -84,6 +92,10 @@ itsdangerous==2.2.0 # via flask jinja2==3.1.6 # via flask +jmespath==1.1.0 + # via + # boto3 + # botocore kombu==5.6.2 # via celery markdown-it-py==3.0.0 @@ -137,7 +149,9 @@ pyparsing==3.2.3 pyshacl==0.30.1 # via roc-validator python-dateutil==2.9.0.post0 - # via celery + # via + # botocore + # celery python-dotenv==1.2.2 # via -r requirements.in rdflib[html]==7.1.4 @@ -162,6 +176,8 @@ rich-click==1.8.9 # via roc-validator roc-validator==0.9.0 # via -r requirements.in +s3transfer==0.18.0 + # via boto3 six==1.17.0 # via python-dateutil toml==0.10.2 @@ -187,6 +203,7 @@ url-normalize==2.2.1 # via requests-cache urllib3==2.6.3 # via + # botocore # minio # requests # requests-cache diff --git a/tests/test_storage_s3.py b/tests/test_storage_s3.py new file mode 100644 index 0000000..3d8660e --- /dev/null +++ b/tests/test_storage_s3.py @@ -0,0 +1,97 @@ +"""Tests for the boto3-backed S3 storage backend, tested against moto.""" + +import boto3 +import pytest +from moto import mock_aws + +from app.storage.base import StorageBackend +from app.storage.errors import ObjectNotFound, StorageError +from app.storage.s3 import S3Backend +from app.utils.config import Settings + +BUCKET = "test-bucket" + + +@pytest.fixture +def s3_backend(): + with mock_aws(): + client = boto3.client("s3", region_name="us-east-1") + client.create_bucket(Bucket=BUCKET) + yield S3Backend(client, BUCKET) + + +def test_put_then_get_round_trips_bytes(s3_backend): + s3_backend.put_bytes("crates/foo.zip", b"payload") + assert s3_backend.get_bytes("crates/foo.zip") == b"payload" + + +def test_get_missing_key_raises_object_not_found(s3_backend): + with pytest.raises(ObjectNotFound): + s3_backend.get_bytes("crates/missing.zip") + + +def test_stat_returns_size_for_existing_object(s3_backend): + s3_backend.put_bytes("crates/foo.zip", b"12345") + stat = s3_backend.stat("crates/foo.zip") + assert stat.key == "crates/foo.zip" + assert stat.size == 5 + + +def test_stat_missing_key_raises_object_not_found(s3_backend): + with pytest.raises(ObjectNotFound): + s3_backend.stat("crates/missing.zip") + + +def test_list_returns_only_keys_under_prefix_sorted(s3_backend): + s3_backend.put_bytes("crates/a/ro-crate-metadata.json", b"{}") + s3_backend.put_bytes("crates/a/data.csv", b"x") + s3_backend.put_bytes("crates/b.zip", b"y") + s3_backend.put_bytes("results/a.json", b"z") + + assert s3_backend.list("crates/a/") == [ + "crates/a/data.csv", + "crates/a/ro-crate-metadata.json", + ] + + +def test_list_paginates_beyond_one_thousand_objects(s3_backend): + for i in range(1500): + s3_backend.put_bytes(f"many/{i:04d}.txt", b"x") + assert len(s3_backend.list("many/")) == 1500 + + +def test_download_tree_preserves_relative_structure(s3_backend, tmp_path): + s3_backend.put_bytes("crates/a/ro-crate-metadata.json", b"{}") + s3_backend.put_bytes("crates/a/sub/data.csv", b"col\n1\n") + + s3_backend.download_tree("crates/a/", str(tmp_path)) + + assert (tmp_path / "ro-crate-metadata.json").read_bytes() == b"{}" + assert (tmp_path / "sub" / "data.csv").read_bytes() == b"col\n1\n" + + +def test_non_missing_client_error_becomes_storage_error(s3_backend): + """A failure other than a missing key surfaces as StorageError, not ObjectNotFound.""" + broken = S3Backend(s3_backend._client, "nonexistent-bucket") + with pytest.raises(StorageError) as exc_info: + broken.get_bytes("whatever") + assert not isinstance(exc_info.value, ObjectNotFound) + + +def test_s3_backend_satisfies_protocol(s3_backend): + assert isinstance(s3_backend, StorageBackend) + + +def test_from_settings_builds_backend_for_s3_compatible_endpoint(): + env = { + "STORAGE_ENABLED": "true", + "S3_ENDPOINT": "minio:9000", + "S3_ACCESS_KEY": "minioadmin", + "S3_SECRET_KEY": "minioadmin", + "S3_BUCKET": "ro-crates", + "CELERY_BROKER_URL": "redis://redis:6379/0", + "CELERY_RESULT_BACKEND": "redis://redis:6379/1", + } + backend = S3Backend.from_settings(Settings.from_env(env)) + assert isinstance(backend, StorageBackend) + assert backend.bucket == "ro-crates" From 837c74dd1f1a6d9dd4c960cdaa96a36ce121bd58 Mon Sep 17 00:00:00 2001 From: Alex Hambley <33315205+alexhambley@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:41:54 +0100 Subject: [PATCH 03/16] feat(crates): add crate-ID validation Add app/crates/ids.py: validate_crate_id / is_valid_crate_id and an InvalidCrateId exception. IDs are constrained to a safe charset (start alphanumeric, [A-Za-z0-9._-], max 128, no '/' or '..'). Fixes the ".zip in crate ID" fragility. IDs are no longer parsed. Closes #174 --- app/crates/__init__.py | 1 + app/crates/ids.py | 37 ++++++++++++++++++++++++++++ tests/test_crate_ids.py | 54 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 app/crates/__init__.py create mode 100644 app/crates/ids.py create mode 100644 tests/test_crate_ids.py diff --git a/app/crates/__init__.py b/app/crates/__init__.py new file mode 100644 index 0000000..2415d5e --- /dev/null +++ b/app/crates/__init__.py @@ -0,0 +1 @@ +"""Crate identification, layout, and resolution within object storage.""" diff --git a/app/crates/ids.py b/app/crates/ids.py new file mode 100644 index 0000000..973ceed --- /dev/null +++ b/app/crates/ids.py @@ -0,0 +1,37 @@ +"""Strict validation for crate identifiers. + +A crate ID is treated as a single-segment label. Constraining it to a safe charset +means it can be composed into object keys and local paths without risk of collisions +or traversal, and removes the need to parse meaning (such as a ``.zip`` suffix) back +out of it. +""" + +import re + +# Start with an alphanumeric (so no leading dot/dash), then up to 127 more of a +# restricted set. No slashes, whitespace, or non-ASCII; max length 128. +_CRATE_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") + + +class InvalidCrateId(ValueError): + """Raised when a crate ID does not meet the required format.""" + + +def is_valid_crate_id(crate_id: object) -> bool: + """Return whether ``crate_id`` is a well-formed crate identifier.""" + if not isinstance(crate_id, str): + return False + if ".." in crate_id: + return False + return _CRATE_ID_PATTERN.match(crate_id) is not None + + +def validate_crate_id(crate_id: object) -> str: + """Return ``crate_id`` unchanged if valid, else raise :class:`InvalidCrateId`.""" + if not is_valid_crate_id(crate_id): + raise InvalidCrateId( + f"Invalid crate ID: {crate_id!r}. Crate IDs must start with a letter " + "or digit and contain only letters, digits, '.', '_' or '-' " + "(max 128 characters, no '/' or '..')." + ) + return crate_id diff --git a/tests/test_crate_ids.py b/tests/test_crate_ids.py new file mode 100644 index 0000000..f91f247 --- /dev/null +++ b/tests/test_crate_ids.py @@ -0,0 +1,54 @@ +"""Tests for strict crate-ID validation.""" + +import pytest + +from app.crates.ids import validate_crate_id, is_valid_crate_id, InvalidCrateId + + +@pytest.mark.parametrize( + "crate_id", + [ + "a", + "crate-123", + "my_crate.v2", + "ABC.def-123_456", + "release.zip", # ".zip" in the ID is harmless now: IDs are opaque + "x" * 128, # max length + ], +) +def test_valid_ids_are_accepted(crate_id): + assert validate_crate_id(crate_id) == crate_id + assert is_valid_crate_id(crate_id) is True + + +@pytest.mark.parametrize( + "crate_id", + [ + "", # empty + ".hidden", # leading dot + "-leading-dash", # must start alphanumeric + "a/b", # path separator + "../etc/passwd", # traversal + "a..b", # parent-dir sequence + "with space", # whitespace + "tab\tchar", # control char + "x" * 129, # too long + "unicodé", # non-ASCII + ], +) +def test_invalid_ids_are_rejected(crate_id): + assert is_valid_crate_id(crate_id) is False + with pytest.raises(InvalidCrateId): + validate_crate_id(crate_id) + + +def test_non_string_is_rejected(): + assert is_valid_crate_id(None) is False + with pytest.raises(InvalidCrateId): + validate_crate_id(None) + + +def test_error_message_names_the_offending_id(): + with pytest.raises(InvalidCrateId) as exc_info: + validate_crate_id("a/b") + assert "a/b" in str(exc_info.value) From 70ed8e73c6d9da25f61cdaf4f284ab38374b3ba9 Mon Sep 17 00:00:00 2001 From: Alex Hambley <33315205+alexhambley@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:10:15 +0100 Subject: [PATCH 04/16] feat(crates): deterministic crate resolver Add app/crates/layout.py (separate crate/result key prefixes) and resolver.py. `resolve_crate()` locates a crate by direct stat checks on "canonical keys" instead of prefix-listing. - Distinguishes zip vs directory, - confirms ro-crate-metadata.json for directories, and - reports "AmbiguousCrate" / "CrateNotFound" - Fixes sibling false-match, .zip-in-id, and some missing-metadata gaps. Closes #175 --- app/crates/layout.py | 40 ++++++++++++++++ app/crates/resolver.py | 76 +++++++++++++++++++++++++++++ tests/test_crate_layout.py | 33 +++++++++++++ tests/test_crate_resolver.py | 92 ++++++++++++++++++++++++++++++++++++ 4 files changed, 241 insertions(+) create mode 100644 app/crates/layout.py create mode 100644 app/crates/resolver.py create mode 100644 tests/test_crate_layout.py create mode 100644 tests/test_crate_resolver.py diff --git a/app/crates/layout.py b/app/crates/layout.py new file mode 100644 index 0000000..d3e4649 --- /dev/null +++ b/app/crates/layout.py @@ -0,0 +1,40 @@ +"""Canonical object-key layout for crates and their validation results. + +A single place defines where crates and results live in the bucket. Crates and +results use *separate* prefixes so a result object can never collide with, or be +mistaken for, a crate object. + +Layout (given a ``crate_prefix`` and ``results_prefix``): + +- Crate (zip): ``{crate_prefix}/{id}.zip`` +- Crate (directory): ``{crate_prefix}/{id}/`` containing ``ro-crate-metadata.json`` +- Validation result: ``{results_prefix}/{id}.json`` +""" + +METADATA_FILENAME = "ro-crate-metadata.json" + + +def _join(prefix: str, suffix: str) -> str: + """Join an optional prefix and a suffix with a single separator.""" + prefix = prefix.strip("/") + return f"{prefix}/{suffix}" if prefix else suffix + + +def crate_zip_key(crate_prefix: str, crate_id: str) -> str: + """Object key for a crate stored as a zip archive.""" + return _join(crate_prefix, f"{crate_id}.zip") + + +def crate_dir_prefix(crate_prefix: str, crate_id: str) -> str: + """Key prefix (with trailing slash) for a crate stored as a directory.""" + return _join(crate_prefix, f"{crate_id}/") + + +def crate_metadata_key(crate_prefix: str, crate_id: str) -> str: + """Object key for the metadata file inside a directory-style crate.""" + return _join(crate_prefix, f"{crate_id}/{METADATA_FILENAME}") + + +def result_key(results_prefix: str, crate_id: str) -> str: + """Object key for a crate's stored validation result.""" + return _join(results_prefix, f"{crate_id}.json") diff --git a/app/crates/resolver.py b/app/crates/resolver.py new file mode 100644 index 0000000..c6e22f5 --- /dev/null +++ b/app/crates/resolver.py @@ -0,0 +1,76 @@ +"""Deterministic crate resolution over a StorageBackend. + +Resolution is by direct existence checks on canonical keys, never by listing a +prefix and substring-matching. This removes the previous fragilities: it cannot +false-match a sibling prefix, it confirms a directory crate actually contains +``ro-crate-metadata.json``, and it treats the ID as opaque (so a ``.zip`` in the +ID is harmless). Ambiguity and absence are reported explicitly. +""" + +from dataclasses import dataclass + +from app.crates.ids import validate_crate_id +from app.crates.layout import crate_zip_key, crate_dir_prefix, crate_metadata_key +from app.storage.base import StorageBackend +from app.storage.errors import ObjectNotFound + + +@dataclass(frozen=True) +class ResolvedCrate: + """A crate located in storage. + + ``key`` is the zip object key for a zip crate, or the directory prefix + (with trailing slash) for a directory crate. + """ + + crate_id: str + key: str + is_zip: bool + + +class CrateNotFound(Exception): + """Raised when no crate exists for the given ID.""" + + +class AmbiguousCrate(Exception): + """Raised when both a zip and a directory crate exist for the same ID.""" + + +def _object_exists(storage: StorageBackend, key: str) -> bool: + try: + storage.stat(key) + return True + except ObjectNotFound: + return False + + +def resolve_crate( + storage: StorageBackend, crate_id: str, crate_prefix: str +) -> ResolvedCrate: + """Resolve ``crate_id`` to a concrete crate object. + + :raises InvalidCrateId: If the ID is malformed. + :raises AmbiguousCrate: If both zip and directory forms exist. + :raises CrateNotFound: If neither form exists. + """ + validate_crate_id(crate_id) + + zip_key = crate_zip_key(crate_prefix, crate_id) + metadata_key = crate_metadata_key(crate_prefix, crate_id) + + zip_exists = _object_exists(storage, zip_key) + directory_exists = _object_exists(storage, metadata_key) + + if zip_exists and directory_exists: + raise AmbiguousCrate( + f"Crate {crate_id!r} exists as both a zip and a directory; refusing to guess." + ) + if zip_exists: + return ResolvedCrate(crate_id=crate_id, key=zip_key, is_zip=True) + if directory_exists: + return ResolvedCrate( + crate_id=crate_id, + key=crate_dir_prefix(crate_prefix, crate_id), + is_zip=False, + ) + raise CrateNotFound(f"No crate found for ID {crate_id!r}") diff --git a/tests/test_crate_layout.py b/tests/test_crate_layout.py new file mode 100644 index 0000000..3c174da --- /dev/null +++ b/tests/test_crate_layout.py @@ -0,0 +1,33 @@ +"""Tests for canonical crate/result key construction.""" + +import pytest + +from app.crates.layout import ( + crate_zip_key, + crate_dir_prefix, + crate_metadata_key, + result_key, +) + + +def test_crate_zip_key_under_prefix(): + assert crate_zip_key("crates", "foo") == "crates/foo.zip" + + +def test_crate_dir_prefix_under_prefix(): + assert crate_dir_prefix("crates", "foo") == "crates/foo/" + + +def test_crate_metadata_key_under_prefix(): + assert crate_metadata_key("crates", "foo") == "crates/foo/ro-crate-metadata.json" + + +def test_result_key_uses_separate_results_prefix(): + assert result_key("validation-results", "foo") == "validation-results/foo.json" + + +@pytest.mark.parametrize("prefix", ["", "crates/"]) +def test_prefix_edge_cases_are_normalised(prefix): + """An empty prefix maps to the bucket root; a trailing slash is not doubled.""" + expected = "foo.zip" if prefix == "" else "crates/foo.zip" + assert crate_zip_key(prefix, "foo") == expected diff --git a/tests/test_crate_resolver.py b/tests/test_crate_resolver.py new file mode 100644 index 0000000..ceffa7c --- /dev/null +++ b/tests/test_crate_resolver.py @@ -0,0 +1,92 @@ +"""Tests for deterministic crate resolution over a StorageBackend.""" + +import pytest + +from app.crates.ids import InvalidCrateId +from app.crates.resolver import ( + resolve_crate, + ResolvedCrate, + CrateNotFound, + AmbiguousCrate, +) +from app.storage.memory import InMemoryStorage + +PREFIX = "crates" + + +@pytest.fixture +def storage() -> InMemoryStorage: + return InMemoryStorage() + + +def test_resolves_zip_crate(storage): + storage.put_bytes("crates/foo.zip", b"PK...") + + resolved = resolve_crate(storage, "foo", PREFIX) + + assert isinstance(resolved, ResolvedCrate) + assert resolved.crate_id == "foo" + assert resolved.is_zip is True + assert resolved.key == "crates/foo.zip" + + +def test_resolves_directory_crate_with_metadata(storage): + storage.put_bytes("crates/foo/ro-crate-metadata.json", b"{}") + storage.put_bytes("crates/foo/data.csv", b"x") + + resolved = resolve_crate(storage, "foo", PREFIX) + + assert resolved.is_zip is False + assert resolved.key == "crates/foo/" + + +def test_directory_without_metadata_is_not_a_crate(storage): + """A directory lacking ro-crate-metadata.json must not resolve (old TODO).""" + storage.put_bytes("crates/foo/data.csv", b"x") + + with pytest.raises(CrateNotFound): + resolve_crate(storage, "foo", PREFIX) + + +def test_ambiguous_when_both_zip_and_directory_exist(storage): + storage.put_bytes("crates/foo.zip", b"PK...") + storage.put_bytes("crates/foo/ro-crate-metadata.json", b"{}") + + with pytest.raises(AmbiguousCrate): + resolve_crate(storage, "foo", PREFIX) + + +def test_missing_crate_raises_not_found(storage): + with pytest.raises(CrateNotFound): + resolve_crate(storage, "absent", PREFIX) + + +def test_sibling_prefix_does_not_false_match(storage): + """Resolving 'foo' must not match 'foobar' (old prefix-substring bug).""" + storage.put_bytes("crates/foobar.zip", b"PK...") + + with pytest.raises(CrateNotFound): + resolve_crate(storage, "foo", PREFIX) + + +def test_zip_suffix_in_id_resolves_as_directory(storage): + """An ID containing '.zip' is opaque: a directory crate named 'data.zip' resolves.""" + storage.put_bytes("crates/data.zip/ro-crate-metadata.json", b"{}") + + resolved = resolve_crate(storage, "data.zip", PREFIX) + + assert resolved.is_zip is False + assert resolved.key == "crates/data.zip/" + + +def test_invalid_id_propagates(storage): + with pytest.raises(InvalidCrateId): + resolve_crate(storage, "../etc", PREFIX) + + +def test_result_object_does_not_satisfy_crate_resolution(storage): + """A stored result under a separate prefix never counts as the crate itself.""" + storage.put_bytes("validation-results/foo.json", b"{}") + + with pytest.raises(CrateNotFound): + resolve_crate(storage, "foo", PREFIX) From ef93bdd4ecb8878bd73ec32dbac006b971cc4fd5 Mon Sep 17 00:00:00 2001 From: Alex Hambley <33315205+alexhambley@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:22:19 +0100 Subject: [PATCH 05/16] feat(validation): add `ValidationOutcome` type and runner - Add `app/validation/results.py` (`ValidationOutcome` with valid/invalid/error status, detail/error, serialisation) and `runner.py` wrapping rocrate_validator so both entry points always return an outcome; - Replaces the ValidationResult|str / isinstance pattern. Closes #176 --- app/validation/__init__.py | 1 + app/validation/results.py | 80 +++++++++++++++++++++++++++++++ app/validation/runner.py | 81 ++++++++++++++++++++++++++++++++ tests/test_validation_outcome.py | 66 ++++++++++++++++++++++++++ tests/test_validation_runner.py | 80 +++++++++++++++++++++++++++++++ 5 files changed, 308 insertions(+) create mode 100644 app/validation/__init__.py create mode 100644 app/validation/results.py create mode 100644 app/validation/runner.py create mode 100644 tests/test_validation_outcome.py create mode 100644 tests/test_validation_runner.py diff --git a/app/validation/__init__.py b/app/validation/__init__.py new file mode 100644 index 0000000..2e275e1 --- /dev/null +++ b/app/validation/__init__.py @@ -0,0 +1 @@ +"""RO-Crate validation: a single outcome type and the runner that produces it.""" diff --git a/app/validation/results.py b/app/validation/results.py new file mode 100644 index 0000000..0de0d86 --- /dev/null +++ b/app/validation/results.py @@ -0,0 +1,80 @@ +"""Defines an explicit result type for validation.""" + +import json + +from dataclasses import dataclass +from enum import Enum +from typing import Optional + + +class ValidationStatus(str, Enum): + """The outcome of a validation run.""" + + VALID = "valid" + INVALID = "invalid" + ERROR = "error" + + +@dataclass(frozen=True) +class ValidationOutcome: + """The result of validating a crate or its metadata. + + ``detail`` holds the validator's report for ``valid``/``invalid`` outcomes; + ``error`` holds the message for an ``error`` outcome. The two are mutually + exclusive. + """ + + status: ValidationStatus + profile: Optional[str] = None + detail: Optional[dict] = None + error: Optional[str] = None + created_at: Optional[str] = None + + @property + def is_valid(self) -> bool: + return self.status is ValidationStatus.VALID + + def to_dict(self) -> dict: + data = { + "status": self.status.value, + "profile": self.profile, + "created_at": self.created_at, + } + if self.detail is not None: + data["detail"] = self.detail + if self.error is not None: + data["error"] = self.error + return data + + def to_json(self) -> str: + return json.dumps(self.to_dict()) + + @classmethod + def from_validator_result( + cls, result, profile: Optional[str] = None, created_at: Optional[str] = None + ) -> "ValidationOutcome": + """Build an outcome from a rocrate_validator ``ValidationResult``.""" + status = ( + ValidationStatus.INVALID if result.has_issues() else ValidationStatus.VALID + ) + return cls( + status=status, + profile=profile, + detail=json.loads(result.to_json()), + created_at=created_at, + ) + + @classmethod + def from_error( + cls, + message: str, + profile: Optional[str] = None, + created_at: Optional[str] = None, + ) -> "ValidationOutcome": + """Build an error outcome from a failure message.""" + return cls( + status=ValidationStatus.ERROR, + profile=profile, + error=message, + created_at=created_at, + ) diff --git a/app/validation/runner.py b/app/validation/runner.py new file mode 100644 index 0000000..e619903 --- /dev/null +++ b/app/validation/runner.py @@ -0,0 +1,81 @@ +"""Runs rocrate_validator and adapts its output to a ValidationOutcome. + +This is the boundary to the external validator. Both entry points always +return a :class:`ValidationOutcome` - a validator exception becomes an ``error`` +outcome rather than a string, so callers never have to type-check the result. +""" + +import logging + +from typing import Optional + +from rocrate_validator import services + +from app.validation.results import ValidationOutcome + +logger = logging.getLogger(__name__) + + +def validate_crate_path( + rocrate_uri: str, + profile_name: Optional[str] = None, + profiles_path: Optional[str] = None, + skip_checks: Optional[list] = None, + created_at: Optional[str] = None, +) -> ValidationOutcome: + """Validate a crate on disk (a directory or zip) at ``rocrate_uri``.""" + return _run( + {"rocrate_uri": rocrate_uri}, + profile_name=profile_name, + profiles_path=profiles_path, + skip_checks=skip_checks, + created_at=created_at, + ) + + +def validate_metadata( + metadata: dict, + profile_name: Optional[str] = None, + profiles_path: Optional[str] = None, + skip_checks: Optional[list] = None, + created_at: Optional[str] = None, +) -> ValidationOutcome: + """Validate an in-memory RO-Crate metadata graph.""" + return _run( + {"metadata_only": True, "metadata_dict": metadata}, + profile_name=profile_name, + profiles_path=profiles_path, + skip_checks=skip_checks, + created_at=created_at, + ) + + +def _run( + base_settings: dict, + profile_name: Optional[str], + profiles_path: Optional[str], + skip_checks: Optional[list], + created_at: Optional[str], +) -> ValidationOutcome: + options = dict(base_settings) + if profile_name: + options["profile_identifier"] = profile_name + if profiles_path: + options["profiles_path"] = profiles_path + if skip_checks: + options["skip_checks"] = skip_checks + + try: + settings = services.ValidationSettings(**options) + result = services.validate(settings) + except ( + Exception + ) as error: # noqa: BLE001 - adapt any validator failure to an outcome + logger.error("Validation failed: %s", error) + return ValidationOutcome.from_error( + str(error), profile=profile_name, created_at=created_at + ) + + return ValidationOutcome.from_validator_result( + result, profile=profile_name, created_at=created_at + ) diff --git a/tests/test_validation_outcome.py b/tests/test_validation_outcome.py new file mode 100644 index 0000000..80d1503 --- /dev/null +++ b/tests/test_validation_outcome.py @@ -0,0 +1,66 @@ +"""Tests for the ValidationOutcome result type.""" + +import json + +from app.validation.results import ValidationOutcome, ValidationStatus + + +class FakeResult: + """Stand-in for a rocrate_validator ValidationResult.""" + + def __init__(self, has_issues: bool, report: dict): + self._has_issues = has_issues + self._report = report + + def has_issues(self) -> bool: + return self._has_issues + + def to_json(self) -> str: + return json.dumps(self._report) + + +def test_from_validator_result_without_issues_is_valid(): + outcome = ValidationOutcome.from_validator_result( + FakeResult(False, {"report": "ok"}), profile="ro-crate" + ) + assert outcome.status is ValidationStatus.VALID + assert outcome.is_valid is True + assert outcome.profile == "ro-crate" + assert outcome.detail == {"report": "ok"} + assert outcome.error is None + + +def test_from_validator_result_with_issues_is_invalid(): + outcome = ValidationOutcome.from_validator_result(FakeResult(True, {"issues": [1]})) + assert outcome.status is ValidationStatus.INVALID + assert outcome.is_valid is False + assert outcome.detail == {"issues": [1]} + + +def test_from_error_records_message_and_has_no_detail(): + outcome = ValidationOutcome.from_error("boom", profile="ro-crate") + assert outcome.status is ValidationStatus.ERROR + assert outcome.is_valid is False + assert outcome.error == "boom" + assert outcome.detail is None + + +def test_to_dict_serialises_status_as_string_and_omits_absent_fields(): + outcome = ValidationOutcome.from_validator_result(FakeResult(False, {"r": 1})) + data = outcome.to_dict() + assert data["status"] == "valid" + assert data["detail"] == {"r": 1} + assert "error" not in data + + +def test_to_json_round_trips(): + outcome = ValidationOutcome.from_error("nope") + parsed = json.loads(outcome.to_json()) + assert parsed["status"] == "error" + assert parsed["error"] == "nope" + + +def test_created_at_is_propagated_when_provided(): + outcome = ValidationOutcome.from_error("x", created_at="2026-06-16T00:00:00Z") + assert outcome.created_at == "2026-06-16T00:00:00Z" + assert outcome.to_dict()["created_at"] == "2026-06-16T00:00:00Z" diff --git a/tests/test_validation_runner.py b/tests/test_validation_runner.py new file mode 100644 index 0000000..bd68c68 --- /dev/null +++ b/tests/test_validation_runner.py @@ -0,0 +1,80 @@ +"""Tests for the validation runner that wraps rocrate_validator.""" + +import json + +import pytest + +from app.validation import runner +from app.validation.results import ValidationStatus + + +class FakeResult: + def __init__(self, has_issues: bool): + self._has_issues = has_issues + + def has_issues(self) -> bool: + return self._has_issues + + def to_json(self) -> str: + return json.dumps({"issues": self._has_issues}) + + +class FakeServices: + """A stand-in for rocrate_validator.services.""" + + def __init__(self, result=None, raises=None): + self._result = result + self._raises = raises + self.last_settings = None + + def ValidationSettings(self, **kwargs): # noqa: N802 - mirrors the real API + self.last_settings = kwargs + return kwargs + + def validate(self, settings): + if self._raises is not None: + raise self._raises + return self._result + + +def test_validate_metadata_success_is_valid(monkeypatch): + fake = FakeServices(result=FakeResult(has_issues=False)) + monkeypatch.setattr(runner, "services", fake) + + outcome = runner.validate_metadata({"@graph": []}, profile_name="ro-crate") + + assert outcome.status is ValidationStatus.VALID + assert outcome.profile == "ro-crate" + assert fake.last_settings["metadata_only"] is True + assert fake.last_settings["metadata_dict"] == {"@graph": []} + + +def test_validate_metadata_with_issues_is_invalid(monkeypatch): + monkeypatch.setattr(runner, "services", FakeServices(result=FakeResult(True))) + outcome = runner.validate_metadata({"@graph": []}) + assert outcome.status is ValidationStatus.INVALID + + +def test_validate_metadata_exception_becomes_error_outcome(monkeypatch): + monkeypatch.setattr(runner, "services", FakeServices(raises=RuntimeError("kaboom"))) + outcome = runner.validate_metadata({"@graph": []}, profile_name="ro-crate") + assert outcome.status is ValidationStatus.ERROR + assert "kaboom" in outcome.error + assert outcome.profile == "ro-crate" + + +def test_validate_crate_path_success(monkeypatch): + fake = FakeServices(result=FakeResult(has_issues=False)) + monkeypatch.setattr(runner, "services", fake) + + outcome = runner.validate_crate_path("/tmp/crate", profile_name="ro-crate") + + assert outcome.status is ValidationStatus.VALID + assert fake.last_settings["rocrate_uri"] == "/tmp/crate" + + +def test_validate_crate_path_exception_becomes_error_outcome(monkeypatch): + monkeypatch.setattr(runner, "services", FakeServices(raises=ValueError("bad crate"))) + outcome = runner.validate_crate_path("/tmp/crate") + assert outcome.status is ValidationStatus.ERROR + assert "bad crate" in outcome.error From db4482cb78424b1272dee9fb9dd07aa31267411d Mon Sep 17 00:00:00 2001 From: Alex Hambley <33315205+alexhambley@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:36:03 +0100 Subject: [PATCH 06/16] refactor(validation): make metadata validation synchronous - replaces metadata path with `run_metadata_validation()`, which validates inline via the runner and returns a `ValidationOutcome` (200 valid/invalid, 422 unvalidatable). - remove the metadata Celery task and `perform_metadata_validation`, which addresses the result.get() blocking and the return-in-finally bug. closes #169 --- app/ro_crates/routes/post_routes.py | 8 +- app/services/validation_service.py | 81 +++++++-------- app/tasks/validation_tasks.py | 96 ----------------- tests/test_api_routes.py | 4 +- tests/test_services.py | 113 ++++++++++---------- tests/test_validation_tasks.py | 155 ---------------------------- 6 files changed, 96 insertions(+), 361 deletions(-) diff --git a/app/ro_crates/routes/post_routes.py b/app/ro_crates/routes/post_routes.py index 5fb1fda..2af2258 100644 --- a/app/ro_crates/routes/post_routes.py +++ b/app/ro_crates/routes/post_routes.py @@ -1,9 +1,5 @@ """Defines post API endpoints for validating RO-Crates using their IDs from MinIO.""" -# Author: Alexander Hambley -# License: MIT -# Copyright (c) 2025 eScience Lab, The University of Manchester - from apiflask import APIBlueprint, Schema from apiflask.fields import String, Boolean from marshmallow.fields import Nested @@ -11,7 +7,7 @@ from app.services.validation_service import ( queue_ro_crate_validation_task, - queue_ro_crate_metadata_validation_task, + run_metadata_validation, ) # Always-on blueprint: @@ -119,6 +115,6 @@ def validate_ro_crate_metadata(json_data) -> tuple[Response, int]: profiles_path = current_app.config["PROFILES_PATH"] - return queue_ro_crate_metadata_validation_task( + return run_metadata_validation( crate_json, profile_name, profiles_path=profiles_path ) diff --git a/app/services/validation_service.py b/app/services/validation_service.py index 37c5c05..8cd40da 100644 --- a/app/services/validation_service.py +++ b/app/services/validation_service.py @@ -1,9 +1,5 @@ """Service methods to queue RO-Crates for validation using the CRS4 validator and Celery.""" -# Author: Alexander Hambley -# License: MIT -# Copyright (c) 2025 eScience Lab, The University of Manchester - import logging import json @@ -11,22 +7,26 @@ from app.tasks.validation_tasks import ( process_validation_task_by_id, - process_validation_task_by_metadata, return_ro_crate_validation, check_ro_crate_exists, - check_validation_exists - ) + check_validation_exists, +) from app.utils.config import InvalidAPIUsage from app.utils.minio_utils import get_minio_client - +from app.validation.runner import validate_metadata +from app.validation.results import ValidationStatus logger = logging.getLogger(__name__) def queue_ro_crate_validation_task( - minio_config, crate_id, root_path=None, profile_name=None, webhook_url=None, - profiles_path=None + minio_config, + crate_id, + root_path=None, + profile_name=None, + webhook_url=None, + profiles_path=None, ) -> tuple[Response, int]: """ Queues an RO-Crate for validation with Celery. @@ -52,55 +52,47 @@ def queue_ro_crate_validation_task( raise InvalidAPIUsage(f"No RO-Crate with prefix: {crate_id}", 400) try: - process_validation_task_by_id.delay(minio_config, crate_id, root_path, - profile_name, webhook_url, profiles_path) + process_validation_task_by_id.delay( + minio_config, crate_id, root_path, profile_name, webhook_url, profiles_path + ) return jsonify({"message": "Validation in progress"}), 202 except Exception as e: return jsonify({"error": str(e)}), 500 -def queue_ro_crate_metadata_validation_task( - crate_json: str, profile_name=None, webhook_url=None, profiles_path=None +def run_metadata_validation( + crate_json: str, profile_name=None, profiles_path=None ) -> tuple[Response, int]: """ - Queues an RO-Crate for validation with Celery. + Validates RO-Crate metadata synchronously and returns the result inline. - :param crate_id: The ID of the RO-Crate to validate. + Metadata-only validation is fast and stateless, so it runs in the request + rather than via Celery. Returns 200 for a valid/invalid outcome and 422 + when the input cannot be validated (bad JSON, empty, or a validator error). + + :param crate_json: The RO-Crate JSON-LD metadata, as a string. :param profile_name: The profile to validate against. - :param webhook_url: The URL to POST the validation results to. :param profiles_path: A path to the profile definition directory. :return: A tuple containing a JSON response and an HTTP status code. - :raises: Exception: If an error occurs whilst queueing the task. """ - logging.info(f"Processing: {crate_json}, {profile_name}, {webhook_url}") - if not crate_json: return jsonify({"error": "Missing required parameter: crate_json"}), 422 try: - json_dict = json.loads(crate_json) - except json.decoder.JSONDecodeError as err: - return jsonify({"error": f"Required parameter crate_json is not valid JSON: {err}"}), 422 - else: - if len(json_dict) == 0: - return jsonify({"error": "Required parameter crate_json is empty"}), 422 + metadata = json.loads(crate_json) + except json.JSONDecodeError as err: + return jsonify({"error": f"crate_json is not valid JSON: {err}"}), 422 - try: - result = process_validation_task_by_metadata.delay( - crate_json, - profile_name, - webhook_url, - profiles_path - ) - if webhook_url: - return jsonify({"message": "Validation in progress"}), 202 - else: - return jsonify({"result": result.get()}), 200 + if not metadata: + return jsonify({"error": "Required parameter crate_json is empty"}), 422 - except Exception as e: - return jsonify({"error": str(e)}), 500 + outcome = validate_metadata( + metadata, profile_name=profile_name, profiles_path=profiles_path + ) + status_code = 422 if outcome.status is ValidationStatus.ERROR else 200 + return jsonify(outcome.to_dict()), status_code def get_ro_crate_validation_task( @@ -127,10 +119,17 @@ def get_ro_crate_validation_task( logging.info("RO-Crate does not exist") raise InvalidAPIUsage(f"No RO-Crate with prefix: {crate_id}", 400) - if check_validation_exists(minio_client, minio_config["bucket"], crate_id, root_path): + if check_validation_exists( + minio_client, minio_config["bucket"], crate_id, root_path + ): logging.info("Validation result exists") else: logging.info("Validation does not exist") raise InvalidAPIUsage(f"No validation result yet for RO-Crate: {crate_id}", 400) - return return_ro_crate_validation(minio_client, minio_config["bucket"], crate_id, root_path), 200 + return ( + return_ro_crate_validation( + minio_client, minio_config["bucket"], crate_id, root_path + ), + 200, + ) diff --git a/app/tasks/validation_tasks.py b/app/tasks/validation_tasks.py index d0d6925..beb6bb3 100644 --- a/app/tasks/validation_tasks.py +++ b/app/tasks/validation_tasks.py @@ -1,13 +1,8 @@ """Tasks and helper methods for processing RO-Crate validation.""" -# Author: Alexander Hambley -# License: MIT -# Copyright (c) 2025 eScience Lab, The University of Manchester - import logging import os import shutil -import json from typing import Optional from rocrate_validator import services @@ -110,61 +105,6 @@ def process_validation_task_by_id( shutil.rmtree(file_path) -@celery.task -def process_validation_task_by_metadata( - crate_json: str, - profile_name: str | None, - webhook_url: str | None, - profiles_path: Optional[str] = None, -) -> ValidationResult | str: - """ - Background task to process the RO-Crate validation for a given json metadata string. - - :param crate_json: A string containing the RO-Crate JSON metadata to validate. - :param profile_name: The name of the validation profile to use. Defaults to None. - :param webhook_url: The webhook URL to send notifications to. Defaults to None. - :param profiles_path: The path to the profiles definition directory. Defaults to None. - :raises Exception: If an error occurs during the validation process. - - :todo: Replace the Crate ID with a more comprehensive system, and replace profile name with URI. - """ - - try: - logging.info("Processing validation task for provided metadata string") - - # Perform validation: - validation_result = perform_metadata_validation( - crate_json, profile_name, profiles_path=profiles_path - ) - - if isinstance(validation_result, str): - logging.error(f"Validation failed: {validation_result}") - # TODO: Send webhook with failure notification - raise Exception(f"Validation failed: {validation_result}") - - if not validation_result.has_issues(): - logging.info("RO Crate metadata is valid.") - else: - logging.info("RO Crate metadata is invalid.") - - if webhook_url: - send_webhook_notification(webhook_url, validation_result.to_json()) - - except Exception as e: - logging.error(f"Error processing validation task: {e}") - - # Send failure notification via webhook - error_data = {"profile_name": profile_name, "error": str(e)} - if webhook_url: - send_webhook_notification(webhook_url, error_data) - - finally: - if isinstance(validation_result, str): - return validation_result - else: - return validation_result.to_json() - - def perform_ro_crate_validation( file_path: str, profile_name: str | None, @@ -206,42 +146,6 @@ def perform_ro_crate_validation( return str(e) -def perform_metadata_validation( - crate_json: str, - profile_name: str | None, - skip_checks_list: Optional[list] = None, - profiles_path: Optional[str] = None, -) -> ValidationResult | str: - """ - Validates only RO-Crate metadata provided as a json string. - - :param crate_json: The JSON string containing the metadata - :param profile_name: The name of the validation profile to use. Defaults to None. If None, the CRS4 validator will - attempt to determine the profile. - :param profiles_path: The path to the profiles definition directory - :param skip_checks_list: A list of checks to skip, if needed - :return: The validation result. - :raises Exception: If an error occurs during the validation process. - """ - - try: - logging.info(f"Validating ro-crate metadata with profile {profile_name}") - - settings = services.ValidationSettings( - **({"metadata_only": True}), - **({"metadata_dict": json.loads(crate_json)}), - **({"profile_identifier": profile_name} if profile_name else {}), - **({"skip_checks": skip_checks_list} if skip_checks_list else {}), - **({"profiles_path": profiles_path} if profiles_path else {}), - ) - - return services.validate(settings) - - except Exception as e: - logging.error(f"Unexpected error during validation: {e}") - return str(e) - - def check_ro_crate_exists( minio_client: object, bucket_name: str, diff --git a/tests/test_api_routes.py b/tests/test_api_routes.py index c826476..a6ac40d 100644 --- a/tests/test_api_routes.py +++ b/tests/test_api_routes.py @@ -226,7 +226,7 @@ def test_validate_metadata_success( profiles_path: str, ): with patch( - "app.ro_crates.routes.post_routes.queue_ro_crate_metadata_validation_task" + "app.ro_crates.routes.post_routes.run_metadata_validation" ) as mock_queue: mock_queue.return_value = (response_json, status_code) @@ -415,7 +415,7 @@ def test_minio_get_route_not_registered_when_disabled(client: FlaskClient): def test_metadata_route_available_when_minio_disabled(client: FlaskClient): payload = {"crate_json": '{"@context": "https://w3id.org/ro/crate/1.1/context"}'} with patch( - "app.ro_crates.routes.post_routes.queue_ro_crate_metadata_validation_task" + "app.ro_crates.routes.post_routes.run_metadata_validation" ) as mock_queue: mock_queue.return_value = ({"status": "success"}, 200) diff --git a/tests/test_services.py b/tests/test_services.py index 0413e17..13af5a6 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -5,9 +5,10 @@ from app.services.validation_service import ( queue_ro_crate_validation_task, - queue_ro_crate_metadata_validation_task, + run_metadata_validation, get_ro_crate_validation_task ) +from app.validation.results import ValidationOutcome, ValidationStatus from app.utils.minio_utils import InvalidAPIUsage @@ -138,71 +139,61 @@ def test_queue_ro_crate_validation_task_failure( mock_delay.assert_not_called() -# Test function: queue_ro_crate_metadata_validation_task +# Test function: run_metadata_validation (synchronous, no Celery) -@pytest.mark.parametrize( - "crate_json, profile, webhook, status_code, return_value, response_json, delay_side_effect, profiles_path", - [ - ( - '{"@context": "https://w3id.org/ro/crate/1.1/context"}', - "default", "http://webhook", - 202, None, {"message": "Validation in progress"}, - None, None - ), - ( - '{"@context": "https://w3id.org/ro/crate/1.1/context"}', - "default", None, - 200, {"status": "ok"}, {"result": {"status": "ok"}}, - None, None - ), - ( - '{"@context": "https://w3id.org/ro/crate/1.1/context"}', - "default", "http://webhook", - 500, None, {"error": "Celery error"}, - Exception("Celery error"), None - ), - ], - ids=["success_with_webhook", "success_without_webhook", "failure_celery_error"] -) -def test_queue_metadata(flask_app, crate_json: dict, profile: str, webhook: str, - status_code: int, return_value: dict, response_json: dict, - delay_side_effect: Exception, profiles_path: str): - with patch("app.services.validation_service.process_validation_task_by_metadata.delay", - side_effect=delay_side_effect) as mock_delay: - mock_result = MagicMock() - if return_value is not None: - mock_result.get.return_value = return_value - if delay_side_effect is None: - mock_delay.return_value = mock_result - - response, status = queue_ro_crate_metadata_validation_task(crate_json, profile, webhook, profiles_path) - - mock_delay.assert_called_once_with(crate_json, profile, webhook, profiles_path) - assert status == status_code - assert response.json == response_json +@patch("app.services.validation_service.validate_metadata") +def test_run_metadata_validation_valid_is_200(mock_validate, flask_app): + mock_validate.return_value = ValidationOutcome( + status=ValidationStatus.VALID, profile="ro-crate", detail={"report": "ok"} + ) + + response, status = run_metadata_validation( + '{"@graph": []}', "ro-crate", "/app/profiles" + ) + + assert status == 200 + assert response.json["status"] == "valid" + mock_validate.assert_called_once_with( + {"@graph": []}, profile_name="ro-crate", profiles_path="/app/profiles" + ) + + +@patch("app.services.validation_service.validate_metadata") +def test_run_metadata_validation_invalid_is_200(mock_validate, flask_app): + mock_validate.return_value = ValidationOutcome( + status=ValidationStatus.INVALID, detail={"issues": [1]} + ) + + response, status = run_metadata_validation('{"@graph": []}') + + assert status == 200 + assert response.json["status"] == "invalid" + + +@patch("app.services.validation_service.validate_metadata") +def test_run_metadata_validation_error_outcome_is_422(mock_validate, flask_app): + mock_validate.return_value = ValidationOutcome.from_error("validator blew up") + + response, status = run_metadata_validation('{"@graph": []}') + + assert status == 422 + assert response.json["status"] == "error" + assert "validator blew up" in response.json["error"] @pytest.mark.parametrize( - "crate_json, status_code, response_error", - [ - ( - None, - 422, "Missing required parameter: crate_json" - ), - ( - "{", - 422, "not valid JSON" - ), - ( - "{}", - 422, "Required parameter crate_json is empty" - ), - ], - ids=["missing_crate_json","invalid_json","empty_json"] + "crate_json, response_error", + [ + (None, "Missing required parameter: crate_json"), + ("", "Missing required parameter: crate_json"), + ("{", "not valid JSON"), + ("{}", "empty"), + ], + ids=["missing", "blank", "invalid_json", "empty_json"], ) -def test_queue_metadata_json_errors(flask_app, crate_json: str, status_code: int, response_error: str): - response, status = queue_ro_crate_metadata_validation_task(crate_json) - assert status == status_code +def test_run_metadata_validation_json_errors(flask_app, crate_json, response_error): + response, status = run_metadata_validation(crate_json) + assert status == 422 assert response_error in response.json["error"] diff --git a/tests/test_validation_tasks.py b/tests/test_validation_tasks.py index 5d36e1d..f93170c 100644 --- a/tests/test_validation_tasks.py +++ b/tests/test_validation_tasks.py @@ -5,9 +5,7 @@ from app.tasks.validation_tasks import ( process_validation_task_by_id, perform_ro_crate_validation, - perform_metadata_validation, return_ro_crate_validation, - process_validation_task_by_metadata, check_ro_crate_exists, check_validation_exists ) @@ -226,97 +224,6 @@ def test_process_validation_failure( mock_remove.assert_not_called() -# Test function: process_validation_task_by_metadata - -@pytest.mark.parametrize( - "crate_json, profile_name, webhook_url, profiles_path, validation_json, validation_value", - [ - ( - '{"@context": "https://w3id.org/ro/crate/1.1/context", "@graph": []}', - "test-profile", "https://example.com/webhook", - "/app/profiles", - '{"status": "valid"}', False - ), - ( - '{"@context": "https://w3id.org/ro/crate/1.1/context", "@graph": []}', - "test-profile", "https://example.com/webhook", - None, - '{"status": "invalid"}', True - ) - ], - ids=["success_no_issues", "success_with_issues"] -) -@mock.patch("app.tasks.validation_tasks.send_webhook_notification") -@mock.patch("app.tasks.validation_tasks.perform_metadata_validation") -def test_metadata_validation( - mock_validate, mock_webhook, - crate_json: str, profile_name: str, webhook_url: str, profiles_path: str | None, - validation_json: str, validation_value: bool, -): - mock_result = mock.Mock() - mock_result.has_issues.return_value = validation_value - mock_result.to_json.return_value = validation_json - mock_validate.return_value = mock_result - - result = process_validation_task_by_metadata( - crate_json, profile_name, webhook_url, profiles_path - ) - - assert result == validation_json - mock_validate.assert_called_once_with( - crate_json, profile_name, profiles_path=profiles_path - ) - mock_webhook.assert_called_once_with(webhook_url, validation_json) - - -@pytest.mark.parametrize( - "crate_json, profile_name, webhook_url, profiles_path, validation_message", - [ - ( - '{"@context": "https://w3id.org/ro/crate/1.1/context", "@graph": []}', - "test-profile", "https://example.com/webhook", - "/app/profiles", - "Validation error" - ), - ( - '{"@context": "https://w3id.org/ro/crate/1.1/context", "@graph": []}', - "test-profile", None, - None, - "Validation error" - ) - ], - ids=["validation_fails", "validation_fails_no_webhook"] -) -@mock.patch("app.tasks.validation_tasks.send_webhook_notification") -@mock.patch("app.tasks.validation_tasks.perform_metadata_validation") -def test_validation_fails_and_sends_error_notification_to_webhook( - mock_validate, mock_webhook, - crate_json: str, profile_name: str, webhook_url: str, profiles_path: str | None, - validation_message: str -): - - mock_validate.return_value = validation_message - - result = process_validation_task_by_metadata( - crate_json, profile_name, webhook_url, profiles_path - ) - - assert isinstance(result, str) - assert validation_message in result - mock_validate.assert_called_once_with( - crate_json, profile_name, profiles_path=profiles_path - ) - - if webhook_url is not None: - # Error webhook should be sent - mock_webhook.assert_called_once() - args, kwargs = mock_webhook.call_args - assert kwargs is None or "error" in args[1] - else: - # Make sure webhook not sent - mock_webhook.assert_not_called() - - # Test function: perform_ro_crate_validation @pytest.mark.parametrize( @@ -379,68 +286,6 @@ def test_validation_settings_error(mock_validation_settings, mock_validate): mock_validate.assert_not_called() -# Test function: perform_metadata_validation - -@pytest.mark.parametrize( - "crate_json, profile_name, skip_checks", - [ - ('{"id":"dummy json"}', "ro_profile", ["check1", "check2"]), - ('{"id":"dummy json"}', None, None) - ], - ids=["success_with_all_args", "success_with_only_crate"] -) -@mock.patch("app.tasks.validation_tasks.services.validate") -@mock.patch("app.tasks.validation_tasks.services.ValidationSettings") -def test_metadata_validation_success_with_all_args( - mock_validation_settings, mock_validate, - crate_json: str, profile_name: str, skip_checks: list -): - mock_result = mock.Mock() - mock_validate.return_value = mock_result - - result = perform_metadata_validation(crate_json, profile_name, skip_checks) - - # Assert that result was returned - assert result == mock_result - - # Validate proper construction of ValidationSettings - mock_validation_settings.assert_called_once() - args, kwargs = mock_validation_settings.call_args - assert kwargs["metadata_dict"] == json.loads(crate_json) - if profile_name is not None: - assert kwargs["profile_identifier"] == profile_name - else: - assert "profile_identifier" not in kwargs - if skip_checks is not None: - assert kwargs["skip_checks"] == skip_checks - else: - assert "skip_checks" not in kwargs - - mock_validate.assert_called_once_with(mock_validation_settings.return_value) - - -@mock.patch("app.tasks.validation_tasks.services.validate", side_effect=RuntimeError("Validation error")) -@mock.patch("app.tasks.validation_tasks.services.ValidationSettings") -def test_metadata_validation_raises_exception_and_returns_string(mock_validation_settings, mock_validate): - crate_json = '{"id":"test metadata"}' - result = perform_metadata_validation(crate_json, "profile", skip_checks_list=None) - - assert isinstance(result, str) - assert "Validation error" in result - mock_validate.assert_called_once() - - -@mock.patch("app.tasks.validation_tasks.services.validate") -@mock.patch("app.tasks.validation_tasks.services.ValidationSettings", side_effect=ValueError("Bad config")) -def test_metadata_validation_settings_error(mock_validation_settings, mock_validate): - crate_json = '{"id":"test metadata"}' - result = perform_metadata_validation(crate_json, None) - - assert isinstance(result, str) - assert "Bad config" in result - mock_validate.assert_not_called() - - # Test function: return_ro_crate_validation @mock.patch("app.tasks.validation_tasks.get_validation_status_from_minio") From fa1ef1460204298e9180c6ec15b049f19fc721b1 Mon Sep 17 00:00:00 2001 From: Alex Hambley <33315205+alexhambley@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:37:06 +0100 Subject: [PATCH 07/16] refactor(storage): migrate store-backed flow to S3Backend; drop minio-py - Move POST/task/GET onto StorageBackend + resolver + runner with server-side credentials. POST resolves before queueing (400/404/409/503 via app error handlers); - the Celery task (`run_validation_job`) runs fetch->validate->persist->webhook with retries and persists error outcomes; GET reads the results prefix. - Add s3_crate_prefix/s3_results_prefix to Settings. Delete minio_utils and test_minio, remove minio from requirements and recompile the lock. - Covers the store-backed validation flow Closes #177, #178, #179 --- app/__init__.py | 20 ++ app/ro_crates/routes/__init__.py | 2 +- app/ro_crates/routes/get_routes.py | 52 +-- app/ro_crates/routes/post_routes.py | 58 +--- app/services/validation_service.py | 132 +++----- app/tasks/validation_tasks.py | 289 ++++++---------- app/utils/config.py | 5 + app/utils/minio_utils.py | 323 ------------------ requirements.in | 1 - requirements.txt | 18 +- tests/test_api_routes.py | 356 +++---------------- tests/test_minio.py | 506 ---------------------------- tests/test_services.py | 285 +++++----------- tests/test_validation_tasks.py | 474 ++++++-------------------- 14 files changed, 447 insertions(+), 2074 deletions(-) delete mode 100644 app/utils/minio_utils.py delete mode 100644 tests/test_minio.py diff --git a/app/__init__.py b/app/__init__.py index 4ad12bc..caafe64 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -4,7 +4,10 @@ from apiflask import APIFlask +from app.crates.ids import InvalidCrateId +from app.crates.resolver import CrateNotFound, AmbiguousCrate from app.ro_crates.routes import v1_post_bp, v1_minio_post_bp, v1_minio_get_bp +from app.storage.errors import StorageError from app.utils.config import ( Settings, InvalidAPIUsage, @@ -58,6 +61,23 @@ def create_app(settings: Settings | None = None) -> APIFlask: def invalid_api_usage(e): return jsonify(e.to_dict()), e.status_code + @app.errorhandler(InvalidCrateId) + def invalid_crate_id(e): + return jsonify({"error": str(e)}), 400 + + @app.errorhandler(CrateNotFound) + def crate_not_found(e): + return jsonify({"error": str(e)}), 404 + + @app.errorhandler(AmbiguousCrate) + def ambiguous_crate(e): + return jsonify({"error": str(e)}), 409 + + @app.errorhandler(StorageError) + def storage_error(e): + logger.error("Storage error: %s", e) + return jsonify({"error": "Storage backend unavailable"}), 503 + # Integrate Celery make_celery(app) diff --git a/app/ro_crates/routes/__init__.py b/app/ro_crates/routes/__init__.py index f4ae897..62fbbde 100644 --- a/app/ro_crates/routes/__init__.py +++ b/app/ro_crates/routes/__init__.py @@ -10,6 +10,6 @@ # Always registered: v1_post_bp = post_routes_bp -# Registered only when MinIO is enabled: +# Registered only when object storage is enabled: v1_minio_post_bp = minio_post_routes_bp v1_minio_get_bp = get_routes_bp diff --git a/app/ro_crates/routes/get_routes.py b/app/ro_crates/routes/get_routes.py index d6b23c6..5a477ad 100644 --- a/app/ro_crates/routes/get_routes.py +++ b/app/ro_crates/routes/get_routes.py @@ -1,12 +1,6 @@ -"""Defines get API endpoints for validating RO-Crates using their IDs from MinIO.""" +"""GET endpoint for retrieving a stored RO-Crate validation result by ID.""" -# Author: Alexander Hambley -# License: MIT -# Copyright (c) 2025 eScience Lab, The University of Manchester - -from apiflask import APIBlueprint, Schema -from apiflask.fields import String, Boolean -from marshmallow.fields import Nested +from apiflask import APIBlueprint from flask import Response from app.services.validation_service import get_ro_crate_validation_task @@ -14,49 +8,17 @@ get_routes_bp = APIBlueprint("get_routes", __name__) -class MinioConfig(Schema): - endpoint = String(required=True) - accesskey = String(required=True) - secret = String(required=True) - ssl = Boolean(required=True) - bucket = String(required=True) - - -class ValidateResult(Schema): - minio_config = Nested(MinioConfig, required=True) - root_path = String(required=False) - - @get_routes_bp.get("/validation") -@get_routes_bp.input(ValidateResult(partial=False), location='json') -def get_ro_crate_validation_by_id(json_data, crate_id) -> tuple[Response, int]: +def get_ro_crate_validation_by_id(crate_id) -> tuple[Response, int]: """ - Endpoint to obtain an RO-Crate validation result using its ID from MinIO. + Obtain a stored RO-Crate validation result by its ID. Path Parameters: - **crate_id**: The RO-Crate ID. _Required_. - Request Body Parameters: - - **minio_config**: The MinIO bucket containing the RO-Crate. _Required_ - - **endpoint**: Endpoint, e.g. 'localhost:9000' - - **accesskey**: Access key / username - - **secret**: Secret / password - - **ssl**: Use SSL encryption? True/False - - **bucket**: The MinIO bucket to access - - **root_path**: The root path containing the RO-Crate. _Optional_ - Returns: - - A tuple containing the validation result and an HTTP status code. - - Raises: - - KeyError: If required parameters (`crate_id`) are missing. + - A tuple containing the stored validation result and an HTTP status code. + Returns 404 if no result has been stored for the crate yet. """ - minio_config = json_data["minio_config"] - - if "root_path" in json_data: - root_path = json_data["root_path"] - else: - root_path = None - - return get_ro_crate_validation_task(minio_config, crate_id, root_path) + return get_ro_crate_validation_task(crate_id) diff --git a/app/ro_crates/routes/post_routes.py b/app/ro_crates/routes/post_routes.py index 2af2258..d7eef36 100644 --- a/app/ro_crates/routes/post_routes.py +++ b/app/ro_crates/routes/post_routes.py @@ -1,8 +1,7 @@ -"""Defines post API endpoints for validating RO-Crates using their IDs from MinIO.""" +"""POST endpoints for validating RO-Crates by stored ID or by inline metadata.""" from apiflask import APIBlueprint, Schema -from apiflask.fields import String, Boolean -from marshmallow.fields import Nested +from apiflask.fields import String from flask import Response, current_app from app.services.validation_service import ( @@ -13,22 +12,12 @@ # Always-on blueprint: post_routes_bp = APIBlueprint("post_routes", __name__) -# MinIO blueprint. Only registered when MINIO_ENABLED is true +# Store-backed blueprint. Only registered when storage is enabled # (see app.create_app), so the ID-based routes are unreachable by default. minio_post_routes_bp = APIBlueprint("minio_post_routes", __name__) -class MinioConfig(Schema): - endpoint = String(required=True) - accesskey = String(required=True) - secret = String(required=True) - ssl = Boolean(required=True) - bucket = String(required=True) - - class ValidateCrate(Schema): - minio_config = Nested(MinioConfig, required=True) - root_path = String(required=False) profile_name = String(required=False) webhook_url = String(required=False) @@ -42,51 +31,26 @@ class ValidateJSON(Schema): @minio_post_routes_bp.input(ValidateCrate(partial=False), location="json") def validate_ro_crate_via_id(json_data, crate_id) -> tuple[Response, int]: """ - Endpoint to validate an RO-Crate using its ID from MinIO. + Validate a stored RO-Crate by its ID. + + Storage credentials and layout are configured server-side; the request body + carries only optional fields. Path Parameters: - **crate_id**: The RO-Crate ID. _Required_. Request Body Parameters: - - **minio_config**: The MinIO bucket containing the RO-Crate. _Required_ - - **endpoint**: Endpoint, e.g. 'localhost:9000' - - **accesskey**: Access key / username - - **secret**: Secret / password - - **ssl**: Use SSL encryption? True/False - - **bucket**: The MinIO bucket to access - - **root_path**: The root path containing the RO-Crate. _Optional_ - **profile_name**: The profile name for validation. _Optional_. - - **webhook_url**: The webhook URL where validation results will be sent. _Optional_. + - **webhook_url**: The webhook URL where the validation result will be sent. _Optional_. Returns: - A tuple containing the validation task's response and an HTTP status code. - - Raises: - - KeyError: If required parameters (`crate_id` or `webhook_url`) are missing. """ - minio_config = json_data["minio_config"] - - if "root_path" in json_data: - root_path = json_data["root_path"] - else: - root_path = None - - if "webhook_url" in json_data: - webhook_url = json_data["webhook_url"] - else: - webhook_url = None + profile_name = json_data.get("profile_name") + webhook_url = json_data.get("webhook_url") - if "profile_name" in json_data: - profile_name = json_data["profile_name"] - else: - profile_name = None - - profiles_path = current_app.config["PROFILES_PATH"] - - return queue_ro_crate_validation_task( - minio_config, crate_id, root_path, profile_name, webhook_url, profiles_path - ) + return queue_ro_crate_validation_task(crate_id, profile_name, webhook_url) @post_routes_bp.post("/validate_metadata") diff --git a/app/services/validation_service.py b/app/services/validation_service.py index 8cd40da..d322518 100644 --- a/app/services/validation_service.py +++ b/app/services/validation_service.py @@ -1,71 +1,60 @@ -"""Service methods to queue RO-Crates for validation using the CRS4 validator and Celery.""" +"""Service layer for RO-Crate validation requests.""" -import logging import json +import logging -from flask import jsonify, Response - -from app.tasks.validation_tasks import ( - process_validation_task_by_id, - return_ro_crate_validation, - check_ro_crate_exists, - check_validation_exists, -) +from flask import jsonify, Response, current_app +from app.crates.ids import validate_crate_id +from app.crates.layout import result_key +from app.crates.resolver import resolve_crate +from app.storage.errors import ObjectNotFound +from app.storage.s3 import S3Backend +from app.tasks.validation_tasks import process_validation_task_by_id from app.utils.config import InvalidAPIUsage -from app.utils.minio_utils import get_minio_client -from app.validation.runner import validate_metadata from app.validation.results import ValidationStatus +from app.validation.runner import validate_metadata logger = logging.getLogger(__name__) +def _build_storage() -> S3Backend: + """Build the storage backend from server-side settings.""" + settings = current_app.config["SETTINGS"] + return S3Backend.from_settings(settings) + + def queue_ro_crate_validation_task( - minio_config, - crate_id, - root_path=None, - profile_name=None, - webhook_url=None, - profiles_path=None, + crate_id: str, profile_name=None, webhook_url=None ) -> tuple[Response, int]: """ - Queues an RO-Crate for validation with Celery. + Resolve a crate by ID and queue it for asynchronous validation. + + Credentials and layout are server-side; the request carries only the ID and + optional profile/webhook. Resolution happens before queueing so a bad or + missing crate is reported immediately. ``InvalidCrateId`` / ``CrateNotFound`` + / ``AmbiguousCrate`` / ``StorageError`` propagate to the app error handlers. - :param minio_config: Access settings for Minio instance containing the RO-Crate. :param crate_id: The ID of the RO-Crate to validate. - :param root_path: The root path containing the RO-Crate. :param profile_name: The profile to validate against. - :param webhook_url: The URL to POST the validation results to. - :return: A tuple containing a JSON response and an HTTP status code. - :raises: Exception: If an error occurs whilst queueing the task. + :param webhook_url: The URL to POST the validation result to. + :return: A JSON response and HTTP status code. """ + settings = current_app.config["SETTINGS"] + storage = _build_storage() - logging.info(f"Processing: {crate_id}, {profile_name}, {webhook_url}") - logging.info(f"Minio Bucket: {minio_config['bucket']}; Root path: {root_path}") - - minio_client = get_minio_client(minio_config) + # Raises if the crate is missing/ambiguous/invalid -> handled as 4xx. + resolve_crate(storage, crate_id, settings.s3_crate_prefix) - if check_ro_crate_exists(minio_client, minio_config["bucket"], crate_id, root_path): - logging.info("RO-Crate exists") - else: - logging.info("RO-Crate does not exist") - raise InvalidAPIUsage(f"No RO-Crate with prefix: {crate_id}", 400) - - try: - process_validation_task_by_id.delay( - minio_config, crate_id, root_path, profile_name, webhook_url, profiles_path - ) - return jsonify({"message": "Validation in progress"}), 202 - - except Exception as e: - return jsonify({"error": str(e)}), 500 + process_validation_task_by_id.delay(crate_id, profile_name, webhook_url) + return jsonify({"message": "Validation in progress"}), 202 def run_metadata_validation( crate_json: str, profile_name=None, profiles_path=None ) -> tuple[Response, int]: """ - Validates RO-Crate metadata synchronously and returns the result inline. + Validate RO-Crate metadata synchronously and return the result inline. Metadata-only validation is fast and stateless, so it runs in the request rather than via Celery. Returns 200 for a valid/invalid outcome and 422 @@ -74,9 +63,8 @@ def run_metadata_validation( :param crate_json: The RO-Crate JSON-LD metadata, as a string. :param profile_name: The profile to validate against. :param profiles_path: A path to the profile definition directory. - :return: A tuple containing a JSON response and an HTTP status code. + :return: A JSON response and HTTP status code. """ - if not crate_json: return jsonify({"error": "Missing required parameter: crate_json"}), 422 @@ -95,41 +83,25 @@ def run_metadata_validation( return jsonify(outcome.to_dict()), status_code -def get_ro_crate_validation_task( - minio_config: dict, - crate_id: str, - root_path: str, -) -> tuple[Response, int]: +def get_ro_crate_validation_task(crate_id: str) -> tuple[Response, int]: """ - Retrieves an RO-Crate validation result. + Return a crate's stored validation result. - :param minio_config: Access settings for Minio instance containing the RO-Crate. - :param crate_id: The ID of the RO-Crate to validate. - :param root_path: The root path containing the RO-Crate. - :return: A tuple containing a JSON response and an HTTP status code. - :raises Exception: If an error occurs whilst retreiving validation result + Reads the result object from the results prefix. A missing result yields a + 404; the stored outcome (including a persisted ``error`` outcome) is returned + as-is otherwise. + + :param crate_id: The ID of the RO-Crate whose result is requested. + :return: A JSON response and HTTP status code. """ - logging.info(f"Retrieving validation for: {crate_id}") - - minio_client = get_minio_client(minio_config) - - if check_ro_crate_exists(minio_client, minio_config["bucket"], crate_id, root_path): - logging.info("RO-Crate exists") - else: - logging.info("RO-Crate does not exist") - raise InvalidAPIUsage(f"No RO-Crate with prefix: {crate_id}", 400) - - if check_validation_exists( - minio_client, minio_config["bucket"], crate_id, root_path - ): - logging.info("Validation result exists") - else: - logging.info("Validation does not exist") - raise InvalidAPIUsage(f"No validation result yet for RO-Crate: {crate_id}", 400) - - return ( - return_ro_crate_validation( - minio_client, minio_config["bucket"], crate_id, root_path - ), - 200, - ) + settings = current_app.config["SETTINGS"] + storage = _build_storage() + + validate_crate_id(crate_id) # raises InvalidCrateId -> 400 + + try: + data = storage.get_bytes(result_key(settings.s3_results_prefix, crate_id)) + except ObjectNotFound: + raise InvalidAPIUsage(f"No validation result yet for RO-Crate: {crate_id}", 404) + + return jsonify(json.loads(data)), 200 diff --git a/app/tasks/validation_tasks.py b/app/tasks/validation_tasks.py index beb6bb3..ed8a496 100644 --- a/app/tasks/validation_tasks.py +++ b/app/tasks/validation_tasks.py @@ -1,215 +1,134 @@ -"""Tasks and helper methods for processing RO-Crate validation.""" +"""Store-backed RO-Crate validation: orchestration and the Celery task. + +The orchestration lives in :func:`run_validation_job`, a plain function that is +fully testable with an in-memory storage backend. The Celery task is a thin +wrapper that builds the backend from server-side settings and adds retries for +transient storage failures. + +Stages are ordered fetch -> validate -> persist -> webhook, so a failed store +write can never trigger a "success" webhook. The outcome (including ``error`` +outcomes) is always persisted, so a later GET reflects what happened. +""" import logging import os import shutil -from typing import Optional +import tempfile -from rocrate_validator import services -from rocrate_validator.models import ValidationResult +from datetime import datetime, timezone +from typing import Optional from app.celery_worker import celery -from app.utils.minio_utils import ( - fetch_ro_crate_from_minio, - update_validation_status_in_minio, - get_validation_status_from_minio, - get_minio_client, - find_rocrate_object_on_minio, - find_validation_object_on_minio, +from app.crates.ids import InvalidCrateId +from app.crates.layout import result_key +from app.crates.resolver import ( + resolve_crate, + ResolvedCrate, + CrateNotFound, + AmbiguousCrate, ) +from app.storage.base import StorageBackend +from app.storage.errors import StorageError +from app.storage.s3 import S3Backend +from app.utils.config import Settings from app.utils.webhook_utils import send_webhook_notification +from app.validation.results import ValidationOutcome +from app.validation.runner import validate_crate_path logger = logging.getLogger(__name__) -@celery.task -def process_validation_task_by_id( - minio_config: dict, - crate_id: str, - root_path: str, - profile_name: str | None, - webhook_url: str | None, - profiles_path: str | None, -) -> None: - """ - Background task to process the RO-Crate validation by ID. - - :param minio_config: The MinIO configuration. - :param crate_id: The ID of the RO-Crate to validate. - :param root_path: The root path containing the RO-Crate. - :param profile_name: The name of the validation profile to use. Defaults to None. - :param webhook_url: The webhook URL to send notifications to. Defaults to None. - :raises Exception: If an error occurs during the validation process. +def _utcnow_iso() -> str: + return datetime.now(timezone.utc).isoformat() - """ - # TODO: Split try statements: (1) fetch and validate; (2) write to minio; (3) webhook +def _download_crate( + storage: StorageBackend, resolved: ResolvedCrate, temp_dir: str +) -> str: + """Download a resolved crate into ``temp_dir`` and return its local path.""" + if resolved.is_zip: + local_path = os.path.join(temp_dir, f"{resolved.crate_id}.zip") + with open(local_path, "wb") as handle: + handle.write(storage.get_bytes(resolved.key)) + return local_path - minio_client = get_minio_client(minio_config) + dest = os.path.join(temp_dir, resolved.crate_id) + os.makedirs(dest, exist_ok=True) + storage.download_tree(resolved.key, dest) + return dest - file_path = None +def run_validation_job( + storage: StorageBackend, + crate_id: str, + settings: Settings, + profile_name: Optional[str] = None, + webhook_url: Optional[str] = None, + created_at: Optional[str] = None, +) -> ValidationOutcome: + """Fetch, validate, persist, and (optionally) notify, for one crate. + + Resolution/validation problems become ``error`` outcomes that are persisted + like any other. A :class:`StorageError` (transient infrastructure failure) + propagates so the caller can retry. + """ + created_at = created_at or _utcnow_iso() + temp_dir = tempfile.mkdtemp() try: - # Fetch the RO-Crate from MinIO using the provided ID: - file_path = fetch_ro_crate_from_minio( - minio_client, minio_config["bucket"], crate_id, root_path - ) - - logging.info(f"Processing validation task for {file_path}") - - # Perform validation: - validation_result = perform_ro_crate_validation( - file_path, profile_name, profiles_path=profiles_path - ) - - if isinstance(validation_result, str): - logging.error(f"Validation failed: {validation_result}") - # TODO: Send webhook with failure notification - raise Exception(f"Validation failed: {validation_result}") - - if not validation_result.has_issues(): - logging.info(f"RO Crate {crate_id} is valid.") + try: + resolved = resolve_crate(storage, crate_id, settings.s3_crate_prefix) + local_path = _download_crate(storage, resolved, temp_dir) + except (CrateNotFound, AmbiguousCrate, InvalidCrateId) as error: + logger.error("Cannot validate crate %s: %s", crate_id, error) + outcome = ValidationOutcome.from_error( + str(error), profile=profile_name, created_at=created_at + ) else: - logging.info(f"RO Crate {crate_id} is invalid.") - - # Update the validation status in MinIO: - update_validation_status_in_minio( - minio_client, - minio_config["bucket"], - crate_id, - root_path, - validation_result.to_json(), - ) - - # TODO: Prepare the data to send to the webhook, and send the webhook notification. - - if webhook_url: - send_webhook_notification(webhook_url, validation_result.to_json()) - - except Exception as e: - logging.error(f"Error processing validation task: {e}") - - # TODO: Should we write error messages to the minio instance too? - - # Send failure notification via webhook - if webhook_url: - error_data = {"profile_name": profile_name, "error": str(e)} - send_webhook_notification(webhook_url, error_data) - + outcome = validate_crate_path( + local_path, + profile_name=profile_name, + profiles_path=settings.profiles_path, + created_at=created_at, + ) finally: - # Clean up the temporary file if it was created: - if file_path and os.path.exists(file_path): - if os.path.isfile(file_path): - os.remove(file_path) - elif os.path.isdir(file_path): - shutil.rmtree(file_path) - - -def perform_ro_crate_validation( - file_path: str, - profile_name: str | None, - skip_checks_list: Optional[list] = None, - profiles_path: Optional[str] = None, -) -> ValidationResult | str: - """ - Validates an RO-Crate using the provided file path and profile name. - - :param file_path: The path to the RO-Crate file to validate - :param profile_name: The name of the validation profile to use. Defaults to None. If None, the CRS4 validator will - attempt to determine the profile. - :param profiles_path: The path to the profiles definition directory - :param skip_checks_list: A list of checks to skip, if needed - :return: The validation result. - :raises Exception: If an error occurs during the validation process. - """ - - try: - logging.info(f"Validating {file_path} with profile {profile_name}") - - full_file_path = os.path.join( - os.path.dirname( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - ), - file_path, - ) - settings = services.ValidationSettings( - rocrate_uri=full_file_path, - **({"profile_identifier": profile_name} if profile_name else {}), - **({"skip_checks": skip_checks_list} if skip_checks_list else {}), - **({"profiles_path": profiles_path} if profiles_path else {}), - ) - - return services.validate(settings) - - except Exception as e: - logging.error(f"Unexpected error during validation: {e}") - return str(e) - - -def check_ro_crate_exists( - minio_client: object, - bucket_name: str, - crate_id: str, - root_path: str, -) -> bool: - """ - Checks for the existence of an RO-Crate using the provided Crate ID. - - :param minio_client: The MinIO client - :param bucket_name: The MinIO bucket containing the RO-Crate. - :param crate_id: The ID of the RO-Crate to validate. - :param root_path: The root path containing the RO-Crate. - :return: Boolean indicating existence - """ - - logging.info(f"Checking for existence of RO-Crate {crate_id}") - - if find_rocrate_object_on_minio(crate_id, minio_client, bucket_name, root_path): - return True - else: - return False - + shutil.rmtree(temp_dir, ignore_errors=True) -def check_validation_exists( - minio_client: object, - bucket_name: str, - crate_id: str, - root_path: str, -) -> bool: - """ - Checks for the existence of a validation result using the provided Crate ID. - - :param minio_client: The MinIO client - :param minio_bucket: The MinIO bucket containing the RO-Crate. - :param crate_id: The ID of the RO-Crate to validate. - :param root_path: The root path containing the RO-Crate. - :return: Boolean indicating existence - """ + # Persist before notifying, so a write failure cannot precede a webhook. + storage.put_bytes( + result_key(settings.s3_results_prefix, crate_id), + outcome.to_json().encode("utf-8"), + content_type="application/json", + ) - logging.info(f"Checking for existence of RO-Crate {crate_id}") + if webhook_url: + send_webhook_notification(webhook_url, outcome.to_dict()) - if find_validation_object_on_minio(crate_id, minio_client, bucket_name, root_path): - return True - else: - return False + return outcome -def return_ro_crate_validation( - minio_client: object, - bucket_name: str, +@celery.task( + autoretry_for=(StorageError,), + max_retries=3, + retry_backoff=True, + retry_backoff_max=60, +) +def process_validation_task_by_id( crate_id: str, - root_path: str, -) -> dict | str: - """ - Retrieves the validation result for an RO-Crate using the provided Crate ID. + profile_name: Optional[str] = None, + webhook_url: Optional[str] = None, +) -> None: + """Celery entry point: validate a stored crate by ID. - :param minio_client: The MinIO client - :param crate_id: The ID of the RO-Crate that has been validated - :return: The validation result + Credentials and layout come from server-side settings (never the request), + so no secrets travel through the broker. Transient storage failures are + retried with exponential backoff. """ - - logging.info(f"Fetching validation result for RO-Crate {crate_id}") - - return get_validation_status_from_minio( - minio_client, bucket_name, crate_id, root_path + settings = Settings.from_env() + storage = S3Backend.from_settings(settings) + run_validation_job( + storage, + crate_id, + settings, + profile_name=profile_name, + webhook_url=webhook_url, ) diff --git a/app/utils/config.py b/app/utils/config.py index a4116a0..492e202 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -46,6 +46,8 @@ class Settings: s3_region: Optional[str] s3_bucket: Optional[str] s3_use_ssl: bool + s3_crate_prefix: str + s3_results_prefix: str @classmethod def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "Settings": @@ -88,6 +90,9 @@ def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "Settings": s3_region=_clean(env.get("S3_REGION")), s3_bucket=_clean(env.get("S3_BUCKET")), s3_use_ssl=_parse_bool(env.get("S3_USE_SSL")), + s3_crate_prefix=_clean(env.get("S3_CRATE_PREFIX")) or "crates", + s3_results_prefix=_clean(env.get("S3_RESULTS_PREFIX")) + or "validation-results", ) diff --git a/app/utils/minio_utils.py b/app/utils/minio_utils.py deleted file mode 100644 index 1612f90..0000000 --- a/app/utils/minio_utils.py +++ /dev/null @@ -1,323 +0,0 @@ -"""Utility methods for interacting with MinIO.""" - -# Author: Alexander Hambley -# License: MIT -# Copyright (c) 2025 eScience Lab, The University of Manchester - -import json -import logging -import os -import tempfile - -from io import BytesIO -from minio import Minio, S3Error -from app.utils.config import InvalidAPIUsage - - -logger = logging.getLogger(__name__) - - -def fetch_ro_crate_from_minio(minio_client: object, minio_bucket: str, crate_id: str, root_path: str) -> str: - """ - Fetches an RO-Crate from MinIO based on the crate ID. Downloads the crate as a file and returns local file path. - - :param minio_client: The MinIO client - :param minio_bucket: The MinIO bucket containing the RO-Crate. - :param crate_id: The ID of the RO-Crate to fetch from MinIO. - :param root_path: The root path containing the RO-Crate. - :return: The local file path where the RO-Crate is saved. - """ - - rocrate_object = find_rocrate_object_on_minio(crate_id, minio_client, minio_bucket, root_path) - - rocrate_minio_path = rocrate_object.object_name - rocrate_name = rocrate_minio_path.split('/')[-1] - - temp_dir = tempfile.mkdtemp() - local_root_path = os.path.join(temp_dir, rocrate_name) - - logging.info( - f"Fetching RO-Crate {rocrate_name} from MinIO bucket {minio_bucket}. File path {local_root_path}" - ) - - if rocrate_object.is_dir: - os.makedirs(os.path.dirname(local_root_path), exist_ok=True) - - objects_list = get_minio_object_list(rocrate_minio_path, minio_client, minio_bucket, recursive=True) - for obj in objects_list: - relative_path = obj.object_name[len(rocrate_minio_path):].lstrip("/") - local_file_path = os.path.join(local_root_path, relative_path) - os.makedirs(os.path.dirname(local_file_path), exist_ok=True) - download_file_from_minio(minio_client, minio_bucket, obj.object_name, local_file_path) - - else: - file_path = local_root_path - download_file_from_minio(minio_client, minio_bucket, rocrate_minio_path, file_path) - - logging.info( - f"RO-Crate {rocrate_name} fetched successfully and saved to {local_root_path}." - ) - - return local_root_path - - -def update_validation_status_in_minio(minio_client: object, minio_bucket: str, crate_id: str, root_path: str, validation_status: str) -> None: - """ - Uploads the validation status to the MinIO bucket. - - :param minio_client: The MinIO client - :param minio_bucket: The MinIO bucket containing the RO-Crate. - :param crate_id: The ID of the RO-Crate in MinIO - :param validation_status: The validation result to upload - :raises S3Error: If an error occurs during the MinIO operation - :raises ValueError: If the required environment variables are not set - :raises Exception: If an unexpected error occurs - """ - - # The object in MinIO is _validation/validation_status.txt - if root_path: - object_name = f"{root_path}/{crate_id}_validation/validation_status.txt" - else: - object_name = f"{crate_id}_validation/validation_status.txt" - - # convert pretty string to dictionary, then back to plain utf-8 encoded string - validation_string = json.dumps(json.loads(validation_status), indent=None).encode("utf-8") - - try: - minio_client.put_object( - minio_bucket, - object_name, - data=BytesIO(validation_string), - length=len(validation_string), - content_type="application/json", - ) - - except S3Error as s3_error: - logging.error(f"MinIO S3 Error: {s3_error}") - raise InvalidAPIUsage(f"MinIO S3 Error: {s3_error}", 500) - - except ValueError as value_error: - logging.error(f"Configuration Error: {value_error}") - raise InvalidAPIUsage(f"Configuration Error: {value_error}", 500) - - except Exception as e: - logging.error(f"Unexpected error updating validation status in MinIO: {e}") - raise InvalidAPIUsage(f"Unknown Error: {e}", 500) - - logging.info( - f"Validation status file uploaded to {minio_bucket}/{object_name} successfully." - ) - - -def get_validation_status_from_minio(minio_client: object, minio_bucket: str, crate_id: str, root_path: str) -> dict: - """ - Checks for the existence of a validation report for the given RO-Crate in the MinIO bucket. - Returns validation message if it exists, or notification that it is missing if not. - - :param minio_client: The MinIO client - :param minio_bucket: The MinIO bucket containing the RO-Crate. - :param crate_id: The ID of the RO-Crate in MinIO - :return validation_status: Either the validation status, or note that this does not exist - - """ - - # The object in MinIO is _validation/validation_status.txt - if root_path: - object_name = f"{root_path}/{crate_id}_validation/validation_status.txt" - else: - object_name = f"{crate_id}_validation/validation_status.txt" - - logging.info(f"Getting object {object_name}") - - try: - response = minio_client.get_object( - minio_bucket, - object_name, - ) - - validation_message = json.loads(response.data.decode()) - response.close() - response.release_conn() - - except S3Error as s3_error: - logging.error(f"MinIO S3 Error: {s3_error}") - raise InvalidAPIUsage(f"MinIO S3 Error: {s3_error}", 500) - - except ValueError as value_error: - logging.error(f"Configuration Error: {value_error}") - raise InvalidAPIUsage(f"Configuration Error: {value_error}", 500) - - except Exception as e: - logging.error(f"Unexpected error retrieving validation status from MinIO: {e}") - raise InvalidAPIUsage(f"Unknown Error: {e}", 500) - - else: - return validation_message - - -def download_file_from_minio(minio_client: object, minio_bucket: str, object_path: str, file_path: str) -> None: - """ - Downloads a file from MinIO - - :param minio_client: MinIO object - :param minio_bucket: name of MinIO bucket, string - :param object_path: path to object on MinIO, string - :param file_path: local path, string - :raises S3Error: If an error occurs during the MinIO operation - :raises ValueError: If the required environment variables are not set - :raises Exception: If an unexpected error occurs - """ - - try: - minio_client.fget_object(minio_bucket, object_path, file_path) - - except S3Error as s3_error: - logging.error(f"MinIO S3 Error: {s3_error}") - raise InvalidAPIUsage(f"MinIO S3 Error: {s3_error}", 500) - - except ValueError as value_error: - logging.error(f"Configuration Error: {value_error}") - raise InvalidAPIUsage(f"Configuration Error: {value_error}", 500) - - except Exception as e: - logging.error(f"Unexpected error retrieving file from MinIO: {e}") - raise InvalidAPIUsage(f"Unknown Error: {e}", 500) - - -def find_validation_object_on_minio(rocrate_id: str, minio_client, minio_bucket: str, root_path: str) -> object: - """ - Checks that the requested object exists on the MinIO instance. - - If it does not exist then a False value is returned. - If it does exist then the minio.datatypes.Object is returned. - - :param rocrate_id: string containing the name of ro-crate - :param root_path: string containing the path within which the ro-crate should be - :param minio_client: minio object - :param minio_bucket: string containing bucket on minio - :return return_object: rocrate object we require - :raise Exception: If validation result can't be found, 400 - """ - - logging.info(f"Finding Validation result: {rocrate_id}_validation/validation_status.txt") - - if root_path: - file_path = f"{root_path}/{rocrate_id}_validation/validation_status.txt" - else: - file_path = f"{rocrate_id}_validation/validation_status.txt" - - file_list = get_minio_object_list(file_path, minio_client, minio_bucket) - - return_object = False - for obj in file_list: - if obj.object_name == file_path: - return_object = obj - break - - if not return_object: - logging.error(f"No validation result yet for RO-Crate: {rocrate_id}") - return False - else: - return return_object - - -def find_rocrate_object_on_minio(rocrate_id: str, minio_client, minio_bucket: str, root_path: str) -> object | bool: - """ - Checks that the requested object exists on the MinIO instance. - - If it does not exist then a False value is returned. - If it does exist then the minio.datatypes.Object is returned. - - :param rocrate_id: string containing the name of ro-crate - :param root_path: string containing the path within which the ro-crate should be - :param minio_client: minio object - :param minio_bucket: string containing bucket on minio - :return return_object or False: rocrate object we require, or False result - :raise Exception: If RO-Crate can't be found, 400 - """ - - logging.info(f"Finding RO-Crate: {rocrate_id}") - - if root_path: - rocrate_path = f"{root_path}/{rocrate_id}" - else: - rocrate_path = rocrate_id - - rocrate_list = get_minio_object_list(rocrate_path, minio_client, minio_bucket) - - return_object = False - for obj in rocrate_list: - # TODO: We should be checking here for the existence of the ro-crate metadata file within this object too - if (obj.object_name == f"{rocrate_path}/" and obj.is_dir) or obj.object_name == f"{rocrate_path}.zip": - return_object = obj - break - - if not return_object: - logging.error(f"No RO-Crate with prefix: {rocrate_path}") - return False - else: - return return_object - - -def get_minio_object_list(object_path: str, minio_client, minio_bucket: str, recursive: bool = False) -> list: - """ - Creates a list of objects which match the object_id and path_prefix - - :param object_path: The object ID, string - :param path_prefix: Path prefix, string, optional - :param minio_client: MinIO client object - :param minio_bucket: string - :param recursive: boolean, default = False - :return object_list: List containing objects of type minio.datatypes.Object - :raises S3Error: If an error occurs during the MinIO operation, 500 - :raises ValueError: If the required environment variables are not set, 500 - :raises Exception: If an unexpected error occurs, 500 - """ - - try: - response = minio_client.list_objects( - minio_bucket, - object_path, - recursive=recursive - ) - object_list = [obj for obj in response] - - response.close() - - except S3Error as s3_error: - logging.error(f"MinIO S3 Error: {s3_error}") - raise InvalidAPIUsage(f"MinIO S3 Error: {s3_error}", 500) - - except ValueError as value_error: - logging.error(f"Configuration Error: {value_error}") - raise InvalidAPIUsage(f"Configuration Error: {value_error}", 500) - - except Exception as e: - logging.error(f"Unexpected error getting object list from MinIO: {e}") - raise InvalidAPIUsage(f"Unknown Error: {e}", 500) - - else: - return object_list - - -def get_minio_client(minio_config: dict) -> Minio: - """ - Initialises the MinIO client from provided settings. - - :param minio_config: A dictionary containing the below parameters - :param endpoint: A string containing host and port. E.g. 'localhost:9000' - :param access_key: A string containing the access key / username - :param secret_key: A string containing the secret key / password - :param use_ssl: Boolean defining if SSL connection should be used or not - :return: The MinIO client. - :raises ValueError: If required environment variables are not set. - """ - - minio_client = Minio( - endpoint=minio_config["endpoint"], - access_key=minio_config["accesskey"], - secret_key=minio_config["secret"], - secure=minio_config["ssl"], - ) - - return minio_client diff --git a/requirements.in b/requirements.in index 13c8cf3..c419c34 100644 --- a/requirements.in +++ b/requirements.in @@ -1,6 +1,5 @@ celery==5.6.3 boto3==1.43.29 -minio==7.2.20 requests==2.33.1 Flask==3.1.3 Werkzeug==3.1.8 diff --git a/requirements.txt b/requirements.txt index a77a518..4c79bff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,10 +12,6 @@ apiflask==3.1.0 # via -r requirements.in apispec==6.8.2 # via apiflask -argon2-cffi==25.1.0 - # via minio -argon2-cffi-bindings==25.1.0 - # via argon2-cffi async-timeout==5.0.1 # via redis attrs==25.3.0 @@ -37,11 +33,7 @@ cattrs==25.1.1 celery==5.6.3 # via -r requirements.in certifi==2025.8.3 - # via - # minio - # requests -cffi==1.17.1 - # via argon2-cffi-bindings + # via requests charset-normalizer==3.4.2 # via requests click==8.2.1 @@ -112,8 +104,6 @@ marshmallow==4.1.2 # webargs mdurl==0.1.2 # via markdown-it-py -minio==7.2.20 - # via -r requirements.in owlrl==7.1.4 # via pyshacl packaging==25.0 @@ -132,10 +122,6 @@ prompt-toolkit==3.0.51 # via # click-repl # inquirerpy -pycparser==2.22 - # via cffi -pycryptodome==3.23.0 - # via minio pydantic[email]==2.12.4 # via apiflask pydantic-core==2.41.5 @@ -186,7 +172,6 @@ typing-extensions==4.14.1 # via # cattrs # enum-tools - # minio # pydantic # pydantic-core # rich-click @@ -204,7 +189,6 @@ url-normalize==2.2.1 urllib3==2.6.3 # via # botocore - # minio # requests # requests-cache vine==5.1.0 diff --git a/tests/test_api_routes.py b/tests/test_api_routes.py index a6ac40d..ce02d06 100644 --- a/tests/test_api_routes.py +++ b/tests/test_api_routes.py @@ -26,7 +26,7 @@ def client(): @pytest.fixture -def minio_client(): +def storage_client(): """Client with storage enabled, so the ID-based validation endpoints are registered.""" app = create_app(settings=Settings.from_env(_storage_env())) return app.test_client() @@ -36,165 +36,49 @@ def minio_client(): @pytest.mark.parametrize( - "crate_id, payload, profiles_path, status_code, response_json", + "payload, expected_args", [ - ( - "crate-123", - { - "minio_config": { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket", - }, - "root_path": "base_path", - "webhook_url": "https://webhook.example.com", - "profile_name": "default", - }, - None, - 202, - {"message": "Validation in progress"}, - ), - ( - "crate-123", - { - "minio_config": { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket", - }, - "root_path": "base_path", - "webhook_url": "https://webhook.example.com", - }, - None, - 202, - {"message": "Validation in progress"}, - ), - ( - "crate-123", - { - "minio_config": { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket", - }, - "root_path": "base_path", - "profile_name": "default", - }, - None, - 202, - {"message": "Validation in progress"}, - ), - ( - "crate-123", - { - "minio_config": { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket", - }, - "webhook_url": "https://webhook.example.com", - "profile_name": "default", - }, - None, - 202, - {"message": "Validation in progress"}, - ), - ( - "crate-123", - { - "minio_config": { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket", - }, - }, - None, - 202, - {"message": "Validation in progress"}, - ), - ], - ids=[ - "validate_by_id", - "validate_with_missing_profile_name", - "validate_with_missing_webhook_url", - "validate_with_missing_root_path", - "validate_with_missing_root_path_and_profile_name_and_webhook_url", + ({"profile_name": "ro-crate", "webhook_url": "https://hook"}, + ("crate-123", "ro-crate", "https://hook")), + ({"profile_name": "ro-crate"}, ("crate-123", "ro-crate", None)), + ({"webhook_url": "https://hook"}, ("crate-123", None, "https://hook")), + ({}, ("crate-123", None, None)), ], + ids=["all_fields", "no_webhook", "no_profile", "empty_body"], ) -def test_validate_by_id_success( - minio_client: FlaskClient, - crate_id: str, - payload: dict, - profiles_path: str, - status_code: int, - response_json: dict, -): +def test_validate_by_id_queues_and_returns_202(storage_client, payload, expected_args): with patch( "app.ro_crates.routes.post_routes.queue_ro_crate_validation_task" ) as mock_queue: - mock_queue.return_value = (response_json, status_code) + mock_queue.return_value = ({"message": "Validation in progress"}, 202) - response = minio_client.post( - f"/v1/ro_crates/{crate_id}/validation", json=payload - ) + response = storage_client.post("/v1/ro_crates/crate-123/validation", json=payload) - minio_config = payload["minio_config"] if "minio_config" in payload else None - root_path = payload["root_path"] if "root_path" in payload else None - profile_name = payload["profile_name"] if "profile_name" in payload else None - webhook_url = payload["webhook_url"] if "webhook_url" in payload else None - assert response.status_code == status_code - assert response.json == response_json - mock_queue.assert_called_once_with( - minio_config, crate_id, root_path, profile_name, webhook_url, profiles_path - ) + assert response.status_code == 202 + assert response.json == {"message": "Validation in progress"} + mock_queue.assert_called_once_with(*expected_args) -@pytest.mark.parametrize( - "crate_id, payload, status_code", - [ - ( - "", - { - "minio_bucket": "test_bucket", - "root_path": "base_path", - "webhook_url": "https://webhook.example.com", - "profile_name": "default", - }, - 404, - ), - ( - "crate-123", - { - "root_path": "base_path", - "webhook_url": "https://webhook.example.com", - "profile_name": "default", - }, - 422, - ), - ], - ids=["missing_crate_id_returns_404", "missing_minio_bucket_returns_422"], -) -def test_validate_fails_missing_elements( - minio_client: FlaskClient, crate_id: str, payload: dict, status_code: int -): - response = minio_client.post(f"/v1/ro_crates/{crate_id}/validation", json=payload) - assert response.status_code == status_code +def test_validate_by_id_no_longer_accepts_credentials(storage_client): + """The request body carries no storage credentials; only optional fields.""" + with patch( + "app.ro_crates.routes.post_routes.queue_ro_crate_validation_task" + ) as mock_queue: + mock_queue.return_value = ({"message": "Validation in progress"}, 202) + + response = storage_client.post( + "/v1/ro_crates/crate-123/validation", + json={"profile_name": "ro-crate"}, + ) + + assert response.status_code == 202 + # Only crate_id, profile, webhook are forwarded — no minio_config. + mock_queue.assert_called_once_with("crate-123", "ro-crate", None) # Test POST API: /v1/ro_crates/validate_metadata -# TODO: Write tests for profiles_path environment variable. This will require a refactoring of the create_app function. @pytest.mark.parametrize( "payload, status_code, response_json, profiles_path", [ @@ -204,38 +88,31 @@ def test_validate_fails_missing_elements( "profile_name": "default", }, 200, - {"status": "success"}, + {"status": "valid"}, None, ), ( - { - "crate_json": '{"@context": "https://w3id.org/ro/crate/1.1/context"}', - }, + {"crate_json": '{"@context": "https://w3id.org/ro/crate/1.1/context"}'}, 200, - {"status": "success"}, + {"status": "valid"}, None, ), ], ids=["success_with_all_fields", "success_without_profile_name"], ) def test_validate_metadata_success( - client: FlaskClient, - payload: dict, - status_code: int, - response_json: dict, - profiles_path: str, + client: FlaskClient, payload, status_code, response_json, profiles_path ): with patch( "app.ro_crates.routes.post_routes.run_metadata_validation" - ) as mock_queue: - mock_queue.return_value = (response_json, status_code) + ) as mock_run: + mock_run.return_value = (response_json, status_code) response = client.post("/v1/ro_crates/validate_metadata", json=payload) - crate_json = payload["crate_json"] if "crate_json" in payload else None - profile_name = payload["profile_name"] if "profile_name" in payload else None - - mock_queue.assert_called_once_with( + crate_json = payload.get("crate_json") + profile_name = payload.get("profile_name") + mock_run.assert_called_once_with( crate_json, profile_name, profiles_path=profiles_path ) assert response.status_code == status_code @@ -246,37 +123,14 @@ def test_validate_metadata_success( "payload, status_code, response_text", [ ({"profile_name": "default"}, 422, "Missing data for required field"), - ( - { - "crate_json": "", - }, - 422, - "Missing required parameter", - ), - ( - { - "crate_json": "{", - }, - 422, - "not valid JSON", - ), - ( - { - "crate_json": "{}", - }, - 422, - "Required parameter crate_json is empty", - ), - ], - ids=[ - "failure_missing_crate", - "failure_empty_crate", - "failure_malformed_crate", - "failure_empty_crate", + ({"crate_json": ""}, 422, "Missing required parameter"), + ({"crate_json": "{"}, 422, "not valid JSON"), + ({"crate_json": "{}"}, 422, "empty"), ], + ids=["missing_crate", "blank_crate", "malformed_crate", "empty_crate"], ) def test_validate_metadata_failure( - client: FlaskClient, payload: dict, status_code: int, response_text: str + client: FlaskClient, payload, status_code, response_text ): response = client.post("/v1/ro_crates/validate_metadata", json=payload) assert response.status_code == status_code @@ -286,140 +140,40 @@ def test_validate_metadata_failure( # Test GET API: /v1/ro_crates/{crate_id}/validation -@pytest.mark.parametrize( - "crate_id, payload, status_code", - [ - ( - "", - { - "minio_config": { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket", - }, - "root_path": "base_path", - }, - 404, - ), - ( - "crate-123", - { - "minio_config": { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - }, - "root_path": "base_path", - }, - 422, - ), - ], - ids=["failure_missing_crate_id", "failure_missing_minio_bucket"], -) -def test_get_validation_by_id_failures( - minio_client: FlaskClient, crate_id: str, payload: dict, status_code: int -): - response = minio_client.get(f"/v1/ro_crates/{crate_id}/validation", json=payload) - assert response.status_code == status_code - - -def test_get_validation_by_id_success(minio_client): - crate_id = "crate-123" - payload = { - "minio_config": { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket", - }, - "root_path": "base_path", - } - +def test_get_validation_by_id_returns_result(storage_client): with patch( "app.ro_crates.routes.get_routes.get_ro_crate_validation_task" ) as mock_get: mock_get.return_value = ({"status": "valid"}, 200) - response = minio_client.get( - f"/v1/ro_crates/{crate_id}/validation", json=payload - ) + response = storage_client.get("/v1/ro_crates/crate-123/validation") assert response.status_code == 200 assert response.json == {"status": "valid"} - mock_get.assert_called_once_with( - payload["minio_config"], "crate-123", "base_path" - ) + mock_get.assert_called_once_with("crate-123") -def test_get_validation_by_id_missing_root_path(minio_client): - crate_id = "crate-123" - payload = { - "minio_config": { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket", - } - } +# Test store-backed endpoints are unavailable when storage is disabled (the default) - with patch( - "app.ro_crates.routes.get_routes.get_ro_crate_validation_task" - ) as mock_get: - mock_get.return_value = ({"status": "valid"}, 200) - - response = minio_client.get( - f"/v1/ro_crates/{crate_id}/validation", json=payload - ) - - assert response.status_code == 200 - assert response.json == {"status": "valid"} - mock_get.assert_called_once_with(payload["minio_config"], "crate-123", None) - -# Test MinIO-backed endpoints are unavailable when MinIO is disabled (the default) - - -def test_minio_post_route_not_registered_when_disabled(client: FlaskClient): - payload = { - "minio_config": { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket", - } - } - response = client.post("/v1/ro_crates/crate-123/validation", json=payload) +def test_post_route_not_registered_when_storage_disabled(client: FlaskClient): + response = client.post("/v1/ro_crates/crate-123/validation", json={}) assert response.status_code == 404 -def test_minio_get_route_not_registered_when_disabled(client: FlaskClient): - payload = { - "minio_config": { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket", - } - } - response = client.get("/v1/ro_crates/crate-123/validation", json=payload) +def test_get_route_not_registered_when_storage_disabled(client: FlaskClient): + response = client.get("/v1/ro_crates/crate-123/validation") assert response.status_code == 404 -def test_metadata_route_available_when_minio_disabled(client: FlaskClient): +def test_metadata_route_available_when_storage_disabled(client: FlaskClient): payload = {"crate_json": '{"@context": "https://w3id.org/ro/crate/1.1/context"}'} with patch( "app.ro_crates.routes.post_routes.run_metadata_validation" - ) as mock_queue: - mock_queue.return_value = ({"status": "success"}, 200) + ) as mock_run: + mock_run.return_value = ({"status": "valid"}, 200) response = client.post("/v1/ro_crates/validate_metadata", json=payload) assert response.status_code == 200 - mock_queue.assert_called_once() + mock_run.assert_called_once() diff --git a/tests/test_minio.py b/tests/test_minio.py deleted file mode 100644 index 426d901..0000000 --- a/tests/test_minio.py +++ /dev/null @@ -1,506 +0,0 @@ -import json -import pytest -from io import BytesIO -from minio import Minio -from minio.error import S3Error -from unittest.mock import MagicMock, patch -from unittest import mock - - -@pytest.fixture -def mock_minio_response(): - response = MagicMock() - response.data.decode.return_value = json.dumps({"status": "valid"}) - return response - - -class DummyObject: - def __init__(self, name, is_dir=False): - self.object_name = name - self.is_dir = is_dir - - -# Testing function: get_minio_client - -@pytest.mark.parametrize( - "minio_config", - [ - { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False - }, - { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "ignore_this" - } - ], - ids=["base_case", "ignore_extra_items"] -) -def test_get_minio_client_success(minio_config: dict): - - from app.utils.minio_utils import get_minio_client - client = get_minio_client(minio_config) - - assert isinstance(client, Minio) - assert client._base_url.host == "localhost:9000" - - -# Testing function: get_minio_object_list - -def test_get_minio_object_list_success(): - # Setup mock response - mock_response = MagicMock() - mock_objects = [DummyObject("file1.txt"), DummyObject("file2.txt")] - mock_response.__iter__.return_value = iter(mock_objects) - - # Patch minio_client - mock_minio_client = MagicMock() - mock_minio_client.list_objects.return_value = mock_response - - # Call function - from app.utils.minio_utils import get_minio_object_list - result = get_minio_object_list("path/", mock_minio_client, "my-bucket", recursive=True) - - # Assert - assert result == mock_objects - mock_minio_client.list_objects.assert_called_once_with("my-bucket", "path/", recursive=True) - mock_response.close.assert_called_once() - - -@pytest.mark.parametrize( - "bucket, path, status_code, list_side_effect, error_check", - [ - ( - "my-bucket", "path/rocrate.zip", 500, - S3Error(code="S3 error", - message=None, - resource=None, - request_id=None, - host_id=None, - response=None), - "MinIO S3 Error" - ), - ( - "my-bucket", "path/rocrate.zip", 500, - ValueError("Missing config"), - "Configuration Error" - ), - ( - "my-bucket", "path/rocrate.zip", 500, - RuntimeError("Something went wrong"), - "Unknown Error" - ), - ], - ids=["s3error", "value_error", "unexpected_error"] -) -def test_get_minio_object_list_errors(bucket: str, path: str, status_code: int, list_side_effect, error_check: str): - mock_minio_client = MagicMock() - mock_minio_client.list_objects.side_effect = list_side_effect - - from app.utils.minio_utils import get_minio_object_list, InvalidAPIUsage - with pytest.raises(InvalidAPIUsage) as exc: - get_minio_object_list(path, mock_minio_client, bucket) - - assert exc.value.status_code == status_code - assert error_check in str(exc.value.message) - - -# Testing function: find_rocrate_object_on_minio - - -@pytest.mark.parametrize( - "rocrate_object, crateid, bucket, root_path", - [ - ( - DummyObject("my/path/rocrate123/", is_dir=True), - "rocrate123", "bucket", "my/path" - ), - ( - DummyObject("my/path/rocrate123.zip"), - "rocrate123", "bucket", "my/path" - ), - ( - DummyObject("rocrate123.zip"), - "rocrate123", "bucket", None - ), - ], - ids=["rocrate_directory", "rocrate_zip", "rootpath_none"] -) -@patch("app.utils.minio_utils.get_minio_object_list") -def test_finding_rocrate_on_minio( - mock_get_list, - rocrate_object: DummyObject, crateid: str, bucket: str, root_path: str): - # Simulate a directory object match - mock_get_list.return_value = [rocrate_object] - minio_client = MagicMock() - - from app.utils.minio_utils import find_rocrate_object_on_minio - result = find_rocrate_object_on_minio(crateid, minio_client, bucket, root_path) - assert result == rocrate_object - - -@patch("app.utils.minio_utils.get_minio_object_list") -def test_rocrate_not_found(mock_get_list): - # Simulate no matching object - mock_get_list.return_value = [ - DummyObject("something_else"), - DummyObject("another_dir", is_dir=True) - ] - minio_client = MagicMock() - - from app.utils.minio_utils import find_rocrate_object_on_minio - result = find_rocrate_object_on_minio("rocrate123", minio_client, "bucket", None) - - mock_get_list.assert_called_once() - assert not result - - -# Testing function: find_validation_object_on_minio - -@pytest.mark.parametrize( - "object_path, crateid, bucket, root_path", - [ - ( - "my/storage/rocrate123_validation/validation_status.txt", - "rocrate123", "bucket", "my/storage" - ), - ( - "rocrate123_validation/validation_status.txt", - "rocrate123", "bucket", None - ), - ], - ids=["with_storage_path", "without_storage_path"] -) -@patch("app.utils.minio_utils.get_minio_object_list") -def test_validation_object_found_with_storage_path( - mock_get_list, - object_path: str, crateid: str, bucket: str, root_path: str): - # Setup - obj = DummyObject(object_path) - mock_get_list.return_value = [obj] - - from app.utils.minio_utils import find_validation_object_on_minio - # Execute - result = find_validation_object_on_minio(crateid, MagicMock(), bucket, root_path) - - # Assert - assert result == obj - mock_get_list.assert_called_once_with(object_path, mock.ANY, bucket) - - -@pytest.mark.parametrize( - "object_list, crateid, bucket, root_path", - [ - ( - [DummyObject("some/other/object.txt")], - "rocrate999", "bucket", None - ), - ( - [], - "rocrate999", "bucket", None - ), - ], - ids=["other_objects", "empty_list"] -) -@patch("app.utils.minio_utils.get_minio_object_list") -def test_validation_object_not_found( - mock_get_list, - object_list: list, crateid: str, bucket: str, root_path: str): - # Setup: no objects returned - mock_get_list.return_value = object_list - - from app.utils.minio_utils import find_validation_object_on_minio - result = find_validation_object_on_minio(crateid, MagicMock(), bucket, root_path) - - assert result is False - - -# Testing function: download_file_from_minio - -@patch("app.utils.minio_utils.logging") -def test_download_success(mock_logging): - mock_minio = MagicMock() - - from app.utils.minio_utils import download_file_from_minio - # No exceptions raised - download_file_from_minio(mock_minio, "bucket", "remote/path.txt", "local/path.txt") - - mock_minio.fget_object.assert_called_once_with("bucket", "remote/path.txt", "local/path.txt") - mock_logging.error.assert_not_called() - - -@pytest.mark.parametrize( - "bucket, remotepath, localpath, status_code, get_side_effect, error_check", - [ - ( - "my-bucket", "remote/path.txt", "local/path.txt", 500, - S3Error(code="S3 error", - message=None, - resource=None, - request_id=None, - host_id=None, - response=None), - "MinIO S3 Error" - ), - ( - "my-bucket", "remote/path.txt", "local/path.txt", 500, - ValueError("Missing config"), - "Configuration Error" - ), - ( - "my-bucket", "remote/path.txt", "local/path.txt", 500, - RuntimeError("Something went wrong"), - "Unknown Error" - ), - ], - ids=["s3error", "value_error", "unexpected_error"] -) -@patch("app.utils.minio_utils.logging") -def test_download_s3error( - mock_logging, - bucket: str, remotepath: str, localpath: str, status_code: int, - get_side_effect, error_check: str -): - mock_minio = MagicMock() - mock_minio.fget_object.side_effect = get_side_effect - - from app.utils.minio_utils import download_file_from_minio, InvalidAPIUsage - with pytest.raises(InvalidAPIUsage) as exc: - download_file_from_minio(mock_minio, bucket, remotepath, localpath) - - assert exc.value.status_code == status_code - assert error_check in str(exc.value.message) - mock_logging.error.assert_called_once() - - -# Testing function: get_validation_status_from_minio - -def test_successful_retrieval(mocker, mock_minio_response): - mock_client = MagicMock() - mock_client.get_object.return_value = mock_minio_response - - from app.utils.minio_utils import get_validation_status_from_minio - result = get_validation_status_from_minio(mock_client, "test_bucket", "crate123", None) - - assert result == {"status": "valid"} - mock_minio_response.close.assert_called_once() - mock_minio_response.release_conn.assert_called_once() - - -@pytest.mark.parametrize( - "bucket, crateid, root_path, status_code, get_side_effect, error_check", - [ - ( - "my-bucket", "crate123", None, 500, - S3Error(code="S3 error", - message=None, - resource=None, - request_id=None, - host_id=None, - response=None), - "MinIO S3 Error" - ), - ( - "my-bucket", "crate123", None, 500, - ValueError("Missing env var"), - "Configuration Error" - ), - ( - "my-bucket", "crate123", None, 500, - RuntimeError("Unexpected failure"), - "Unknown Error" - ), - ], - ids=["s3error", "value_error", "unexpected_error"] -) -def test_get_validation_error_raised( - mocker, - bucket: str, crateid: str, root_path: str, status_code: int, get_side_effect, error_check: str -): - mock_client = MagicMock() - mock_client.get_object.side_effect = get_side_effect - - from app.utils.minio_utils import get_validation_status_from_minio, InvalidAPIUsage - with pytest.raises(InvalidAPIUsage) as exc: - get_validation_status_from_minio(mock_client, bucket, crateid, root_path) - - assert exc.value.status_code == status_code - assert error_check in str(exc.value.message) - - -# Testing function: update_validation_status_in_minio - -def test_update_validation_status_success(): - mock_minio_client = mock.Mock() - - crate_id = "crate123" - validation_status = json.dumps({"status": "valid", "errors": []}) - - from app.utils.minio_utils import update_validation_status_in_minio - update_validation_status_in_minio(mock_minio_client, "test_bucket", crate_id, "", validation_status) - - expected_object_name = f"{crate_id}_validation/validation_status.txt" - expected_data = json.dumps(json.loads(validation_status), indent=None).encode("utf-8") - - mock_minio_client.put_object.assert_called_once() - args, kwargs = mock_minio_client.put_object.call_args - - # FIXME: Original suggested test expected 4 values in args, but returned only 2. - # Solution was to check both args and kwargs for the 'data' and 'length' objects. - # Do we need to chose one format of call_args for our tests, or is this ambiguity okay? - bucket_name = args[0] if args else kwargs["bucket_name"] - object_name = args[1] if len(args) > 1 else kwargs["object_name"] - actual_data_stream = args[2] if len(args) > 2 else kwargs["data"] - length = args[3] if len(args) > 3 else kwargs["length"] - - assert bucket_name == "test_bucket" - assert object_name == expected_object_name - assert isinstance(actual_data_stream, BytesIO) - actual_data_stream.seek(0) - assert actual_data_stream.read() == expected_data - assert length == len(expected_data) - assert kwargs["content_type"] == "application/json" - - -@pytest.mark.parametrize( - "bucket, crateid, root_path, validation_result, put_side_effect, error_check, status_code", - [ - ( - "my-bucket", "crate123", None, - {"status": "valid"}, - S3Error(code="S3 error", - message=None, - resource=None, - request_id=None, - host_id=None, - response=None), - "MinIO S3 Error", 500 - ), - ( - "my-bucket", "crate123", None, - {"status": "valid"}, - ValueError("Missing env vars"), - "Configuration Error", 500 - ), - ( - "my-bucket", "crate123", None, - {"status": "valid"}, - RuntimeError("Unexpected failure"), - "Unknown Error", 500 - ), - ], - ids=["s3error", "value_error", "unexpected_error"] -) -def test_update_validation_status_erro( - bucket: str, crateid: str, root_path: str, validation_result: dict, - put_side_effect, error_check: str, status_code: int -): - mock_minio_client = mock.Mock() - mock_minio_client.put_object.side_effect = put_side_effect - - from app.utils.minio_utils import update_validation_status_in_minio, InvalidAPIUsage - with pytest.raises(InvalidAPIUsage) as exc: - update_validation_status_in_minio(mock_minio_client, bucket, crateid, root_path, json.dumps(validation_result)) - - assert exc.value.status_code == status_code - assert error_check in str(exc.value.message) - - -# Testing function: fetch_ro_crate_from_minio - -@patch("app.utils.minio_utils.download_file_from_minio") -@patch("app.utils.minio_utils.get_minio_object_list") -@patch("app.utils.minio_utils.find_rocrate_object_on_minio") -def test_fetch_rocrate_zip( - mock_find_object, - mock_get_list, - mock_download, - tmp_path, -): - # Setup mocks - minio_client = "minio_client" - rocrate_obj = DummyObject("some/path/rocrate123.zip", is_dir=False) - mock_find_object.return_value = rocrate_obj - - from app.utils.minio_utils import fetch_ro_crate_from_minio - - with patch("app.utils.minio_utils.tempfile.mkdtemp", return_value=str(tmp_path)): - # Execute - result = fetch_ro_crate_from_minio(minio_client, "test_bucket", "rocrate123", "some/path") - - # Assert - expected_path = tmp_path / "rocrate123.zip" - assert result == str(expected_path) - mock_download.assert_called_once_with( - "minio_client", "test_bucket", - "some/path/rocrate123.zip", str(expected_path)) - - -@patch("app.utils.minio_utils.download_file_from_minio") -@patch("app.utils.minio_utils.get_minio_object_list") -@patch("app.utils.minio_utils.find_rocrate_object_on_minio") -def test_fetch_rocrate_directory( - mock_find_object, - mock_get_list, - mock_download, - tmp_path, -): - # Setup mocks - minio_client = "minio_client" - rocrate_obj = DummyObject("rocrates/rocrate124", is_dir=True) - mock_find_object.return_value = rocrate_obj - - from app.utils.minio_utils import fetch_ro_crate_from_minio - - with patch("app.utils.minio_utils.tempfile.mkdtemp", return_value=str(tmp_path)): - # Objects inside the RO-Crate - mock_get_list.return_value = [ - DummyObject("rocrates/rocrate124/metadata.json"), - DummyObject("rocrates/rocrate124/data/file1.txt"), - ] - - # Execute - result = fetch_ro_crate_from_minio(minio_client, "test_bucket", "rocrate124", "rocrates") - - # Assert - expected_root = tmp_path / "rocrate124" - assert result == str(expected_root) - mock_download.assert_any_call( - "minio_client", "test_bucket", - "rocrates/rocrate124/metadata.json", - str(expected_root / "metadata.json") - ) - mock_download.assert_any_call( - "minio_client", "test_bucket", - "rocrates/rocrate124/data/file1.txt", - str(expected_root / "data/file1.txt") - ) - - -@patch("app.utils.minio_utils.download_file_from_minio") -@patch("app.utils.minio_utils.get_minio_object_list") -@patch("app.utils.minio_utils.find_rocrate_object_on_minio") -def test_fetch_rocrate_handles_empty_dir( - mock_find_object, - mock_get_list, - mock_download, - tmp_path, -): - minio_client = "minio_client" - rocrate_obj = DummyObject("rocrate456", is_dir=True) - mock_find_object.return_value = rocrate_obj - mock_get_list.return_value = [] - - from app.utils.minio_utils import fetch_ro_crate_from_minio - - with patch("app.utils.minio_utils.tempfile.mkdtemp", return_value=str(tmp_path)): - result = fetch_ro_crate_from_minio(minio_client, "test_bucket", "rocrate456", "") - - expected_root = tmp_path / "rocrate456" - assert result == str(expected_root) - mock_download.assert_not_called() diff --git a/tests/test_services.py b/tests/test_services.py index 13af5a6..2907073 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1,145 +1,84 @@ +"""Tests for the validation service layer.""" + import pytest -from unittest.mock import patch, MagicMock +from unittest.mock import patch from flask import Flask -from flask.testing import FlaskClient +from app import create_app +from app.crates.ids import InvalidCrateId +from app.crates.layout import result_key +from app.crates.resolver import CrateNotFound, AmbiguousCrate from app.services.validation_service import ( queue_ro_crate_validation_task, run_metadata_validation, - get_ro_crate_validation_task + get_ro_crate_validation_task, ) +from app.storage.memory import InMemoryStorage +from app.utils.config import InvalidAPIUsage, Settings from app.validation.results import ValidationOutcome, ValidationStatus -from app.utils.minio_utils import InvalidAPIUsage + +def _storage_env() -> dict: + return { + "STORAGE_ENABLED": "true", + "S3_ENDPOINT": "minio:9000", + "S3_ACCESS_KEY": "a", + "S3_SECRET_KEY": "b", + "S3_BUCKET": "ro-crates", + "CELERY_BROKER_URL": "redis://r/0", + "CELERY_RESULT_BACKEND": "redis://r/1", + } @pytest.fixture def flask_app(): + """Bare app context for functions that only need jsonify.""" app = Flask(__name__) with app.app_context(): yield app -# Test function: queue_ro_crate_validation_task +@pytest.fixture +def app_ctx(): + """Storage-enabled app context, so current_app.config['SETTINGS'] is set.""" + app = create_app(settings=Settings.from_env(_storage_env())) + with app.app_context(): + yield app + + +# --- queue_ro_crate_validation_task -------------------------------------- -@pytest.mark.parametrize( - "crate_id, rocrate_exists, minio_client, delay_side_effects, payload, profiles_path, status_code, response_dict", - [ - ( - "crate123", True, "minio_client", None, - { - "minio_config": { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket" - }, - "root_path": "base_path", - "webhook_url": "https://webhook.example.com", - "profile_name": "default" - }, - None, - 202, {"message": "Validation in progress"} - ), - ( - "crate123", True, "minio_client", Exception("Celery down"), - { - "minio_config": { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket" - }, - "root_path": "base_path", - "webhook_url": "https://webhook.example.com", - "profile_name": "default" - }, - None, - 500, {"error": "Celery down"} - ), - ], - ids=["successful_queue", "celery_server_down"] -) @patch("app.services.validation_service.process_validation_task_by_id.delay") -@patch("app.services.validation_service.check_ro_crate_exists") -@patch("app.services.validation_service.get_minio_client") -def test_queue_ro_crate_validation_task( - mock_client, - mock_exists, - mock_delay, - flask_app: FlaskClient, crate_id: str, rocrate_exists: bool, minio_client: str, - delay_side_effects: Exception, payload: dict, profiles_path: str, status_code: int, response_dict: dict -): - mock_delay.side_effect = delay_side_effects - mock_exists.return_value = rocrate_exists - mock_client.return_value = minio_client - - minio_config = payload["minio_config"] if "minio_config" in payload else None - root_path = payload["root_path"] if "root_path" in payload else None - profile_name = payload["profile_name"] if "profile_name" in payload else None - webhook_url = payload["webhook_url"] if "webhook_url" in payload else None - - response, status_code = queue_ro_crate_validation_task(minio_config, crate_id, root_path, - profile_name, webhook_url, profiles_path) - - mock_client.assert_called_once_with(minio_config) - mock_exists.assert_called_once_with(minio_client, minio_config["bucket"], crate_id, root_path) - mock_delay.assert_called_once_with(minio_config, crate_id, root_path, profile_name, webhook_url, profiles_path) - assert status_code == status_code - assert response.json == response_dict +@patch("app.services.validation_service.resolve_crate") +@patch("app.services.validation_service._build_storage") +def test_queue_resolves_then_delays(mock_storage, mock_resolve, mock_delay, app_ctx): + response, status = queue_ro_crate_validation_task("crate123", "ro-crate", "https://hook") + + assert status == 202 + assert response.json == {"message": "Validation in progress"} + mock_resolve.assert_called_once() + mock_delay.assert_called_once_with("crate123", "ro-crate", "https://hook") -@pytest.mark.parametrize( - "crate_id, rocrate_exists, minio_client, payload, iau_message", - [ - ( - "crate12z", False, "minio_client", - { - "minio_config": { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket" - }, - "root_path": "base_path", - "webhook_url": "https://webhook.example.com", - "profile_name": "default" - }, "No RO-Crate with prefix: crate12z" - ), - ], - ids=["no_rocrate_exists"] -) @patch("app.services.validation_service.process_validation_task_by_id.delay") -@patch("app.services.validation_service.check_ro_crate_exists") -@patch("app.services.validation_service.get_minio_client") -def test_queue_ro_crate_validation_task_failure( - mock_client, - mock_exists, - mock_delay, - flask_app: FlaskClient, crate_id: str, rocrate_exists: bool, - minio_client: str, payload: dict, iau_message: str -): - mock_exists.return_value = rocrate_exists - mock_client.return_value = minio_client - - minio_config = payload["minio_config"] if "minio_config" in payload else None - root_path = payload["root_path"] if "root_path" in payload else None - profile_name = payload["profile_name"] if "profile_name" in payload else None - webhook_url = payload["webhook_url"] if "webhook_url" in payload else None +@patch("app.services.validation_service.resolve_crate", side_effect=CrateNotFound("nope")) +@patch("app.services.validation_service._build_storage") +def test_queue_not_found_propagates_without_queueing(mock_storage, mock_resolve, mock_delay, app_ctx): + with pytest.raises(CrateNotFound): + queue_ro_crate_validation_task("missing") + mock_delay.assert_not_called() - with pytest.raises(InvalidAPIUsage) as exc_info: - queue_ro_crate_validation_task(minio_config, crate_id, root_path, profile_name, webhook_url) - assert iau_message in str(exc_info.value.message) - mock_client.assert_called_once_with(minio_config) - mock_exists.assert_called_once_with(minio_client, minio_config["bucket"], crate_id, root_path) +@patch("app.services.validation_service.process_validation_task_by_id.delay") +@patch("app.services.validation_service.resolve_crate", side_effect=AmbiguousCrate("both")) +@patch("app.services.validation_service._build_storage") +def test_queue_ambiguous_propagates_without_queueing(mock_storage, mock_resolve, mock_delay, app_ctx): + with pytest.raises(AmbiguousCrate): + queue_ro_crate_validation_task("dup") mock_delay.assert_not_called() -# Test function: run_metadata_validation (synchronous, no Celery) +# --- run_metadata_validation (synchronous) ------------------------------- @patch("app.services.validation_service.validate_metadata") def test_run_metadata_validation_valid_is_200(mock_validate, flask_app): @@ -147,9 +86,7 @@ def test_run_metadata_validation_valid_is_200(mock_validate, flask_app): status=ValidationStatus.VALID, profile="ro-crate", detail={"report": "ok"} ) - response, status = run_metadata_validation( - '{"@graph": []}', "ro-crate", "/app/profiles" - ) + response, status = run_metadata_validation('{"@graph": []}', "ro-crate", "/app/profiles") assert status == 200 assert response.json["status"] == "valid" @@ -163,9 +100,7 @@ def test_run_metadata_validation_invalid_is_200(mock_validate, flask_app): mock_validate.return_value = ValidationOutcome( status=ValidationStatus.INVALID, detail={"issues": [1]} ) - response, status = run_metadata_validation('{"@graph": []}') - assert status == 200 assert response.json["status"] == "invalid" @@ -173,9 +108,7 @@ def test_run_metadata_validation_invalid_is_200(mock_validate, flask_app): @patch("app.services.validation_service.validate_metadata") def test_run_metadata_validation_error_outcome_is_422(mock_validate, flask_app): mock_validate.return_value = ValidationOutcome.from_error("validator blew up") - response, status = run_metadata_validation('{"@graph": []}') - assert status == 422 assert response.json["status"] == "error" assert "validator blew up" in response.json["error"] @@ -197,87 +130,31 @@ def test_run_metadata_validation_json_errors(flask_app, crate_json, response_err assert response_error in response.json["error"] -# Test function: get_ro_crate_validation_task +# --- get_ro_crate_validation_task ---------------------------------------- -@pytest.mark.parametrize( - "minio_config, crate_id, crate_exists, validation_exists, " + - "validation_value, status_code, error_message, minio_client", - [ - ( - { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket" - }, - "crate123", True, True, {"status": "valid"}, 200, None, - "minio_client" - ), - ( - { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket" - }, - "crate123", False, False, None, 400, "No RO-Crate with prefix: crate123", - "minio_client" - ), - ( - { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket" - }, - "crate123", True, False, None, 400, "No validation result yet for RO-Crate: crate123", - "minio_client" - ), - ], - ids=["validation_exists", "rocrate_missing", "validation_missing"] -) -@patch("app.services.validation_service.check_ro_crate_exists") -@patch("app.services.validation_service.check_validation_exists") -@patch("app.services.validation_service.return_ro_crate_validation") -@patch("app.services.validation_service.get_minio_client") -def test_get_validation( - mock_client, - mock_return, - mock_validation, - mock_rocrate, - flask_app, minio_config: dict, crate_id: str, crate_exists: bool, - validation_exists: bool, validation_value: dict, - status_code: int, error_message: str, minio_client: str -): - mock_client.return_value = minio_client - mock_rocrate.return_value = crate_exists - mock_validation.return_value = validation_exists - mock_return.return_value = validation_value - - if crate_exists and validation_exists: - response, status = get_ro_crate_validation_task(minio_config, crate_id, "base_path") - - mock_client.assert_called_once_with(minio_config) - mock_return.assert_called_once_with(minio_client, minio_config["bucket"], crate_id, "base_path") - mock_rocrate.assert_called_once_with(minio_client, minio_config["bucket"], crate_id, "base_path") - mock_validation.assert_called_once_with(minio_client, minio_config["bucket"], crate_id, "base_path") - - assert status == status_code - assert response == validation_value - - else: - with pytest.raises(InvalidAPIUsage) as exc_info: - get_ro_crate_validation_task(minio_config, crate_id, "base_path") - - assert exc_info.value.status_code == status_code - assert error_message in str(exc_info.value.message) - - mock_rocrate.assert_called_once_with(minio_client, minio_config["bucket"], crate_id, "base_path") - if crate_exists: - mock_validation.assert_called_once_with(minio_client, minio_config["bucket"], crate_id, "base_path") - else: - mock_validation.assert_not_called() - mock_return.assert_not_called() +@patch("app.services.validation_service._build_storage") +def test_get_returns_stored_result(mock_storage, app_ctx): + storage = InMemoryStorage() + storage.put_bytes(result_key("validation-results", "crate123"), b'{"status": "valid"}') + mock_storage.return_value = storage + + response, status = get_ro_crate_validation_task("crate123") + + assert status == 200 + assert response.json["status"] == "valid" + + +@patch("app.services.validation_service._build_storage") +def test_get_missing_result_is_404(mock_storage, app_ctx): + mock_storage.return_value = InMemoryStorage() + + with pytest.raises(InvalidAPIUsage) as exc_info: + get_ro_crate_validation_task("crate123") + assert exc_info.value.status_code == 404 + + +@patch("app.services.validation_service._build_storage") +def test_get_invalid_id_raises(mock_storage, app_ctx): + mock_storage.return_value = InMemoryStorage() + with pytest.raises(InvalidCrateId): + get_ro_crate_validation_task("../bad") diff --git a/tests/test_validation_tasks.py b/tests/test_validation_tasks.py index f93170c..9f9ba4c 100644 --- a/tests/test_validation_tasks.py +++ b/tests/test_validation_tasks.py @@ -1,370 +1,116 @@ +"""Tests for the store-backed validation orchestration (run_validation_job).""" + +import json from unittest import mock + import pytest -import json -from app.tasks.validation_tasks import ( - process_validation_task_by_id, - perform_ro_crate_validation, - return_ro_crate_validation, - check_ro_crate_exists, - check_validation_exists -) - -from app.utils.minio_utils import InvalidAPIUsage - - -# Test function: process_validation_task_by_id - -@pytest.mark.parametrize( - "minio_config, crate_id, os_path_exists, os_path_isfile, os_path_isdir, " + - "return_value, webhook, profile, profiles_path, val_success, val_result, minio_client", - [ - ( - { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket" - }, - "crate123", True, True, False, "/tmp/crate.zip", - "https://example.com/hook", "profileA", None, True, '{"status": "valid"}', - "minio_client" - ), - ( - { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket" - }, - "crate123", True, False, True, "/tmp/crate123", - "https://example.com/hook", "profileA", None, True, '{"status": "valid"}', - "minio_client" - ), - ( - { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket" - }, - "crate123", True, False, True, "/tmp/crate123", - None, "profileA", None, True, '{"status": "valid"}', - "minio_client" - ), - ], - ids=["successful_validation_zip", "successful_validation_dir", "successful_validation_nowebhook"] -) -@mock.patch("app.tasks.validation_tasks.get_minio_client") -@mock.patch("app.tasks.validation_tasks.shutil.rmtree") -@mock.patch("app.tasks.validation_tasks.os.remove") -@mock.patch("app.tasks.validation_tasks.os.path.exists") -@mock.patch("app.tasks.validation_tasks.os.path.isfile") -@mock.patch("app.tasks.validation_tasks.os.path.isdir") -@mock.patch("app.tasks.validation_tasks.send_webhook_notification") -@mock.patch("app.tasks.validation_tasks.update_validation_status_in_minio") -@mock.patch("app.tasks.validation_tasks.perform_ro_crate_validation") -@mock.patch("app.tasks.validation_tasks.fetch_ro_crate_from_minio") -def test_process_validation( - mock_fetch, - mock_validate, - mock_update, - mock_webhook, - mock_isdir, - mock_isfile, - mock_exists, - mock_remove, - mock_rmtree, - mock_client, - minio_config: dict, crate_id: str, os_path_exists: bool, os_path_isfile: bool, os_path_isdir: bool, - return_value: str, webhook: str, profile: str, profiles_path: str, val_success: bool, val_result: str, minio_client: str -): - mock_exists.return_value = os_path_exists - mock_isfile.return_value = os_path_isfile - mock_isdir.return_value = os_path_isdir - mock_fetch.return_value = return_value - mock_client.return_value = minio_client - - mock_validation_result = mock.Mock() - mock_validation_result.has_issues.return_value = val_success - mock_validation_result.to_json.return_value = val_result - mock_validate.return_value = mock_validation_result - - process_validation_task_by_id(minio_config, crate_id, "", profile, webhook, profiles_path) - - mock_client.assert_called_once_with(minio_config) - mock_fetch.assert_called_once_with(minio_client, minio_config["bucket"], crate_id, "") - mock_validate.assert_called_once_with(return_value, profile, profiles_path=profiles_path) - mock_update.assert_called_once_with(minio_client, minio_config["bucket"], crate_id, "", val_result) - if webhook is not None: - mock_webhook.assert_called_once_with(webhook, val_result) - else: - mock_webhook.assert_not_called() - if os_path_exists and os_path_isfile: - mock_remove.assert_called_once_with(return_value) - mock_rmtree.assert_not_called() - elif os_path_exists and os_path_isdir: - mock_rmtree.assert_called_once_with(return_value) - mock_remove.assert_not_called() - - -@pytest.mark.parametrize( - "minio_config, crate_id, os_path_exists, os_path_isfile, os_path_isdir, return_fetch, " - + "webhook, profile, profiles_path, return_validate, validate_side_effect, fetch_side_effect, minio_client", - [ - ( - { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket" - }, - "crate123", True, True, False, "/tmp/crate.zip", - "https://example.com/hook", "profileA", None, "Validation failed", None, None, - "minio_client" - ), - ( - { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket" - }, - "crate123", True, True, False, "/tmp/crate.zip", - "https://example.com/hook", "profileA", None, None, Exception("Unexpected error"), None, - "minio_client" - ), - ( - { - "endpoint": "localhost:9000", - "accesskey": "admin", - "secret": "password123", - "ssl": False, - "bucket": "test_bucket" - }, - "crate123", False, False, False, None, - "https://example.com/hook", "profileA", None, None, None, Exception("MinIO fetch failed"), - "minio_client" - ), - ], - ids=["validation_fails_with_message", "validation_fails_with_validation_exception", - "validation_fails_with_fetch_exception"] -) -@mock.patch("app.tasks.validation_tasks.get_minio_client") -@mock.patch("app.tasks.validation_tasks.shutil.rmtree") -@mock.patch("app.tasks.validation_tasks.os.remove") -@mock.patch("app.tasks.validation_tasks.os.path.exists") -@mock.patch("app.tasks.validation_tasks.os.path.isfile") -@mock.patch("app.tasks.validation_tasks.os.path.isdir") -@mock.patch("app.tasks.validation_tasks.send_webhook_notification") -@mock.patch("app.tasks.validation_tasks.update_validation_status_in_minio") -@mock.patch("app.tasks.validation_tasks.perform_ro_crate_validation") -@mock.patch("app.tasks.validation_tasks.fetch_ro_crate_from_minio") -def test_process_validation_failure( - mock_fetch, - mock_validate, - mock_update, - mock_webhook, - mock_isdir, - mock_isfile, - mock_exists, - mock_remove, - mock_rmtree, - mock_client, - minio_config: dict, crate_id: str, os_path_exists: bool, os_path_isfile: bool, os_path_isdir: bool, - return_fetch: str, webhook: str, profile: str, profiles_path: str, return_validate: str, - validate_side_effect: Exception, fetch_side_effect: Exception, minio_client: str -): - mock_exists.return_value = os_path_exists - mock_isfile.return_value = os_path_isfile - mock_isdir.return_value = os_path_isdir - mock_client.return_value = minio_client - - if fetch_side_effect is None: - mock_fetch.return_value = return_fetch - else: - mock_fetch.side_effect = fetch_side_effect - - if validate_side_effect is None: - mock_validate.return_value = return_validate - else: - mock_validate.side_effect = validate_side_effect - - process_validation_task_by_id(minio_config, crate_id, "", profile, webhook, profiles_path) - - if fetch_side_effect is None: - mock_validate.assert_called_once_with(return_fetch, profile, profiles_path=profiles_path) - else: - mock_validate.assert_not_called() - - mock_update.assert_not_called() - mock_webhook.assert_called_once() - args, kwargs = mock_webhook.call_args - assert args[0] == webhook - if fetch_side_effect is not None: - assert fetch_side_effect.args[0] in args[1]["error"] - elif validate_side_effect is not None: - assert validate_side_effect.args[0] in args[1]["error"] - else: - assert return_validate in args[1]["error"] - - if not os_path_exists: - mock_remove.assert_not_called() - mock_rmtree.assert_not_called() - elif os_path_exists and os_path_isfile: - mock_remove.assert_called_once_with(return_fetch) - mock_rmtree.assert_not_called() - elif os_path_exists and os_path_isdir: - mock_rmtree.assert_called_once_with(return_fetch) - mock_remove.assert_not_called() - - -# Test function: perform_ro_crate_validation - -@pytest.mark.parametrize( - "file_path, profile_name, skip_checks", - [ - ("crates/test_crate", "ro_profile", ["check1", "check2"]), - ("crates/test_crate", None, None) - ], - ids=["success_with_all_args", "success_with_only_crate"] -) -@mock.patch("app.tasks.validation_tasks.services.validate") -@mock.patch("app.tasks.validation_tasks.services.ValidationSettings") -def test_validation_success_with_all_args( - mock_validation_settings, mock_validate, - file_path: str, profile_name: str, skip_checks: list -): - mock_result = mock.Mock() - mock_validate.return_value = mock_result - - result = perform_ro_crate_validation(file_path, profile_name, skip_checks) - - # Assert that result was returned - assert result == mock_result - - # Validate proper construction of ValidationSettings - mock_validation_settings.assert_called_once() - args, kwargs = mock_validation_settings.call_args - assert kwargs["rocrate_uri"].endswith(file_path) - if profile_name is not None: - assert kwargs["profile_identifier"] == profile_name - else: - assert "profile_identifier" not in kwargs - if skip_checks is not None: - assert kwargs["skip_checks"] == skip_checks - else: - assert "skip_checks" not in kwargs - - mock_validate.assert_called_once_with(mock_validation_settings.return_value) - - -@mock.patch("app.tasks.validation_tasks.services.validate", side_effect=RuntimeError("Validation error")) -@mock.patch("app.tasks.validation_tasks.services.ValidationSettings") -def test_validation_raises_exception_and_returns_string(mock_validation_settings, mock_validate): - file_path = "crates/test_crate" - result = perform_ro_crate_validation(file_path, "profile", skip_checks_list=None) - - assert isinstance(result, str) - assert "Validation error" in result - mock_validate.assert_called_once() - - -@mock.patch("app.tasks.validation_tasks.services.validate") -@mock.patch("app.tasks.validation_tasks.services.ValidationSettings", side_effect=ValueError("Bad config")) -def test_validation_settings_error(mock_validation_settings, mock_validate): - file_path = "crates/test_crate" - result = perform_ro_crate_validation(file_path, None) - - assert isinstance(result, str) - assert "Bad config" in result - mock_validate.assert_not_called() - - -# Test function: return_ro_crate_validation - -@mock.patch("app.tasks.validation_tasks.get_validation_status_from_minio") -def test_return_validation_returns_dict(mock_get_status): - # Simulate dict result - mock_get_status.return_value = {"status": "passed", "errors": []} - - result = return_ro_crate_validation("minio_client", "test_bucket", "crate123", None) - assert isinstance(result, dict) - assert result["status"] == "passed" - mock_get_status.assert_called_once_with("minio_client", "test_bucket", "crate123", None) - - -@mock.patch("app.tasks.validation_tasks.get_validation_status_from_minio") -def test_return_validation_returns_string(mock_get_status): - # Simulate string result - mock_get_status.return_value = "Validation result: OK" - - result = return_ro_crate_validation("minio_client", "test_bucket", "crate456", None) - assert isinstance(result, str) - assert "OK" in result - mock_get_status.assert_called_once_with("minio_client", "test_bucket", "crate456", None) - - -@mock.patch("app.tasks.validation_tasks.get_validation_status_from_minio") -def test_return_validation_raises_error(mock_get_status): - # Simulate exception - mock_get_status.side_effect = InvalidAPIUsage("MinIO S3 Error: empty", 500) - - with pytest.raises(InvalidAPIUsage) as exc_info: - return_ro_crate_validation("minio_client", "test_bucket", "crate789", None) - - assert "MinIO S3 Error" in str(exc_info.value.message) - mock_get_status.assert_called_once_with("minio_client", "test_bucket", "crate789", None) - - -# Test function: check_ro_crate_exists - -@pytest.mark.parametrize( - "minio_client, bucket, crate_id, base_path, ro_object_return, rocrate_exists", - [ - ("minio_client", "test_bucket", "crate123", "base_path", "crate123", True), - ("minio_client", "test_bucket", "crate12z", "base_path", False, False) - ], - ids=["rocrate_exists", "rocrate_does_not_exist"] -) -@mock.patch("app.tasks.validation_tasks.find_rocrate_object_on_minio") -def test_ro_crate_exists( - mock_find_rocrate, - minio_client: str, bucket: str, crate_id: str, base_path: str, - ro_object_return: str, rocrate_exists: bool -): - mock_find_rocrate.return_value = ro_object_return - - result = check_ro_crate_exists(minio_client, bucket, crate_id, base_path) +from app.crates.layout import result_key +from app.storage.errors import StorageError +from app.storage.memory import InMemoryStorage +from app.tasks import validation_tasks +from app.tasks.validation_tasks import run_validation_job +from app.utils.config import Settings +from app.validation.results import ValidationOutcome, ValidationStatus + +RUNNER = "app.tasks.validation_tasks.validate_crate_path" +WEBHOOK = "app.tasks.validation_tasks.send_webhook_notification" + + +def _settings() -> Settings: + return Settings.from_env( + { + "STORAGE_ENABLED": "true", + "S3_ENDPOINT": "minio:9000", + "S3_ACCESS_KEY": "a", + "S3_SECRET_KEY": "b", + "S3_BUCKET": "ro-crates", + "CELERY_BROKER_URL": "redis://r/0", + "CELERY_RESULT_BACKEND": "redis://r/1", + } + ) + + +def _stored_outcome(storage: InMemoryStorage, crate_id: str) -> dict: + raw = storage.get_bytes(result_key("validation-results", crate_id)) + return json.loads(raw) + + +@pytest.fixture +def storage() -> InMemoryStorage: + return InMemoryStorage() + + +def test_valid_zip_crate_is_validated_and_persisted(storage): + storage.put_bytes("crates/foo.zip", b"PK\x03\x04") + + with mock.patch(RUNNER) as run, mock.patch(WEBHOOK) as hook: + run.return_value = ValidationOutcome( + status=ValidationStatus.VALID, profile=None, detail={"r": 1}, created_at="t" + ) + outcome = run_validation_job(storage, "foo", _settings(), created_at="t") + + assert outcome.status is ValidationStatus.VALID + assert _stored_outcome(storage, "foo")["status"] == "valid" + # The validator was handed the downloaded zip path. + assert run.call_args.args[0].endswith("foo.zip") + hook.assert_not_called() + + +def test_directory_crate_is_downloaded_and_persisted(storage): + storage.put_bytes("crates/foo/ro-crate-metadata.json", b"{}") + storage.put_bytes("crates/foo/data.csv", b"x") + + with mock.patch(RUNNER) as run, mock.patch(WEBHOOK): + run.return_value = ValidationOutcome( + status=ValidationStatus.INVALID, detail={"issues": [1]}, created_at="t" + ) + run_validation_job(storage, "foo", _settings(), created_at="t") + + assert _stored_outcome(storage, "foo")["status"] == "invalid" + + +def test_missing_crate_persists_error_outcome(storage): + with mock.patch(RUNNER) as run, mock.patch(WEBHOOK): + outcome = run_validation_job(storage, "absent", _settings(), created_at="t") + + assert outcome.status is ValidationStatus.ERROR + assert _stored_outcome(storage, "absent")["status"] == "error" + run.assert_not_called() # never reached the validator + + +def test_webhook_is_sent_with_outcome_when_url_given(storage): + storage.put_bytes("crates/foo.zip", b"PK") + + with mock.patch(RUNNER) as run, mock.patch(WEBHOOK) as hook: + run.return_value = ValidationOutcome(status=ValidationStatus.VALID, created_at="t") + run_validation_job( + storage, "foo", _settings(), webhook_url="https://hook", created_at="t" + ) + + hook.assert_called_once() + url, payload = hook.call_args.args + assert url == "https://hook" + assert payload["status"] == "valid" + + +def test_transient_storage_error_propagates_for_retry(): + class FlakyStorage(InMemoryStorage): + def get_bytes(self, key): + raise StorageError("temporary outage") + + storage = FlakyStorage() + storage.put_bytes("crates/foo.zip", b"PK") # so resolution finds the zip + + with mock.patch(RUNNER), mock.patch(WEBHOOK): + with pytest.raises(StorageError): + run_validation_job(storage, "foo", _settings(), created_at="t") - mock_find_rocrate.assert_called_once_with(crate_id, minio_client, bucket, base_path) - assert result is rocrate_exists - - -# Test function: check_validation_exists - -@pytest.mark.parametrize( - "minio_client, bucket, crate_id, base_path, val_object_return, validate_exists", - [ - ("minio_client", "test_bucket", "crate123", "base_path", "crate123", True), - ("minio_client", "test_bucket", "crate12z", "base_path", False, False) - ], - ids=["validation_exists", "validation_does_not_exist"] -) -@mock.patch("app.tasks.validation_tasks.find_validation_object_on_minio") -def test_validation_exists( - mock_find_validation, - minio_client: str, bucket: str, crate_id: str, base_path: str, - val_object_return: str, validate_exists: bool -): - mock_find_validation.return_value = val_object_return - result = check_validation_exists(minio_client, bucket, crate_id, base_path) +def test_created_at_is_persisted(storage): + storage.put_bytes("crates/foo.zip", b"PK") + with mock.patch(RUNNER) as run, mock.patch(WEBHOOK): + run.return_value = ValidationOutcome(status=ValidationStatus.VALID, created_at="2026-06-16T00:00:00Z") + run_validation_job(storage, "foo", _settings(), created_at="2026-06-16T00:00:00Z") - mock_find_validation.assert_called_once_with(crate_id, minio_client, bucket, base_path) - assert result is validate_exists + assert _stored_outcome(storage, "foo")["created_at"] == "2026-06-16T00:00:00Z" From 56913c11375b12e888776c5716e0140b66d1e23e Mon Sep 17 00:00:00 2001 From: Alex Hambley <33315205+alexhambley@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:51:29 +0100 Subject: [PATCH 08/16] feat(webhooks): retry delivery, with backoff and surface failures - Refactor `send_webhook_notification` to retry failures with exponential backoff, add a request timeout, and raise `WebhookDeliveryError` on terminal failure instead of accepting it. - Results are persisted so a delivery failure can be surfaced without losing the result. Closes #180 --- app/utils/webhook_utils.py | 73 ++++++++++++++++++++++++---------- tests/test_validation_tasks.py | 18 +++++++++ tests/test_webhooks.py | 58 +++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 20 deletions(-) create mode 100644 tests/test_webhooks.py diff --git a/app/utils/webhook_utils.py b/app/utils/webhook_utils.py index 3a77fb1..9d152e3 100644 --- a/app/utils/webhook_utils.py +++ b/app/utils/webhook_utils.py @@ -1,29 +1,62 @@ -"""Utility methods for sending webhook notifications.""" - -# Author: Alexander Hambley -# License: MIT -# Copyright (c) 2025 eScience Lab, The University of Manchester +"""Webhook delivery with bounded retries and backoff.""" import logging -import requests +import time -from typing import Any +from typing import Any, Callable + +import requests logger = logging.getLogger(__name__) +DEFAULT_TIMEOUT = 10 -def send_webhook_notification(url: str, data: Any) -> None: - """ - Sends a POST request to the specified webhook URL with the given data. - :param url: The URL to send the webhook notification to. - :param data: The data to send in the POST request. - :raises requests.RequestException: If an error occurs when sending the notification. - """ +class WebhookDeliveryError(Exception): + """Raised when a webhook could not be delivered after all retries.""" + - try: - response = requests.post(url, json=data) - response.raise_for_status() - logging.info(f"Webhook notification sent successfully to {url}") - except requests.RequestException as e: - logging.error(f"Failed to send webhook notification: {e}") +def send_webhook_notification( + url: str, + data: Any, + max_attempts: int = 3, + base_delay: float = 0.5, + sleep: Callable[[float], None] = time.sleep, +) -> None: + """ + POST ``data`` to ``url`` as JSON, retrying transient failures. + + Retries up to ``max_attempts`` times with exponential backoff. On final + failure it raises :class:`WebhookDeliveryError` rather than swallowing the + error, so the caller can surface it. + + :param url: The webhook URL to POST to. + :param data: JSON-serialisable payload. + :param max_attempts: Total number of attempts before giving up. + :param base_delay: Base backoff delay in seconds (doubled each retry). + :param sleep: Sleep function (injectable for testing). + :raises WebhookDeliveryError: If delivery fails after ``max_attempts``. + """ + last_error = None + + for attempt in range(1, max_attempts + 1): + try: + response = requests.post(url, json=data, timeout=DEFAULT_TIMEOUT) + response.raise_for_status() + logger.info("Webhook delivered to %s (attempt %d)", url, attempt) + return + except requests.RequestException as error: + last_error = error + logger.warning( + "Webhook attempt %d/%d to %s failed: %s", + attempt, + max_attempts, + url, + error, + ) + if attempt < max_attempts: + sleep(base_delay * (2 ** (attempt - 1))) + + raise WebhookDeliveryError( + f"Failed to deliver webhook to {url} after {max_attempts} attempts: {last_error}" + ) diff --git a/tests/test_validation_tasks.py b/tests/test_validation_tasks.py index 9f9ba4c..01b26a5 100644 --- a/tests/test_validation_tasks.py +++ b/tests/test_validation_tasks.py @@ -11,6 +11,7 @@ from app.tasks import validation_tasks from app.tasks.validation_tasks import run_validation_job from app.utils.config import Settings +from app.utils.webhook_utils import WebhookDeliveryError from app.validation.results import ValidationOutcome, ValidationStatus RUNNER = "app.tasks.validation_tasks.validate_crate_path" @@ -107,6 +108,23 @@ def get_bytes(self, key): run_validation_job(storage, "foo", _settings(), created_at="t") +def test_webhook_failure_surfaces_but_result_is_already_persisted(storage): + """A terminal webhook failure propagates, yet the outcome was persisted first.""" + storage.put_bytes("crates/foo.zip", b"PK") + + with mock.patch(RUNNER) as run, mock.patch(WEBHOOK) as hook: + run.return_value = ValidationOutcome(status=ValidationStatus.VALID, created_at="t") + hook.side_effect = WebhookDeliveryError("gave up") + + with pytest.raises(WebhookDeliveryError): + run_validation_job( + storage, "foo", _settings(), webhook_url="https://hook", created_at="t" + ) + + # Persisted before the webhook was attempted, so GET still works. + assert _stored_outcome(storage, "foo")["status"] == "valid" + + def test_created_at_is_persisted(storage): storage.put_bytes("crates/foo.zip", b"PK") with mock.patch(RUNNER) as run, mock.patch(WEBHOOK): diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 0000000..018782f --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,58 @@ +"""Tests for webhook delivery with retry/backoff.""" + +from unittest import mock + +import pytest +import requests + +from app.utils import webhook_utils +from app.utils.webhook_utils import send_webhook_notification, WebhookDeliveryError + + +def _ok_response(): + response = mock.Mock() + response.raise_for_status.return_value = None + return response + + +def test_successful_delivery_posts_once(): + with mock.patch.object(webhook_utils.requests, "post", return_value=_ok_response()) as post: + send_webhook_notification("https://hook", {"status": "valid"}, sleep=lambda _: None) + post.assert_called_once() + # The payload is sent as JSON and a timeout is set (no unbounded hang). + assert post.call_args.kwargs["json"] == {"status": "valid"} + assert "timeout" in post.call_args.kwargs + + +def test_retries_then_succeeds(): + flaky = [requests.ConnectionError("boom"), requests.ConnectionError("boom"), _ok_response()] + sleeps = [] + with mock.patch.object(webhook_utils.requests, "post", side_effect=flaky) as post: + send_webhook_notification( + "https://hook", {"x": 1}, max_attempts=3, sleep=sleeps.append + ) + assert post.call_count == 3 + assert len(sleeps) == 2 # slept between the three attempts + + +def test_terminal_failure_raises_after_exhausting_attempts(): + with mock.patch.object( + webhook_utils.requests, "post", side_effect=requests.ConnectionError("down") + ) as post: + with pytest.raises(WebhookDeliveryError) as exc_info: + send_webhook_notification( + "https://hook", {"x": 1}, max_attempts=3, sleep=lambda _: None + ) + assert post.call_count == 3 + assert "https://hook" in str(exc_info.value) + + +def test_http_error_status_is_retried(): + bad = mock.Mock() + bad.raise_for_status.side_effect = requests.HTTPError("500") + with mock.patch.object(webhook_utils.requests, "post", return_value=bad) as post: + with pytest.raises(WebhookDeliveryError): + send_webhook_notification( + "https://hook", {"x": 1}, max_attempts=2, sleep=lambda _: None + ) + assert post.call_count == 2 From ee62364e178bf7d201453c5204f528b24e4adb4d Mon Sep 17 00:00:00 2001 From: Alex Hambley <33315205+alexhambley@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:20:24 +0100 Subject: [PATCH 09/16] feat(logging): structured JSON logs, request IDs, secret redaction Rewrite logging_service with a JsonFormatter, a RedactionFilter that masks configured S3 credentials, and a RequestIdFilter closes #177 --- app/__init__.py | 21 ++++++-- app/services/logging_service.py | 95 ++++++++++++++++++++++++++++----- cratey.py | 2 +- tests/test_app_factory.py | 22 ++++++++ tests/test_logging.py | 74 +++++++++++++++++++++++++ 5 files changed, 196 insertions(+), 18 deletions(-) create mode 100644 tests/test_logging.py diff --git a/app/__init__.py b/app/__init__.py index caafe64..d61d51a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -7,16 +7,23 @@ from app.crates.ids import InvalidCrateId from app.crates.resolver import CrateNotFound, AmbiguousCrate from app.ro_crates.routes import v1_post_bp, v1_minio_post_bp, v1_minio_get_bp +from app.services.logging_service import ( + new_request_id, + set_request_id, + get_request_id, +) from app.storage.errors import StorageError from app.utils.config import ( Settings, InvalidAPIUsage, make_celery, ) -from flask import jsonify +from flask import jsonify, request logger = logging.getLogger(__name__) +REQUEST_ID_HEADER = "X-Request-ID" + def create_app(settings: Settings | None = None) -> APIFlask: """ @@ -52,10 +59,14 @@ def create_app(settings: Settings | None = None) -> APIFlask: else: logger.info("Storage disabled: only metadata validation is available.") - if app.debug: - print("URL Map:") - for rule in app.url_map.iter_rules(): - print(rule) + @app.before_request + def assign_request_id(): + set_request_id(request.headers.get(REQUEST_ID_HEADER) or new_request_id()) + + @app.after_request + def attach_request_id(response): + response.headers[REQUEST_ID_HEADER] = get_request_id() + return response @app.errorhandler(InvalidAPIUsage) def invalid_api_usage(e): diff --git a/app/services/logging_service.py b/app/services/logging_service.py index 94c0dc0..91e776e 100644 --- a/app/services/logging_service.py +++ b/app/services/logging_service.py @@ -1,19 +1,90 @@ -"""Logging service for the application.""" - -# Author: Alexander Hambley -# License: MIT -# Copyright (c) 2025 eScience Lab, The University of Manchester +"""Structured JSON logging with request IDs and secret redaction.""" +import json import logging +import uuid + +from contextvars import ContextVar +from typing import Iterable, Optional + +# correlation ID readable from any logging call in the same context. per request basis. +# (request handler or Celery task). Defaults to "-" when unset. +_request_id: ContextVar[str] = ContextVar("request_id", default="-") + + +def new_request_id() -> str: + """Return a fresh, unique request ID.""" + return str(uuid.uuid4()) + + +def set_request_id(request_id: Optional[str]) -> None: + """Set the current request ID (``None`` resets to the default).""" + _request_id.set(request_id or "-") + + +def get_request_id() -> str: + """Return the current request ID, or ``"-"`` if unset.""" + return _request_id.get() + + +class RequestIdFilter(logging.Filter): + """Attaches the current request ID to every log record.""" + def filter(self, record: logging.LogRecord) -> bool: + record.request_id = get_request_id() + return True -def setup_logging(level: int = logging.INFO) -> None: + +class RedactionFilter(logging.Filter): + """Masks known secret values wherever they appear in a log message.""" + + def __init__(self, secrets: Iterable[Optional[str]]): + super().__init__() + self._secrets = [s for s in secrets if s] + + def filter(self, record: logging.LogRecord) -> bool: + if self._secrets: + message = record.getMessage() + for secret in self._secrets: + message = message.replace(secret, "***") + record.msg = message + record.args = None + return True + + +class JsonFormatter(logging.Formatter): + """Formats log records as single-line JSON.""" + + def format(self, record: logging.LogRecord) -> str: + payload = { + "timestamp": self.formatTime(record), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + "request_id": getattr(record, "request_id", "-"), + } + if record.exc_info: + payload["exc_info"] = self.formatException(record.exc_info) + return json.dumps(payload) + + +def setup_logging(settings=None, level: int = logging.INFO) -> None: """ - Configure the logging for the application. + Configure root logging: JSON output, request IDs, and secret redaction. - :param level: The logging level to set. Defaults to INFO. + :param settings: Optional Settings; its credentials are redacted from logs. + :param level: The logging level to set. """ - logging.basicConfig( - level=level, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - ) + secrets = [] + if settings is not None: + secrets = [settings.s3_secret_key, settings.s3_access_key] + + handler = logging.StreamHandler() + handler.setFormatter(JsonFormatter()) + handler.addFilter(RequestIdFilter()) + handler.addFilter(RedactionFilter(secrets)) + + root = logging.getLogger() + root.handlers.clear() + root.addHandler(handler) + root.setLevel(level) diff --git a/cratey.py b/cratey.py index 338a4a5..f514f14 100644 --- a/cratey.py +++ b/cratey.py @@ -8,7 +8,7 @@ from app.services.logging_service import setup_logging app = create_app() -setup_logging() +setup_logging(app.config["SETTINGS"]) if __name__ == "__main__": # Run the Flask development server: diff --git a/tests/test_app_factory.py b/tests/test_app_factory.py index 9482a43..4c34f56 100644 --- a/tests/test_app_factory.py +++ b/tests/test_app_factory.py @@ -59,3 +59,25 @@ def test_storage_routes_registered_when_enabled(): def test_profiles_path_exposed_to_app_config(): app = create_app(settings=Settings.from_env({"PROFILES_PATH": "/custom/profiles"})) assert app.config["PROFILES_PATH"] == "/custom/profiles" + + +def test_response_includes_generated_request_id_header(): + app = create_app(settings=Settings.from_env({})) + client = app.test_client() + + response = client.post("/v1/ro_crates/validate_metadata", json={"crate_json": "{}"}) + + assert response.headers.get("X-Request-ID") + + +def test_incoming_request_id_is_echoed(): + app = create_app(settings=Settings.from_env({})) + client = app.test_client() + + response = client.post( + "/v1/ro_crates/validate_metadata", + json={"crate_json": "{}"}, + headers={"X-Request-ID": "caller-supplied-id"}, + ) + + assert response.headers["X-Request-ID"] == "caller-supplied-id" diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..25add24 --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,74 @@ +"""Tests for structured logging, request IDs, and secret redaction.""" + +import json +import logging + +from app.services.logging_service import ( + JsonFormatter, + RedactionFilter, + RequestIdFilter, + set_request_id, + get_request_id, + new_request_id, +) + + +def _record(msg, args=None): + return logging.LogRecord("svc", logging.INFO, "path", 1, msg, args, None) + + +def test_json_formatter_emits_expected_fields(): + record = _record("hello") + record.request_id = "r1" + + payload = json.loads(JsonFormatter().format(record)) + + assert payload["level"] == "INFO" + assert payload["logger"] == "svc" + assert payload["message"] == "hello" + assert payload["request_id"] == "r1" + assert "timestamp" in payload + + +def test_redaction_filter_masks_secret_values(): + redact = RedactionFilter(["supersecret", "AKIAEXAMPLE"]) + record = _record("connecting with key=%s token=%s", ("AKIAEXAMPLE", "supersecret")) + + redact.filter(record) + + message = record.getMessage() + assert "supersecret" not in message + assert "AKIAEXAMPLE" not in message + assert message.count("***") == 2 + + +def test_redaction_filter_ignores_empty_secrets(): + redact = RedactionFilter([None, "", "real"]) + record = _record("value=real") + redact.filter(record) + assert record.getMessage() == "value=***" + + +def test_request_id_filter_injects_current_id(): + set_request_id("abc-123") + record = _record("anything") + + RequestIdFilter().filter(record) + + assert record.request_id == "abc-123" + + +def test_request_id_filter_defaults_when_unset(): + set_request_id(None) + record = _record("anything") + RequestIdFilter().filter(record) + assert record.request_id == "-" + + +def test_new_request_id_is_unique(): + assert new_request_id() != new_request_id() + + +def test_get_request_id_round_trips(): + set_request_id("xyz") + assert get_request_id() == "xyz" From f900ae2a23de9d3bc6002b3875c3a4bf32290804 Mon Sep 17 00:00:00 2001 From: Alex Hambley <33315205+alexhambley@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:30:08 +0100 Subject: [PATCH 10/16] feat: add /healthz and /readyz endpoints - Adds endpoints to check if the S3 is reachable and Celery / broker connected; - Returns 503 with a per-check breakdown when a dependency is down. - When storage is disabled both checks report "disabled". Closes #181 --- app/__init__.py | 2 + app/health.py | 70 +++++++++++++++++++++++++++++++ app/ro_crates/routes/__init__.py | 4 -- app/storage/s3.py | 9 ++++ tests/test_health.py | 71 ++++++++++++++++++++++++++++++++ tests/test_storage_s3.py | 10 +++++ 6 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 app/health.py create mode 100644 tests/test_health.py diff --git a/app/__init__.py b/app/__init__.py index d61d51a..fd02815 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -6,6 +6,7 @@ from app.crates.ids import InvalidCrateId from app.crates.resolver import CrateNotFound, AmbiguousCrate +from app.health import health_bp from app.ro_crates.routes import v1_post_bp, v1_minio_post_bp, v1_minio_get_bp from app.services.logging_service import ( new_request_id, @@ -48,6 +49,7 @@ def create_app(settings: Settings | None = None) -> APIFlask: app.config["PROFILES_PATH"] = settings.profiles_path # Always available: + app.register_blueprint(health_bp) app.register_blueprint(v1_post_bp, url_prefix="/v1/ro_crates") # Object storage is optional and disabled by default. Only register the diff --git a/app/health.py b/app/health.py new file mode 100644 index 0000000..3988c33 --- /dev/null +++ b/app/health.py @@ -0,0 +1,70 @@ +"""Liveness and readiness endpoints for orchestration. + +``/healthz`` reports that the process is up. ``/readyz`` reports whether the +service can actually serve S3 requests, by checking the object store and the +Celery broker. When storage is disabled, those dependencies are not required +and report ``disabled``. +""" + +import logging + +from apiflask import APIBlueprint +from flask import current_app, jsonify + +from app.storage.s3 import S3Backend + +logger = logging.getLogger(__name__) + +health_bp = APIBlueprint("health", __name__) + +# Short connection timeout (seconds) so readiness checks fail quickly. +_BROKER_TIMEOUT = 3 + + +def check_storage(settings) -> tuple[bool, str]: + """Return whether the object store is reachable, with a detail string.""" + if not settings.storage_enabled: + return True, "disabled" + try: + S3Backend.from_settings(settings).health_check() + return True, "ok" + except Exception as error: # noqa: BLE001 - any failure means not ready + logger.warning("Storage readiness check failed: %s", error) + return False, str(error) + + +def check_broker(settings) -> tuple[bool, str]: + """Return whether the Celery broker is reachable, with a detail string.""" + if not settings.storage_enabled: + return True, "disabled" + try: + from kombu import Connection + + with Connection(settings.celery_broker_url) as connection: + connection.ensure_connection(max_retries=1, timeout=_BROKER_TIMEOUT) + return True, "ok" + except Exception as error: # noqa: BLE001 - any failure means not ready + logger.warning("Broker readiness check failed: %s", error) + return False, str(error) + + +@health_bp.get("/healthz") +def healthz(): + """Liveness: the process is running.""" + return jsonify({"status": "ok"}), 200 + + +@health_bp.get("/readyz") +def readyz(): + """Readiness: dependencies needed to serve requests are reachable.""" + settings = current_app.config["SETTINGS"] + + storage_ok, storage_detail = check_storage(settings) + broker_ok, broker_detail = check_broker(settings) + ready = storage_ok and broker_ok + + body = { + "status": "ready" if ready else "not ready", + "checks": {"storage": storage_detail, "broker": broker_detail}, + } + return jsonify(body), (200 if ready else 503) diff --git a/app/ro_crates/routes/__init__.py b/app/ro_crates/routes/__init__.py index 62fbbde..75b4405 100644 --- a/app/ro_crates/routes/__init__.py +++ b/app/ro_crates/routes/__init__.py @@ -1,9 +1,5 @@ """Defines main Blueprint and registers sub-Blueprints for organising related routes.""" -# Author: Alexander Hambley -# License: MIT -# Copyright (c) 2025 eScience Lab, The University of Manchester - from app.ro_crates.routes.post_routes import post_routes_bp, minio_post_routes_bp from app.ro_crates.routes.get_routes import get_routes_bp diff --git a/app/storage/s3.py b/app/storage/s3.py index 4f755c4..26e4c7e 100644 --- a/app/storage/s3.py +++ b/app/storage/s3.py @@ -92,6 +92,15 @@ def download_tree(self, prefix: str, dest_dir: str) -> None: with open(local_path, "wb") as handle: handle.write(self.get_bytes(key)) + def health_check(self) -> None: + """Verify the bucket is reachable; raise ``StorageError`` if not.""" + try: + self._client.head_bucket(Bucket=self.bucket) + except (ClientError, BotoCoreError) as error: + raise StorageError( + f"Bucket {self.bucket} not reachable: {error}" + ) from error + @staticmethod def _translate(error: ClientError, key: str) -> StorageError: """Map a botocore ClientError to the storage error vocabulary. diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..84c1bb3 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,71 @@ +"""Tests for the health and readiness endpoints.""" + +from unittest import mock + +import pytest + +from app import create_app +from app.utils.config import Settings + + +def _storage_env() -> dict: + return { + "STORAGE_ENABLED": "true", + "S3_ENDPOINT": "minio:9000", + "S3_ACCESS_KEY": "a", + "S3_SECRET_KEY": "b", + "S3_BUCKET": "ro-crates", + "CELERY_BROKER_URL": "redis://r/0", + "CELERY_RESULT_BACKEND": "redis://r/1", + } + + +@pytest.fixture +def disabled_client(): + return create_app(settings=Settings.from_env({})).test_client() + + +@pytest.fixture +def storage_client(): + return create_app(settings=Settings.from_env(_storage_env())).test_client() + + +def test_healthz_is_always_ok(disabled_client): + response = disabled_client.get("/healthz") + assert response.status_code == 200 + assert response.json["status"] == "ok" + + +def test_readyz_ready_when_storage_disabled(disabled_client): + response = disabled_client.get("/readyz") + assert response.status_code == 200 + assert response.json["status"] == "ready" + assert response.json["checks"]["storage"] == "disabled" + + +def test_readyz_ok_when_all_checks_pass(storage_client): + with mock.patch("app.health.check_storage", return_value=(True, "ok")), \ + mock.patch("app.health.check_broker", return_value=(True, "ok")): + response = storage_client.get("/readyz") + + assert response.status_code == 200 + assert response.json["status"] == "ready" + + +def test_readyz_503_when_storage_unreachable(storage_client): + with mock.patch("app.health.check_storage", return_value=(False, "bucket down")), \ + mock.patch("app.health.check_broker", return_value=(True, "ok")): + response = storage_client.get("/readyz") + + assert response.status_code == 503 + assert response.json["status"] == "not ready" + assert response.json["checks"]["storage"] == "bucket down" + + +def test_readyz_503_when_broker_unreachable(storage_client): + with mock.patch("app.health.check_storage", return_value=(True, "ok")), \ + mock.patch("app.health.check_broker", return_value=(False, "broker down")): + response = storage_client.get("/readyz") + + assert response.status_code == 503 + assert response.json["checks"]["broker"] == "broker down" diff --git a/tests/test_storage_s3.py b/tests/test_storage_s3.py index 3d8660e..d1b4bcf 100644 --- a/tests/test_storage_s3.py +++ b/tests/test_storage_s3.py @@ -78,6 +78,16 @@ def test_non_missing_client_error_becomes_storage_error(s3_backend): assert not isinstance(exc_info.value, ObjectNotFound) +def test_health_check_passes_for_existing_bucket(s3_backend): + s3_backend.health_check() # must not raise + + +def test_health_check_fails_for_missing_bucket(s3_backend): + broken = S3Backend(s3_backend._client, "nonexistent-bucket") + with pytest.raises(StorageError): + broken.health_check() + + def test_s3_backend_satisfies_protocol(s3_backend): assert isinstance(s3_backend, StorageBackend) From 721dcb8b1891b7d6f99fb2c5e826ccbd6a0076ee Mon Sep 17 00:00:00 2001 From: Alex Hambley <33315205+alexhambley@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:43:05 +0100 Subject: [PATCH 11/16] chore(tooling): adopt pyproject, ruff, and update CI - Consolidate packaging and tooling into pyproject.toml and standardise linting and formatting with ruff. - Move metadata and deps into pyproject.toml; compile requirements.txt (runtime) and requirements-dev.txt (runtime + dev: pytest, pytest-mock, moto, ruff) from it via pip-tools; retire requirements.in. - Move pytest config into [tool.pytest.ini_options] and remove pytest.ini. - Add [tool.ruff] config; apply ruff check --fix and ruff format across code. - CI: unit_tests installs requirements-dev.txt; add a lint workflow running ruff check and ruff format --check on PRs to develop. Closes #183, #173 --- .github/workflows/lint.yml | 29 +++ .github/workflows/unit_tests.yml | 3 +- app/__init__.py | 10 +- app/celery_worker.py | 1 - app/crates/resolver.py | 2 +- app/ro_crates/routes/__init__.py | 2 +- app/ro_crates/routes/post_routes.py | 4 +- app/services/logging_service.py | 1 - app/services/validation_service.py | 6 +- app/storage/__init__.py | 4 +- app/storage/base.py | 4 +- app/storage/memory.py | 5 +- app/storage/s3.py | 9 +- app/tasks/validation_tasks.py | 11 +- app/utils/config.py | 4 +- app/utils/webhook_utils.py | 1 - app/validation/results.py | 5 +- app/validation/runner.py | 9 +- pyproject.toml | 48 +++++ pytest.ini | 3 - requirements-dev.txt | 265 ++++++++++++++++++++++++++++ requirements.in | 9 - requirements.txt | 20 +-- tests/test_api_routes.py | 40 ++--- tests/test_app_factory.py | 2 +- tests/test_config.py | 2 +- tests/test_crate_ids.py | 26 +-- tests/test_crate_layout.py | 2 +- tests/test_crate_resolver.py | 6 +- tests/test_health.py | 18 +- tests/test_integration.py | 16 +- tests/test_logging.py | 2 +- tests/test_services.py | 18 +- tests/test_storage.py | 2 +- tests/test_validation_runner.py | 2 - tests/test_validation_tasks.py | 9 +- tests/test_webhooks.py | 6 +- 37 files changed, 451 insertions(+), 155 deletions(-) create mode 100644 .github/workflows/lint.yml create mode 100644 pyproject.toml delete mode 100644 pytest.ini create mode 100644 requirements-dev.txt delete mode 100644 requirements.in diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..1e760d2 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,29 @@ +name: Lint + +on: + pull_request: + branches: [ develop ] + +jobs: + ruff: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install ruff + run: | + python -m pip install --upgrade pip + pip install ruff + + - name: Lint + run: ruff check . + + - name: Format check + run: ruff format --check . diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 8db5c6a..3d44ed4 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -20,8 +20,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt - pip install pytest pytest-mock "moto[s3]" + pip install -r requirements-dev.txt - name: Run tests (excluding integration tests) run: | diff --git a/app/__init__.py b/app/__init__.py index fd02815..8dcd59a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -3,23 +3,23 @@ import logging from apiflask import APIFlask +from flask import jsonify, request from app.crates.ids import InvalidCrateId -from app.crates.resolver import CrateNotFound, AmbiguousCrate +from app.crates.resolver import AmbiguousCrate, CrateNotFound from app.health import health_bp -from app.ro_crates.routes import v1_post_bp, v1_minio_post_bp, v1_minio_get_bp +from app.ro_crates.routes import v1_minio_get_bp, v1_minio_post_bp, v1_post_bp from app.services.logging_service import ( + get_request_id, new_request_id, set_request_id, - get_request_id, ) from app.storage.errors import StorageError from app.utils.config import ( - Settings, InvalidAPIUsage, + Settings, make_celery, ) -from flask import jsonify, request logger = logging.getLogger(__name__) diff --git a/app/celery_worker.py b/app/celery_worker.py index 778407c..90899eb 100644 --- a/app/celery_worker.py +++ b/app/celery_worker.py @@ -6,5 +6,4 @@ from celery import Celery - celery = Celery() diff --git a/app/crates/resolver.py b/app/crates/resolver.py index c6e22f5..9d3acab 100644 --- a/app/crates/resolver.py +++ b/app/crates/resolver.py @@ -10,7 +10,7 @@ from dataclasses import dataclass from app.crates.ids import validate_crate_id -from app.crates.layout import crate_zip_key, crate_dir_prefix, crate_metadata_key +from app.crates.layout import crate_dir_prefix, crate_metadata_key, crate_zip_key from app.storage.base import StorageBackend from app.storage.errors import ObjectNotFound diff --git a/app/ro_crates/routes/__init__.py b/app/ro_crates/routes/__init__.py index 75b4405..6002963 100644 --- a/app/ro_crates/routes/__init__.py +++ b/app/ro_crates/routes/__init__.py @@ -1,7 +1,7 @@ """Defines main Blueprint and registers sub-Blueprints for organising related routes.""" -from app.ro_crates.routes.post_routes import post_routes_bp, minio_post_routes_bp from app.ro_crates.routes.get_routes import get_routes_bp +from app.ro_crates.routes.post_routes import minio_post_routes_bp, post_routes_bp # Always registered: v1_post_bp = post_routes_bp diff --git a/app/ro_crates/routes/post_routes.py b/app/ro_crates/routes/post_routes.py index d7eef36..90ebdc4 100644 --- a/app/ro_crates/routes/post_routes.py +++ b/app/ro_crates/routes/post_routes.py @@ -79,6 +79,4 @@ def validate_ro_crate_metadata(json_data) -> tuple[Response, int]: profiles_path = current_app.config["PROFILES_PATH"] - return run_metadata_validation( - crate_json, profile_name, profiles_path=profiles_path - ) + return run_metadata_validation(crate_json, profile_name, profiles_path=profiles_path) diff --git a/app/services/logging_service.py b/app/services/logging_service.py index 91e776e..ccd8efe 100644 --- a/app/services/logging_service.py +++ b/app/services/logging_service.py @@ -3,7 +3,6 @@ import json import logging import uuid - from contextvars import ContextVar from typing import Iterable, Optional diff --git a/app/services/validation_service.py b/app/services/validation_service.py index d322518..532d2ee 100644 --- a/app/services/validation_service.py +++ b/app/services/validation_service.py @@ -3,7 +3,7 @@ import json import logging -from flask import jsonify, Response, current_app +from flask import Response, current_app, jsonify from app.crates.ids import validate_crate_id from app.crates.layout import result_key @@ -76,9 +76,7 @@ def run_metadata_validation( if not metadata: return jsonify({"error": "Required parameter crate_json is empty"}), 422 - outcome = validate_metadata( - metadata, profile_name=profile_name, profiles_path=profiles_path - ) + outcome = validate_metadata(metadata, profile_name=profile_name, profiles_path=profiles_path) status_code = 422 if outcome.status is ValidationStatus.ERROR else 200 return jsonify(outcome.to_dict()), status_code diff --git a/app/storage/__init__.py b/app/storage/__init__.py index d7b591f..9db1e79 100644 --- a/app/storage/__init__.py +++ b/app/storage/__init__.py @@ -5,7 +5,7 @@ are interchangeable. """ -from app.storage.base import StorageBackend, ObjectStat -from app.storage.errors import StorageError, ObjectNotFound +from app.storage.base import ObjectStat, StorageBackend +from app.storage.errors import ObjectNotFound, StorageError __all__ = ["StorageBackend", "ObjectStat", "StorageError", "ObjectNotFound"] diff --git a/app/storage/base.py b/app/storage/base.py index c06e84e..0e545fb 100644 --- a/app/storage/base.py +++ b/app/storage/base.py @@ -29,9 +29,7 @@ def get_bytes(self, key: str) -> bytes: """Return the object's bytes or raise ``ObjectNotFound``.""" ... - def put_bytes( - self, key: str, data: bytes, content_type: Optional[str] = None - ) -> None: + def put_bytes(self, key: str, data: bytes, content_type: Optional[str] = None) -> None: """Store ``data`` at ``key``, overwriting any existing object.""" ... diff --git a/app/storage/memory.py b/app/storage/memory.py index 8653b6b..bfc9ca6 100644 --- a/app/storage/memory.py +++ b/app/storage/memory.py @@ -1,7 +1,6 @@ """An in-memory storage backend for tests and local use.""" import os - from typing import Dict, List, Optional from app.storage.base import ObjectStat @@ -28,9 +27,7 @@ def get_bytes(self, key: str) -> bytes: except KeyError: raise ObjectNotFound(key) - def put_bytes( - self, key: str, data: bytes, content_type: Optional[str] = None - ) -> None: + def put_bytes(self, key: str, data: bytes, content_type: Optional[str] = None) -> None: self._objects[key] = data def list(self, prefix: str) -> List[str]: diff --git a/app/storage/s3.py b/app/storage/s3.py index 26e4c7e..586abf8 100644 --- a/app/storage/s3.py +++ b/app/storage/s3.py @@ -6,7 +6,6 @@ """ import os - from typing import List, Optional import boto3 @@ -63,9 +62,7 @@ def get_bytes(self, key: str) -> bytes: except BotoCoreError as error: raise StorageError(f"Storage error for {key}: {error}") from error - def put_bytes( - self, key: str, data: bytes, content_type: Optional[str] = None - ) -> None: + def put_bytes(self, key: str, data: bytes, content_type: Optional[str] = None) -> None: kwargs = {"Bucket": self.bucket, "Key": key, "Body": data} if content_type: kwargs["ContentType"] = content_type @@ -97,9 +94,7 @@ def health_check(self) -> None: try: self._client.head_bucket(Bucket=self.bucket) except (ClientError, BotoCoreError) as error: - raise StorageError( - f"Bucket {self.bucket} not reachable: {error}" - ) from error + raise StorageError(f"Bucket {self.bucket} not reachable: {error}") from error @staticmethod def _translate(error: ClientError, key: str) -> StorageError: diff --git a/app/tasks/validation_tasks.py b/app/tasks/validation_tasks.py index ed8a496..19ea3d5 100644 --- a/app/tasks/validation_tasks.py +++ b/app/tasks/validation_tasks.py @@ -14,7 +14,6 @@ import os import shutil import tempfile - from datetime import datetime, timezone from typing import Optional @@ -22,10 +21,10 @@ from app.crates.ids import InvalidCrateId from app.crates.layout import result_key from app.crates.resolver import ( - resolve_crate, - ResolvedCrate, - CrateNotFound, AmbiguousCrate, + CrateNotFound, + ResolvedCrate, + resolve_crate, ) from app.storage.base import StorageBackend from app.storage.errors import StorageError @@ -42,9 +41,7 @@ def _utcnow_iso() -> str: return datetime.now(timezone.utc).isoformat() -def _download_crate( - storage: StorageBackend, resolved: ResolvedCrate, temp_dir: str -) -> str: +def _download_crate(storage: StorageBackend, resolved: ResolvedCrate, temp_dir: str) -> str: """Download a resolved crate into ``temp_dir`` and return its local path.""" if resolved.is_zip: local_path = os.path.join(temp_dir, f"{resolved.crate_id}.zip") diff --git a/app/utils/config.py b/app/utils/config.py index 492e202..b52946b 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -1,7 +1,6 @@ """Configuration module for the Flask application.""" import os - from dataclasses import dataclass from typing import Mapping, Optional @@ -91,8 +90,7 @@ def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "Settings": s3_bucket=_clean(env.get("S3_BUCKET")), s3_use_ssl=_parse_bool(env.get("S3_USE_SSL")), s3_crate_prefix=_clean(env.get("S3_CRATE_PREFIX")) or "crates", - s3_results_prefix=_clean(env.get("S3_RESULTS_PREFIX")) - or "validation-results", + s3_results_prefix=_clean(env.get("S3_RESULTS_PREFIX")) or "validation-results", ) diff --git a/app/utils/webhook_utils.py b/app/utils/webhook_utils.py index 9d152e3..29f506f 100644 --- a/app/utils/webhook_utils.py +++ b/app/utils/webhook_utils.py @@ -2,7 +2,6 @@ import logging import time - from typing import Any, Callable import requests diff --git a/app/validation/results.py b/app/validation/results.py index 0de0d86..cd1418c 100644 --- a/app/validation/results.py +++ b/app/validation/results.py @@ -1,7 +1,6 @@ """Defines an explicit result type for validation.""" import json - from dataclasses import dataclass from enum import Enum from typing import Optional @@ -54,9 +53,7 @@ def from_validator_result( cls, result, profile: Optional[str] = None, created_at: Optional[str] = None ) -> "ValidationOutcome": """Build an outcome from a rocrate_validator ``ValidationResult``.""" - status = ( - ValidationStatus.INVALID if result.has_issues() else ValidationStatus.VALID - ) + status = ValidationStatus.INVALID if result.has_issues() else ValidationStatus.VALID return cls( status=status, profile=profile, diff --git a/app/validation/runner.py b/app/validation/runner.py index e619903..d7efac6 100644 --- a/app/validation/runner.py +++ b/app/validation/runner.py @@ -6,7 +6,6 @@ """ import logging - from typing import Optional from rocrate_validator import services @@ -68,13 +67,9 @@ def _run( try: settings = services.ValidationSettings(**options) result = services.validate(settings) - except ( - Exception - ) as error: # noqa: BLE001 - adapt any validator failure to an outcome + except Exception as error: # noqa: BLE001 - adapt any validator failure to an outcome logger.error("Validation failed: %s", error) - return ValidationOutcome.from_error( - str(error), profile=profile_name, created_at=created_at - ) + return ValidationOutcome.from_error(str(error), profile=profile_name, created_at=created_at) return ValidationOutcome.from_validator_result( result, profile=profile_name, created_at=created_at diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..60c63ea --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,48 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "ro-crate-validation-service" +version = "0.1.0" +description = "A service for validating RO-Crates." +readme = "README.md" +requires-python = ">=3.11" +license = { text = "MIT" } +authors = [{ name = "eScience Lab, The University of Manchester" }] + +# Direct runtime dependencies: +dependencies = [ + "celery==5.6.3", + "boto3==1.43.29", + "requests==2.33.1", + "Flask==3.1.3", + "Werkzeug==3.1.8", + "redis==7.4.0", + "python-dotenv==1.2.2", + "apiflask==3.1.0", + "roc-validator==0.9.0", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-mock", + "moto[s3]", + "ruff", + "pip-tools", +] + +[tool.setuptools.packages.find] +include = ["app*"] + +[tool.pytest.ini_options] +log_format = "%(asctime)s %(levelname)s %(message)s" +log_date_format = "%Y-%m-%d %H:%M:%S" + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "W", "I"] diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 96735eb..0000000 --- a/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -log_format = %(asctime)s %(levelname)s %(message)s -log_date_format = %Y-%m-%d %H:%M:%S diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..48a638d --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,265 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --extra=dev --output-file=requirements-dev.txt pyproject.toml +# +amqp==5.3.1 + # via kombu +annotated-types==0.7.0 + # via pydantic +apiflask==3.1.0 + # via ro-crate-validation-service (pyproject.toml) +apispec==6.10.0 + # via apiflask +async-timeout==5.0.1 + # via redis +attrs==26.1.0 + # via + # cattrs + # requests-cache +billiard==4.2.4 + # via celery +blinker==1.9.0 + # via flask +boto3==1.43.29 + # via + # moto + # ro-crate-validation-service (pyproject.toml) +botocore==1.43.31 + # via + # boto3 + # moto + # s3transfer +build==1.5.0 + # via pip-tools +cattrs==26.1.0 + # via requests-cache +celery==5.6.3 + # via ro-crate-validation-service (pyproject.toml) +certifi==2026.6.17 + # via requests +cffi==2.0.0 + # via cryptography +charset-normalizer==3.4.7 + # via requests +click==8.4.1 + # via + # celery + # click-didyoumean + # click-plugins + # click-repl + # flask + # pip-tools + # rich-click + # roc-validator +click-didyoumean==0.3.1 + # via celery +click-plugins==1.1.1.2 + # via celery +click-repl==0.3.0 + # via celery +colorlog==6.10.1 + # via roc-validator +cryptography==49.0.0 + # via moto +dnspython==2.8.0 + # via email-validator +email-validator==2.3.0 + # via pydantic +enum-tools==0.12.0 + # via roc-validator +flask==3.1.3 + # via + # apiflask + # flask-httpauth + # flask-marshmallow + # ro-crate-validation-service (pyproject.toml) +flask-httpauth==4.8.1 + # via apiflask +flask-marshmallow==1.5.0 + # via apiflask +html5rdf==1.2.1 + # via rdflib +idna==3.18 + # via + # email-validator + # requests + # url-normalize +importlib-metadata==9.0.0 + # via pyshacl +iniconfig==2.3.0 + # via pytest +inquirerpy==0.3.4 + # via roc-validator +itsdangerous==2.2.0 + # via flask +jinja2==3.1.6 + # via flask +jmespath==1.1.0 + # via + # boto3 + # botocore +kombu==5.6.2 + # via celery +markdown-it-py==4.2.0 + # via rich +markupsafe==3.0.3 + # via + # flask + # jinja2 + # werkzeug +marshmallow==4.3.0 + # via + # apiflask + # flask-marshmallow + # webargs +mdurl==0.1.2 + # via markdown-it-py +moto[s3]==5.2.2 + # via ro-crate-validation-service (pyproject.toml) +owlrl==7.1.4 + # via pyshacl +packaging==26.2 + # via + # apispec + # build + # kombu + # pyshacl + # pytest + # webargs + # wheel +pfzy==0.3.4 + # via inquirerpy +pip-tools==7.5.3 + # via ro-crate-validation-service (pyproject.toml) +platformdirs==4.10.0 + # via requests-cache +pluggy==1.6.0 + # via pytest +prettytable==3.17.0 + # via pyshacl +prompt-toolkit==3.0.52 + # via + # click-repl + # inquirerpy +py-partiql-parser==0.6.3 + # via moto +pycparser==3.0 + # via cffi +pydantic[email]==2.13.4 + # via apiflask +pydantic-core==2.46.4 + # via pydantic +pygments==2.20.0 + # via + # enum-tools + # pytest + # rich +pyparsing==3.3.2 + # via rdflib +pyproject-hooks==1.2.0 + # via + # build + # pip-tools +pyshacl==0.31.0 + # via roc-validator +pytest==9.1.0 + # via + # pytest-mock + # ro-crate-validation-service (pyproject.toml) +pytest-mock==3.15.1 + # via ro-crate-validation-service (pyproject.toml) +python-dateutil==2.9.0.post0 + # via + # botocore + # celery +python-dotenv==1.2.2 + # via ro-crate-validation-service (pyproject.toml) +pyyaml==6.0.3 + # via + # moto + # responses +rdflib[html]==7.6.0 + # via + # owlrl + # pyshacl + # roc-validator +redis==7.4.0 + # via ro-crate-validation-service (pyproject.toml) +requests==2.33.1 + # via + # moto + # requests-cache + # responses + # ro-crate-validation-service (pyproject.toml) + # roc-validator +requests-cache==1.3.2 + # via roc-validator +responses==0.26.1 + # via moto +rich==13.9.4 + # via + # rich-click + # roc-validator +rich-click==1.9.8 + # via roc-validator +roc-validator==0.9.0 + # via ro-crate-validation-service (pyproject.toml) +ruff==0.15.17 + # via ro-crate-validation-service (pyproject.toml) +s3transfer==0.18.0 + # via boto3 +six==1.17.0 + # via python-dateutil +toml==0.10.2 + # via roc-validator +typing-extensions==4.15.0 + # via + # cattrs + # enum-tools + # pydantic + # pydantic-core + # typing-inspection +typing-inspection==0.4.2 + # via pydantic +typos==1.47.2 + # via roc-validator +tzdata==2026.2 + # via kombu +tzlocal==5.4.3 + # via celery +url-normalize==3.0.0 + # via requests-cache +urllib3==2.7.0 + # via + # botocore + # requests + # requests-cache + # responses +vine==5.1.0 + # via + # amqp + # celery + # kombu +wcwidth==0.8.1 + # via + # prettytable + # prompt-toolkit +webargs==8.7.1 + # via apiflask +werkzeug==3.1.8 + # via + # flask + # moto + # ro-crate-validation-service (pyproject.toml) +wheel==0.47.0 + # via pip-tools +xmltodict==1.0.4 + # via moto +zipp==4.1.0 + # via importlib-metadata + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/requirements.in b/requirements.in deleted file mode 100644 index c419c34..0000000 --- a/requirements.in +++ /dev/null @@ -1,9 +0,0 @@ -celery==5.6.3 -boto3==1.43.29 -requests==2.33.1 -Flask==3.1.3 -Werkzeug==3.1.8 -redis==7.4.0 -python-dotenv==1.2.2 -apiflask==3.1.0 -roc-validator==0.9.0 diff --git a/requirements.txt b/requirements.txt index 4c79bff..b392f3d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,14 +2,14 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --output-file=requirements.txt requirements.in +# pip-compile --output-file=requirements.txt pyproject.toml # amqp==5.3.1 # via kombu annotated-types==0.7.0 # via pydantic apiflask==3.1.0 - # via -r requirements.in + # via ro-crate-validation-service (pyproject.toml) apispec==6.8.2 # via apiflask async-timeout==5.0.1 @@ -23,7 +23,7 @@ billiard==4.2.1 blinker==1.9.0 # via flask boto3==1.43.29 - # via -r requirements.in + # via ro-crate-validation-service (pyproject.toml) botocore==1.43.29 # via # boto3 @@ -31,7 +31,7 @@ botocore==1.43.29 cattrs==25.1.1 # via requests-cache celery==5.6.3 - # via -r requirements.in + # via ro-crate-validation-service (pyproject.toml) certifi==2025.8.3 # via requests charset-normalizer==3.4.2 @@ -61,10 +61,10 @@ enum-tools==0.12.0 # via roc-validator flask==3.1.3 # via - # -r requirements.in # apiflask # flask-httpauth # flask-marshmallow + # ro-crate-validation-service (pyproject.toml) flask-httpauth==4.8.1 # via apiflask flask-marshmallow==1.3.0 @@ -139,18 +139,18 @@ python-dateutil==2.9.0.post0 # botocore # celery python-dotenv==1.2.2 - # via -r requirements.in + # via ro-crate-validation-service (pyproject.toml) rdflib[html]==7.1.4 # via # owlrl # pyshacl # roc-validator redis==7.4.0 - # via -r requirements.in + # via ro-crate-validation-service (pyproject.toml) requests==2.33.1 # via - # -r requirements.in # requests-cache + # ro-crate-validation-service (pyproject.toml) # roc-validator requests-cache==1.2.1 # via roc-validator @@ -161,7 +161,7 @@ rich==13.9.4 rich-click==1.8.9 # via roc-validator roc-validator==0.9.0 - # via -r requirements.in + # via ro-crate-validation-service (pyproject.toml) s3transfer==0.18.0 # via boto3 six==1.17.0 @@ -204,7 +204,7 @@ webargs==8.7.0 # via apiflask werkzeug==3.1.8 # via - # -r requirements.in # flask + # ro-crate-validation-service (pyproject.toml) zipp==3.23.0 # via importlib-metadata diff --git a/tests/test_api_routes.py b/tests/test_api_routes.py index ce02d06..5fa391a 100644 --- a/tests/test_api_routes.py +++ b/tests/test_api_routes.py @@ -1,6 +1,8 @@ -from flask.testing import FlaskClient -import pytest from unittest.mock import patch + +import pytest +from flask.testing import FlaskClient + from app import create_app from app.utils.config import Settings @@ -38,8 +40,10 @@ def storage_client(): @pytest.mark.parametrize( "payload, expected_args", [ - ({"profile_name": "ro-crate", "webhook_url": "https://hook"}, - ("crate-123", "ro-crate", "https://hook")), + ( + {"profile_name": "ro-crate", "webhook_url": "https://hook"}, + ("crate-123", "ro-crate", "https://hook"), + ), ({"profile_name": "ro-crate"}, ("crate-123", "ro-crate", None)), ({"webhook_url": "https://hook"}, ("crate-123", None, "https://hook")), ({}, ("crate-123", None, None)), @@ -47,9 +51,7 @@ def storage_client(): ids=["all_fields", "no_webhook", "no_profile", "empty_body"], ) def test_validate_by_id_queues_and_returns_202(storage_client, payload, expected_args): - with patch( - "app.ro_crates.routes.post_routes.queue_ro_crate_validation_task" - ) as mock_queue: + with patch("app.ro_crates.routes.post_routes.queue_ro_crate_validation_task") as mock_queue: mock_queue.return_value = ({"message": "Validation in progress"}, 202) response = storage_client.post("/v1/ro_crates/crate-123/validation", json=payload) @@ -61,9 +63,7 @@ def test_validate_by_id_queues_and_returns_202(storage_client, payload, expected def test_validate_by_id_no_longer_accepts_credentials(storage_client): """The request body carries no storage credentials; only optional fields.""" - with patch( - "app.ro_crates.routes.post_routes.queue_ro_crate_validation_task" - ) as mock_queue: + with patch("app.ro_crates.routes.post_routes.queue_ro_crate_validation_task") as mock_queue: mock_queue.return_value = ({"message": "Validation in progress"}, 202) response = storage_client.post( @@ -103,18 +103,14 @@ def test_validate_by_id_no_longer_accepts_credentials(storage_client): def test_validate_metadata_success( client: FlaskClient, payload, status_code, response_json, profiles_path ): - with patch( - "app.ro_crates.routes.post_routes.run_metadata_validation" - ) as mock_run: + with patch("app.ro_crates.routes.post_routes.run_metadata_validation") as mock_run: mock_run.return_value = (response_json, status_code) response = client.post("/v1/ro_crates/validate_metadata", json=payload) crate_json = payload.get("crate_json") profile_name = payload.get("profile_name") - mock_run.assert_called_once_with( - crate_json, profile_name, profiles_path=profiles_path - ) + mock_run.assert_called_once_with(crate_json, profile_name, profiles_path=profiles_path) assert response.status_code == status_code assert response.json == response_json @@ -129,9 +125,7 @@ def test_validate_metadata_success( ], ids=["missing_crate", "blank_crate", "malformed_crate", "empty_crate"], ) -def test_validate_metadata_failure( - client: FlaskClient, payload, status_code, response_text -): +def test_validate_metadata_failure(client: FlaskClient, payload, status_code, response_text): response = client.post("/v1/ro_crates/validate_metadata", json=payload) assert response.status_code == status_code assert response_text in response.get_data(as_text=True) @@ -141,9 +135,7 @@ def test_validate_metadata_failure( def test_get_validation_by_id_returns_result(storage_client): - with patch( - "app.ro_crates.routes.get_routes.get_ro_crate_validation_task" - ) as mock_get: + with patch("app.ro_crates.routes.get_routes.get_ro_crate_validation_task") as mock_get: mock_get.return_value = ({"status": "valid"}, 200) response = storage_client.get("/v1/ro_crates/crate-123/validation") @@ -168,9 +160,7 @@ def test_get_route_not_registered_when_storage_disabled(client: FlaskClient): def test_metadata_route_available_when_storage_disabled(client: FlaskClient): payload = {"crate_json": '{"@context": "https://w3id.org/ro/crate/1.1/context"}'} - with patch( - "app.ro_crates.routes.post_routes.run_metadata_validation" - ) as mock_run: + with patch("app.ro_crates.routes.post_routes.run_metadata_validation") as mock_run: mock_run.return_value = ({"status": "valid"}, 200) response = client.post("/v1/ro_crates/validate_metadata", json=payload) diff --git a/tests/test_app_factory.py b/tests/test_app_factory.py index 4c34f56..defcfb3 100644 --- a/tests/test_app_factory.py +++ b/tests/test_app_factory.py @@ -3,7 +3,7 @@ import pytest from app import create_app -from app.utils.config import Settings, ConfigError +from app.utils.config import ConfigError, Settings def _storage_env() -> dict: diff --git a/tests/test_config.py b/tests/test_config.py index ebe9226..b1ea143 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,7 +2,7 @@ import pytest -from app.utils.config import Settings, ConfigError +from app.utils.config import ConfigError, Settings def test_defaults_when_storage_disabled(): diff --git a/tests/test_crate_ids.py b/tests/test_crate_ids.py index f91f247..ed18a4e 100644 --- a/tests/test_crate_ids.py +++ b/tests/test_crate_ids.py @@ -2,7 +2,7 @@ import pytest -from app.crates.ids import validate_crate_id, is_valid_crate_id, InvalidCrateId +from app.crates.ids import InvalidCrateId, is_valid_crate_id, validate_crate_id @pytest.mark.parametrize( @@ -12,8 +12,8 @@ "crate-123", "my_crate.v2", "ABC.def-123_456", - "release.zip", # ".zip" in the ID is harmless now: IDs are opaque - "x" * 128, # max length + "release.zip", # ".zip" in the ID is harmless now: IDs are opaque + "x" * 128, # max length ], ) def test_valid_ids_are_accepted(crate_id): @@ -24,16 +24,16 @@ def test_valid_ids_are_accepted(crate_id): @pytest.mark.parametrize( "crate_id", [ - "", # empty - ".hidden", # leading dot - "-leading-dash", # must start alphanumeric - "a/b", # path separator - "../etc/passwd", # traversal - "a..b", # parent-dir sequence - "with space", # whitespace - "tab\tchar", # control char - "x" * 129, # too long - "unicodé", # non-ASCII + "", # empty + ".hidden", # leading dot + "-leading-dash", # must start alphanumeric + "a/b", # path separator + "../etc/passwd", # traversal + "a..b", # parent-dir sequence + "with space", # whitespace + "tab\tchar", # control char + "x" * 129, # too long + "unicodé", # non-ASCII ], ) def test_invalid_ids_are_rejected(crate_id): diff --git a/tests/test_crate_layout.py b/tests/test_crate_layout.py index 3c174da..7f0a199 100644 --- a/tests/test_crate_layout.py +++ b/tests/test_crate_layout.py @@ -3,9 +3,9 @@ import pytest from app.crates.layout import ( - crate_zip_key, crate_dir_prefix, crate_metadata_key, + crate_zip_key, result_key, ) diff --git a/tests/test_crate_resolver.py b/tests/test_crate_resolver.py index ceffa7c..5d9a41b 100644 --- a/tests/test_crate_resolver.py +++ b/tests/test_crate_resolver.py @@ -4,10 +4,10 @@ from app.crates.ids import InvalidCrateId from app.crates.resolver import ( - resolve_crate, - ResolvedCrate, - CrateNotFound, AmbiguousCrate, + CrateNotFound, + ResolvedCrate, + resolve_crate, ) from app.storage.memory import InMemoryStorage diff --git a/tests/test_health.py b/tests/test_health.py index 84c1bb3..064865d 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -44,8 +44,10 @@ def test_readyz_ready_when_storage_disabled(disabled_client): def test_readyz_ok_when_all_checks_pass(storage_client): - with mock.patch("app.health.check_storage", return_value=(True, "ok")), \ - mock.patch("app.health.check_broker", return_value=(True, "ok")): + with ( + mock.patch("app.health.check_storage", return_value=(True, "ok")), + mock.patch("app.health.check_broker", return_value=(True, "ok")), + ): response = storage_client.get("/readyz") assert response.status_code == 200 @@ -53,8 +55,10 @@ def test_readyz_ok_when_all_checks_pass(storage_client): def test_readyz_503_when_storage_unreachable(storage_client): - with mock.patch("app.health.check_storage", return_value=(False, "bucket down")), \ - mock.patch("app.health.check_broker", return_value=(True, "ok")): + with ( + mock.patch("app.health.check_storage", return_value=(False, "bucket down")), + mock.patch("app.health.check_broker", return_value=(True, "ok")), + ): response = storage_client.get("/readyz") assert response.status_code == 503 @@ -63,8 +67,10 @@ def test_readyz_503_when_storage_unreachable(storage_client): def test_readyz_503_when_broker_unreachable(storage_client): - with mock.patch("app.health.check_storage", return_value=(True, "ok")), \ - mock.patch("app.health.check_broker", return_value=(False, "broker down")): + with ( + mock.patch("app.health.check_storage", return_value=(True, "ok")), + mock.patch("app.health.check_broker", return_value=(False, "broker down")), + ): response = storage_client.get("/readyz") assert response.status_code == 503 diff --git a/tests/test_integration.py b/tests/test_integration.py index 0c3b5fe..629f7b6 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,12 +1,13 @@ -import pytest -import subprocess -import time -import requests import json import os +import subprocess +import time +import uuid + import docker +import pytest +import requests from minio import Minio -import uuid @pytest.fixture(scope="session") @@ -234,10 +235,7 @@ def test_rocrate_not_validated_yet(): # Assertions assert response.status_code == 400 - assert ( - response_result["message"] - == f"No validation result yet for RO-Crate: {ro_crate}" - ) + assert response_result["message"] == f"No validation result yet for RO-Crate: {ro_crate}" def test_zipped_rocrate_validation(): diff --git a/tests/test_logging.py b/tests/test_logging.py index 25add24..7a0667a 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -7,9 +7,9 @@ JsonFormatter, RedactionFilter, RequestIdFilter, - set_request_id, get_request_id, new_request_id, + set_request_id, ) diff --git a/tests/test_services.py b/tests/test_services.py index 2907073..02216bd 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1,17 +1,18 @@ """Tests for the validation service layer.""" -import pytest from unittest.mock import patch + +import pytest from flask import Flask from app import create_app from app.crates.ids import InvalidCrateId from app.crates.layout import result_key -from app.crates.resolver import CrateNotFound, AmbiguousCrate +from app.crates.resolver import AmbiguousCrate, CrateNotFound from app.services.validation_service import ( + get_ro_crate_validation_task, queue_ro_crate_validation_task, run_metadata_validation, - get_ro_crate_validation_task, ) from app.storage.memory import InMemoryStorage from app.utils.config import InvalidAPIUsage, Settings @@ -48,6 +49,7 @@ def app_ctx(): # --- queue_ro_crate_validation_task -------------------------------------- + @patch("app.services.validation_service.process_validation_task_by_id.delay") @patch("app.services.validation_service.resolve_crate") @patch("app.services.validation_service._build_storage") @@ -63,7 +65,9 @@ def test_queue_resolves_then_delays(mock_storage, mock_resolve, mock_delay, app_ @patch("app.services.validation_service.process_validation_task_by_id.delay") @patch("app.services.validation_service.resolve_crate", side_effect=CrateNotFound("nope")) @patch("app.services.validation_service._build_storage") -def test_queue_not_found_propagates_without_queueing(mock_storage, mock_resolve, mock_delay, app_ctx): +def test_queue_not_found_propagates_without_queueing( + mock_storage, mock_resolve, mock_delay, app_ctx +): with pytest.raises(CrateNotFound): queue_ro_crate_validation_task("missing") mock_delay.assert_not_called() @@ -72,7 +76,9 @@ def test_queue_not_found_propagates_without_queueing(mock_storage, mock_resolve, @patch("app.services.validation_service.process_validation_task_by_id.delay") @patch("app.services.validation_service.resolve_crate", side_effect=AmbiguousCrate("both")) @patch("app.services.validation_service._build_storage") -def test_queue_ambiguous_propagates_without_queueing(mock_storage, mock_resolve, mock_delay, app_ctx): +def test_queue_ambiguous_propagates_without_queueing( + mock_storage, mock_resolve, mock_delay, app_ctx +): with pytest.raises(AmbiguousCrate): queue_ro_crate_validation_task("dup") mock_delay.assert_not_called() @@ -80,6 +86,7 @@ def test_queue_ambiguous_propagates_without_queueing(mock_storage, mock_resolve, # --- run_metadata_validation (synchronous) ------------------------------- + @patch("app.services.validation_service.validate_metadata") def test_run_metadata_validation_valid_is_200(mock_validate, flask_app): mock_validate.return_value = ValidationOutcome( @@ -132,6 +139,7 @@ def test_run_metadata_validation_json_errors(flask_app, crate_json, response_err # --- get_ro_crate_validation_task ---------------------------------------- + @patch("app.services.validation_service._build_storage") def test_get_returns_stored_result(mock_storage, app_ctx): storage = InMemoryStorage() diff --git a/tests/test_storage.py b/tests/test_storage.py index 99244f9..a2af99d 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -2,7 +2,7 @@ import pytest -from app.storage.base import StorageBackend, ObjectStat +from app.storage.base import ObjectStat, StorageBackend from app.storage.errors import ObjectNotFound from app.storage.memory import InMemoryStorage diff --git a/tests/test_validation_runner.py b/tests/test_validation_runner.py index bd68c68..8bf2dde 100644 --- a/tests/test_validation_runner.py +++ b/tests/test_validation_runner.py @@ -2,8 +2,6 @@ import json -import pytest - from app.validation import runner from app.validation.results import ValidationStatus diff --git a/tests/test_validation_tasks.py b/tests/test_validation_tasks.py index 01b26a5..e0ad932 100644 --- a/tests/test_validation_tasks.py +++ b/tests/test_validation_tasks.py @@ -8,7 +8,6 @@ from app.crates.layout import result_key from app.storage.errors import StorageError from app.storage.memory import InMemoryStorage -from app.tasks import validation_tasks from app.tasks.validation_tasks import run_validation_job from app.utils.config import Settings from app.utils.webhook_utils import WebhookDeliveryError @@ -85,9 +84,7 @@ def test_webhook_is_sent_with_outcome_when_url_given(storage): with mock.patch(RUNNER) as run, mock.patch(WEBHOOK) as hook: run.return_value = ValidationOutcome(status=ValidationStatus.VALID, created_at="t") - run_validation_job( - storage, "foo", _settings(), webhook_url="https://hook", created_at="t" - ) + run_validation_job(storage, "foo", _settings(), webhook_url="https://hook", created_at="t") hook.assert_called_once() url, payload = hook.call_args.args @@ -128,7 +125,9 @@ def test_webhook_failure_surfaces_but_result_is_already_persisted(storage): def test_created_at_is_persisted(storage): storage.put_bytes("crates/foo.zip", b"PK") with mock.patch(RUNNER) as run, mock.patch(WEBHOOK): - run.return_value = ValidationOutcome(status=ValidationStatus.VALID, created_at="2026-06-16T00:00:00Z") + run.return_value = ValidationOutcome( + status=ValidationStatus.VALID, created_at="2026-06-16T00:00:00Z" + ) run_validation_job(storage, "foo", _settings(), created_at="2026-06-16T00:00:00Z") assert _stored_outcome(storage, "foo")["created_at"] == "2026-06-16T00:00:00Z" diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 018782f..3e8ecfa 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -6,7 +6,7 @@ import requests from app.utils import webhook_utils -from app.utils.webhook_utils import send_webhook_notification, WebhookDeliveryError +from app.utils.webhook_utils import WebhookDeliveryError, send_webhook_notification def _ok_response(): @@ -28,9 +28,7 @@ def test_retries_then_succeeds(): flaky = [requests.ConnectionError("boom"), requests.ConnectionError("boom"), _ok_response()] sleeps = [] with mock.patch.object(webhook_utils.requests, "post", side_effect=flaky) as post: - send_webhook_notification( - "https://hook", {"x": 1}, max_attempts=3, sleep=sleeps.append - ) + send_webhook_notification("https://hook", {"x": 1}, max_attempts=3, sleep=sleeps.append) assert post.call_count == 3 assert len(sleeps) == 2 # slept between the three attempts From 882fe57461a5ac036ace3c3d0536657c64c92d47 Mon Sep 17 00:00:00 2001 From: Alex Hambley <33315205+alexhambley@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:14:38 +0100 Subject: [PATCH 12/16] chore(dev): use RustFS as the local S3-compatible dev store - Replace the MinIO dev container with a vendor-neutral "objectstore" service running rustfs/rustfs. - Update .env/example.env to S3_* app config plus RUSTFS_* container credentials; the app/worker stay vendor-neutral (S3_* only). - Start with `docker compose --profile objectstore up`. --- docker-compose-develop.yml | 47 +++++++++++++++++++++++--------------- docker-compose.yml | 46 ++++++++++++++++++++++--------------- example.env | 28 ++++++++++++++++------- 3 files changed, 77 insertions(+), 44 deletions(-) diff --git a/docker-compose-develop.yml b/docker-compose-develop.yml index 334b0d6..ed99040 100644 --- a/docker-compose-develop.yml +++ b/docker-compose-develop.yml @@ -12,13 +12,14 @@ services: - FLASK_ENV=development - CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/0 - # Optional object storage. Set MINIO_ENABLED=true and start the "minio" - # profile (docker compose --profile minio up) to use - - MINIO_ENABLED=${MINIO_ENABLED:-false} - - MINIO_ENDPOINT=${MINIO_ENDPOINT} - - MINIO_ROOT_USER=${MINIO_ROOT_USER} - - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD} - - MINIO_BUCKET_NAME=${MINIO_BUCKET_NAME} + # Optional object storage. Set STORAGE_ENABLED=true and start the + # "objectstore" profile (docker compose --profile objectstore up). + - STORAGE_ENABLED=${STORAGE_ENABLED:-false} + - S3_ENDPOINT=${S3_ENDPOINT} + - S3_ACCESS_KEY=${S3_ACCESS_KEY} + - S3_SECRET_KEY=${S3_SECRET_KEY} + - S3_BUCKET=${S3_BUCKET} + - S3_USE_SSL=${S3_USE_SSL:-false} - PROFILES_PATH=/app/profiles depends_on: - redis @@ -31,7 +32,15 @@ services: environment: - CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/0 - - MINIO_ENABLED=${MINIO_ENABLED:-false} + # The worker builds its storage client from these, so it needs the full + # S3 config (not just the enabled flag). + - STORAGE_ENABLED=${STORAGE_ENABLED:-false} + - S3_ENDPOINT=${S3_ENDPOINT} + - S3_ACCESS_KEY=${S3_ACCESS_KEY} + - S3_SECRET_KEY=${S3_SECRET_KEY} + - S3_BUCKET=${S3_BUCKET} + - S3_USE_SSL=${S3_USE_SSL:-false} + - PROFILES_PATH=/app/profiles depends_on: - redis volumes: @@ -42,21 +51,23 @@ services: ports: - "6379:6379" - minio: - image: "minio/minio" - # Started with `docker compose --profile minio up`. + objectstore: + image: "rustfs/rustfs:latest" + # Local S3-compatible object store (RustFS) for development. + # Started with `docker compose --profile objectstore up`. profiles: - - minio + - objectstore ports: - "9000:9000" - "9001:9001" environment: - - MINIO_ROOT_USER=${MINIO_ROOT_USER} - - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD} - - MINIO_BROWSER_REDIRECT_PORT=9001 - command: server --console-address ":9001" /data + - RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY} + - RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY} + - RUSTFS_VOLUMES=/data + - RUSTFS_ADDRESS=0.0.0.0:9000 + - RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001 volumes: - - minio_data:/data + - objectstore_data:/data volumes: - minio_data: + objectstore_data: diff --git a/docker-compose.yml b/docker-compose.yml index 2f0bb3b..d74cc3d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,13 +11,14 @@ services: - FLASK_ENV=development - CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/0 - # Optional object storage. Set MINIO_ENABLED=true and start the "minio" - # profile (docker compose --profile minio up) to use - - MINIO_ENABLED=${MINIO_ENABLED:-false} - - MINIO_ENDPOINT=${MINIO_ENDPOINT} - - MINIO_ROOT_USER=${MINIO_ROOT_USER} - - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD} - - MINIO_BUCKET_NAME=${MINIO_BUCKET_NAME} + # Optional object storage. Set STORAGE_ENABLED=true and start the + # "objectstore" profile (docker compose --profile objectstore up). + - STORAGE_ENABLED=${STORAGE_ENABLED:-false} + - S3_ENDPOINT=${S3_ENDPOINT} + - S3_ACCESS_KEY=${S3_ACCESS_KEY} + - S3_SECRET_KEY=${S3_SECRET_KEY} + - S3_BUCKET=${S3_BUCKET} + - S3_USE_SSL=${S3_USE_SSL:-false} depends_on: - redis @@ -28,7 +29,14 @@ services: environment: - CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/0 - - MINIO_ENABLED=${MINIO_ENABLED:-false} + # The worker builds its storage client from these, so it needs the full + # S3 config (not just the enabled flag). + - STORAGE_ENABLED=${STORAGE_ENABLED:-false} + - S3_ENDPOINT=${S3_ENDPOINT} + - S3_ACCESS_KEY=${S3_ACCESS_KEY} + - S3_SECRET_KEY=${S3_SECRET_KEY} + - S3_BUCKET=${S3_BUCKET} + - S3_USE_SSL=${S3_USE_SSL:-false} depends_on: - redis @@ -37,21 +45,23 @@ services: ports: - "6379:6379" - minio: - image: "minio/minio" - # Started with `docker compose --profile minio up`. + objectstore: + image: "rustfs/rustfs:latest" + # Local S3-compatible object store (RustFS) for development. + # Started with `docker compose --profile objectstore up`. profiles: - - minio + - objectstore ports: - "9000:9000" - "9001:9001" environment: - - MINIO_ROOT_USER=${MINIO_ROOT_USER} - - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD} - - MINIO_BROWSER_REDIRECT_PORT=9001 - command: server --console-address ":9001" /data + - RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY} + - RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY} + - RUSTFS_VOLUMES=/data + - RUSTFS_ADDRESS=0.0.0.0:9000 + - RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001 volumes: - - minio_data:/data + - objectstore_data:/data volumes: - minio_data: + objectstore_data: diff --git a/example.env b/example.env index 5235486..8544ea4 100644 --- a/example.env +++ b/example.env @@ -1,9 +1,21 @@ -# MinIO is off by default; only the stateless validation endpoint is exposed when -# disabled. The MINIO_* vars below and the "minio" docker-compose profile are -#only needed when this is true. -MINIO_ENABLED=false +# Object storage is disabled by default; only the stateless metadata validation +# endpoint is exposed. Set STORAGE_ENABLED=true (and start the "objectstore" +# compose profile) to enable the store-backed, ID-based endpoints. +STORAGE_ENABLED=false -MINIO_ROOT_USER=minioadmin -MINIO_ROOT_PASSWORD=minioadmin -MINIO_BUCKET_NAME=ro-crates -MINIO_ENDPOINT=minio:9000 +# Application object-storage client. Works against any S3-compatible store +# (RustFS, MinIO, Ceph, AWS S3) via the endpoint below. For local development +# this points at the "objectstore" container (RustFS). +S3_ENDPOINT=objectstore:9000 +S3_ACCESS_KEY=rustfsadmin +S3_SECRET_KEY=rustfsadmin +S3_BUCKET=ro-crates +S3_USE_SSL=false +# S3_REGION=us-east-1 +# S3_CRATE_PREFIX=crates +# S3_RESULTS_PREFIX=validation-results + +# Credentials for the local RustFS dev container (the "objectstore" compose +# profile, development only). These match the S3 credentials above. +RUSTFS_ACCESS_KEY=rustfsadmin +RUSTFS_SECRET_KEY=rustfsadmin From 4526f568291161432d8753becb9e2e4515eeb417 Mon Sep 17 00:00:00 2001 From: Alex Hambley <33315205+alexhambley@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:59:38 +0100 Subject: [PATCH 13/16] test: mirror app/ layout and rebuild the integration test suite - Move unit tests into app-mirroring sub-packages (storage/, crates/, etc.) and switch pytest to importlib import mode with pythonpath=".". - Rewrite the integration test for the new stack - Update the integration workflow deps Closes #184 --- .github/workflows/test_docker.yml | 15 +- app/crates/resolver.py | 4 +- docker-compose-develop.yml | 4 + pyproject.toml | 2 + .../{test_crate_ids.py => crates/test_ids.py} | 0 .../test_layout.py} | 0 .../test_resolver.py} | 0 .../test_routes.py} | 0 .../test_logging_service.py} | 0 .../test_validation_service.py} | 0 .../test_memory.py} | 0 .../test_s3.py} | 0 tests/{ => tasks}/test_validation_tasks.py | 0 tests/test_integration.py | 678 ++++-------------- tests/{ => utils}/test_config.py | 0 .../test_webhook_utils.py} | 0 .../test_results.py} | 0 .../test_runner.py} | 0 18 files changed, 155 insertions(+), 548 deletions(-) rename tests/{test_crate_ids.py => crates/test_ids.py} (100%) rename tests/{test_crate_layout.py => crates/test_layout.py} (100%) rename tests/{test_crate_resolver.py => crates/test_resolver.py} (100%) rename tests/{test_api_routes.py => ro_crates/test_routes.py} (100%) rename tests/{test_logging.py => services/test_logging_service.py} (100%) rename tests/{test_services.py => services/test_validation_service.py} (100%) rename tests/{test_storage.py => storage/test_memory.py} (100%) rename tests/{test_storage_s3.py => storage/test_s3.py} (100%) rename tests/{ => tasks}/test_validation_tasks.py (100%) rename tests/{ => utils}/test_config.py (100%) rename tests/{test_webhooks.py => utils/test_webhook_utils.py} (100%) rename tests/{test_validation_outcome.py => validation/test_results.py} (100%) rename tests/{test_validation_runner.py => validation/test_runner.py} (100%) diff --git a/.github/workflows/test_docker.yml b/.github/workflows/test_docker.yml index 9af7b23..b0c8c1e 100644 --- a/.github/workflows/test_docker.yml +++ b/.github/workflows/test_docker.yml @@ -20,16 +20,15 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pytest requests minio docker + pip install pytest requests boto3 - - name: Build Docker Compose Containers + - name: Run integration tests (brings up the compose stack) run: | cp example.env .env - docker compose -f docker-compose-develop.yml build + pytest -s -v tests/test_integration.py - - name: Spin Up Docker Compose and Run Tests - run: pytest -s -v tests/test_integration.py - - - name: Ensure that Docker Compose is Shutdown + - name: Ensure Docker Compose is shut down if: always() - run: docker compose down + run: > + docker compose -f docker-compose-develop.yml -p cratey_integration + --profile objectstore down -v || true diff --git a/app/crates/resolver.py b/app/crates/resolver.py index 9d3acab..06b5726 100644 --- a/app/crates/resolver.py +++ b/app/crates/resolver.py @@ -44,9 +44,7 @@ def _object_exists(storage: StorageBackend, key: str) -> bool: return False -def resolve_crate( - storage: StorageBackend, crate_id: str, crate_prefix: str -) -> ResolvedCrate: +def resolve_crate(storage: StorageBackend, crate_id: str, crate_prefix: str) -> ResolvedCrate: """Resolve ``crate_id`` to a concrete crate object. :raises InvalidCrateId: If the ID is malformed. diff --git a/docker-compose-develop.yml b/docker-compose-develop.yml index ed99040..54b5fe7 100644 --- a/docker-compose-develop.yml +++ b/docker-compose-develop.yml @@ -23,6 +23,10 @@ services: - PROFILES_PATH=/app/profiles depends_on: - redis + # Metadata validation runs synchronously in this process, so the flask + # service needs the custom profiles mounted too (not just the worker). + volumes: + - ./tests/data/rocrate_validator_profiles:/app/profiles:ro celery_worker: build: diff --git a/pyproject.toml b/pyproject.toml index 60c63ea..3efe90e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,8 @@ dev = [ include = ["app*"] [tool.pytest.ini_options] +addopts = "--import-mode=importlib" +pythonpath = ["."] log_format = "%(asctime)s %(levelname)s %(message)s" log_date_format = "%Y-%m-%d %H:%M:%S" diff --git a/tests/test_crate_ids.py b/tests/crates/test_ids.py similarity index 100% rename from tests/test_crate_ids.py rename to tests/crates/test_ids.py diff --git a/tests/test_crate_layout.py b/tests/crates/test_layout.py similarity index 100% rename from tests/test_crate_layout.py rename to tests/crates/test_layout.py diff --git a/tests/test_crate_resolver.py b/tests/crates/test_resolver.py similarity index 100% rename from tests/test_crate_resolver.py rename to tests/crates/test_resolver.py diff --git a/tests/test_api_routes.py b/tests/ro_crates/test_routes.py similarity index 100% rename from tests/test_api_routes.py rename to tests/ro_crates/test_routes.py diff --git a/tests/test_logging.py b/tests/services/test_logging_service.py similarity index 100% rename from tests/test_logging.py rename to tests/services/test_logging_service.py diff --git a/tests/test_services.py b/tests/services/test_validation_service.py similarity index 100% rename from tests/test_services.py rename to tests/services/test_validation_service.py diff --git a/tests/test_storage.py b/tests/storage/test_memory.py similarity index 100% rename from tests/test_storage.py rename to tests/storage/test_memory.py diff --git a/tests/test_storage_s3.py b/tests/storage/test_s3.py similarity index 100% rename from tests/test_storage_s3.py rename to tests/storage/test_s3.py diff --git a/tests/test_validation_tasks.py b/tests/tasks/test_validation_tasks.py similarity index 100% rename from tests/test_validation_tasks.py rename to tests/tasks/test_validation_tasks.py diff --git a/tests/test_integration.py b/tests/test_integration.py index 629f7b6..0f0a29a 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,582 +1,186 @@ -import json +"""Integration tests against the full Docker stack. Brings up the dev compose stack (flask + +celery + redis + RustFS objectstore), seeds crates into the canonical layout via boto3, and +drives the HTTP API. + +Run with Docker available: pytest -s -v tests/test_integration.py +Excluded from the unit-test run (it needs Docker). +""" + import os import subprocess import time -import uuid -import docker +import boto3 import pytest import requests -from minio import Minio - -@pytest.fixture(scope="session") -def docker_client(): - return docker.from_env() +BASE_URL = "http://localhost:5001" +S3_URL = "http://localhost:9000" +BUCKET = "ro-crates" +CRATE_PREFIX = "crates" +ACCESS_KEY = "rustfsadmin" +SECRET_KEY = "rustfsadmin" +COMPOSE_FILE = "docker-compose-develop.yml" +PROJECT = "cratey_integration" +TEST_DATA = "tests/data/ro_crates" -@pytest.fixture(scope="session", autouse=True) -def docker_compose(docker_client): - """Start Docker Compose before tests, shut down after.""" - print("Starting Docker Compose...") - - PROJECT = f"test_{uuid.uuid4().hex}" - - # Integration tests use the MinIO endpoints, so enable - # MinIO and start the opt-in "minio" compose profile. - compose_env = {**os.environ, "MINIO_ENABLED": "true"} - +def _compose(*args, env=None): subprocess.run( [ "docker", "compose", "-f", - "docker-compose-develop.yml", + COMPOSE_FILE, "-p", PROJECT, "--profile", - "minio", - "up", - "-d", + "objectstore", + *args, ], check=True, - env=compose_env, + env=env, ) - time.sleep(10) # Wait for services to start — adjust as needed - - load_test_data_into_minio() - - yield # Run the tests - for container in docker_client.containers.list(): - if "cratey-validator" in container.name: - logs = container.logs().decode("utf-8") - print(f"\n======= Logs from {container.name} container =======") - print(logs) - - print("Stopping Docker Compose...") - subprocess.run( - [ - "docker", - "compose", - "-f", - "docker-compose-develop.yml", - "-p", - PROJECT, - "--profile", - "minio", - "down", - "-v", - ], - check=True, - ) +def _wait_for(url, timeout=90): + """Poll a URL until it responds, or fail after timeout seconds.""" + deadline = time.time() + timeout + while time.time() < deadline: + try: + if requests.get(url, timeout=2).status_code: + return + except requests.RequestException: + pass + time.sleep(2) + raise RuntimeError(f"Timed out waiting for {url}") + + +def _seed_crates(s3): + """Create the bucket and upload test crates under the crates/ prefix.""" + try: + s3.create_bucket(Bucket=BUCKET) + except s3.exceptions.ClientError: + pass # already exists + + for root, _, files in os.walk(TEST_DATA): + for name in files: + path = os.path.join(root, name) + rel = os.path.relpath(path, TEST_DATA) + s3.upload_file(path, BUCKET, f"{CRATE_PREFIX}/{rel}") + + +def _poll_result(crate_id, timeout=90): + """Poll the GET endpoint until a stored result appears (past 404).""" + url = f"{BASE_URL}/v1/ro_crates/{crate_id}/validation" + deadline = time.time() + timeout + response = requests.get(url) + while response.status_code == 404 and time.time() < deadline: + time.sleep(3) + response = requests.get(url) + return response -def load_test_data_into_minio(): - """Connect to MinIO and upload test files.""" - minio_client = Minio( - endpoint="localhost:9000", - access_key="minioadmin", - secret_key="minioadmin", - secure=False, +@pytest.fixture(scope="session", autouse=True) +def stack(): + env = { + **os.environ, + "STORAGE_ENABLED": "true", + "S3_ENDPOINT": "objectstore:9000", + "S3_ACCESS_KEY": ACCESS_KEY, + "S3_SECRET_KEY": SECRET_KEY, + "S3_BUCKET": BUCKET, + "S3_USE_SSL": "false", + "RUSTFS_ACCESS_KEY": ACCESS_KEY, + "RUSTFS_SECRET_KEY": SECRET_KEY, + } + _compose("up", "-d", "--build", env=env) + try: + _wait_for(f"{BASE_URL}/healthz") + s3 = boto3.client( + "s3", + endpoint_url=S3_URL, + aws_access_key_id=ACCESS_KEY, + aws_secret_access_key=SECRET_KEY, + region_name="us-east-1", + ) + _seed_crates(s3) + yield + finally: + _compose("down", "-v", env=env) + + +def test_healthz_and_readyz(): + assert requests.get(f"{BASE_URL}/healthz").json()["status"] == "ok" + ready = requests.get(f"{BASE_URL}/readyz") + assert ready.status_code == 200 + body = ready.json() + assert body["status"] == "ready" + assert body["checks"] == {"storage": "ok", "broker": "ok"} + + +def test_validate_metadata_inline(): + with open("tests/data/ro-crate-metadata.json", encoding="utf-8") as f: + crate_json = f.read() + + response = requests.post( + f"{BASE_URL}/v1/ro_crates/validate_metadata", + json={"crate_json": crate_json}, ) - bucket_name = "ro-crates" - test_data_dir = "tests/data/ro_crates" - - minio_client.make_bucket(bucket_name) - - # Walk and upload files - for root, _, files in os.walk(test_data_dir): - for file_name in files: - file_path = os.path.join(root, file_name) - object_name = os.path.relpath(file_path, test_data_dir) - - print(f"Uploading {file_path} as {object_name} to bucket {bucket_name}") - minio_client.fput_object(bucket_name, object_name, file_path) - - -def test_validate_metadata(): - url = "http://localhost:5001/v1/ro_crates/validate_metadata" - headers = {"accept": "application/json", "Content-Type": "application/json"} - - # Load the JSON from file - filepath = os.path.join("tests/data", "ro-crate-metadata.json") - with open(filepath, "r", encoding="utf-8") as f: - crate_json_data = json.load(f) - - # The API expects the JSON to be passed as a string - payload = {"crate_json": json.dumps(crate_json_data)} - - response = requests.post(url, json=payload, headers=headers) - - response_result = json.loads(response.json()["result"]) - - # Print response for debugging - print("Status Code:", response.status_code) - print("Response JSON:", response_result) - - # Assertions — update based on expected API behavior assert response.status_code == 200 - assert response_result["passed"] is True - - -def test_no_rocrate_for_validation(): - ro_crate = "ro_crate_10" - url = f"http://localhost:5001/v1/ro_crates/{ro_crate}/validation" - headers = {"accept": "application/json", "Content-Type": "application/json"} - - # The API expects the JSON to be passed as a string - payload = { - "minio_config": { - "endpoint": "minio:9000", - "accesskey": "minioadmin", - "secret": "minioadmin", - "ssl": False, - "bucket": "ro-crates", - } - } - - response = requests.post(url, json=payload, headers=headers) + assert response.json()["status"] == "valid" - response_result = response.json() - # Print response for debugging - print("Status Code:", response.status_code) - print("Response JSON:", response_result) +def test_missing_crate_returns_404(): + crate = "does_not_exist" + response = requests.post(f"{BASE_URL}/v1/ro_crates/{crate}/validation", json={}) + assert response.status_code == 404 + assert response.json()["error"] == f"No crate found for ID '{crate}'" - # Assertions — update based on expected API behavior - assert response.status_code == 400 - assert response_result["message"] == f"No RO-Crate with prefix: {ro_crate}" +def test_get_missing_result_returns_404(): + crate = "does_not_exist" + response = requests.get(f"{BASE_URL}/v1/ro_crates/{crate}/validation") + assert response.status_code == 404 + assert response.json()["message"] == f"No validation result yet for RO-Crate: {crate}" -def test_no_validation_result_for_missing_crate(): - ro_crate = "ro_crate_10" - url_get = f"http://localhost:5001/v1/ro_crates/{ro_crate}/validation" - headers = {"accept": "application/json", "Content-Type": "application/json"} - # The API expects the JSON to be passed as a string - payload = { - "minio_config": { - "endpoint": "minio:9000", - "accesskey": "minioadmin", - "secret": "minioadmin", - "ssl": False, - "bucket": "ro-crates", - } - } - - # GET action and tests - response = requests.get(url_get, json=payload, headers=headers) - response_result = response.json() - - # Print response for debugging - print("Status Code:", response.status_code) - print("Response JSON:", response_result) - - # Assertions - assert response.status_code == 400 - assert response_result["message"] == f"No RO-Crate with prefix: {ro_crate}" - - -def test_get_existing_validation_result(): - ro_crate = "ro_crate_3" - url_get = f"http://localhost:5001/v1/ro_crates/{ro_crate}/validation" - headers = {"accept": "application/json", "Content-Type": "application/json"} - - # The API expects the JSON to be passed as a string - payload = { - "minio_config": { - "endpoint": "minio:9000", - "accesskey": "minioadmin", - "secret": "minioadmin", - "ssl": False, - "bucket": "ro-crates", - } - } +def test_get_result_for_unvalidated_crate_returns_404(): + """A crate that exists but has not been validated yet has no stored result.""" + crate = "ro_crate_not_validated" + response = requests.get(f"{BASE_URL}/v1/ro_crates/{crate}/validation") + assert response.status_code == 404 + assert response.json()["message"] == f"No validation result yet for RO-Crate: {crate}" - # GET action and tests - response = requests.get(url_get, json=payload, headers=headers) - response_result = response.json() - - # Print response for debugging - print("Status Code:", response.status_code) - print("Response JSON:", response_result) - - # Assertions - assert response.status_code == 200 - assert response_result["passed"] is False - - -def test_rocrate_not_validated_yet(): - ro_crate = "ro_crate_not_validated" - url_get = f"http://localhost:5001/v1/ro_crates/{ro_crate}/validation" - headers = {"accept": "application/json", "Content-Type": "application/json"} - - # The API expects the JSON to be passed as a string - payload = { - "minio_config": { - "endpoint": "minio:9000", - "accesskey": "minioadmin", - "secret": "minioadmin", - "ssl": False, - "bucket": "ro-crates", - } - } - # GET action and tests - response = requests.get(url_get, json=payload, headers=headers) - response_result = response.json() - - # Print response for debugging - print("Status Code:", response.status_code) - print("Response JSON:", response_result) - - # Assertions - assert response.status_code == 400 - assert response_result["message"] == f"No validation result yet for RO-Crate: {ro_crate}" - - -def test_zipped_rocrate_validation(): - ro_crate = "ro_crate_1" - url_post = f"http://localhost:5001/v1/ro_crates/{ro_crate}/validation" - url_get = f"http://localhost:5001/v1/ro_crates/{ro_crate}/validation" - headers = {"accept": "application/json", "Content-Type": "application/json"} - - # The API expects the JSON to be passed as a string - payload = { - "minio_config": { - "endpoint": "minio:9000", - "accesskey": "minioadmin", - "secret": "minioadmin", - "ssl": False, - "bucket": "ro-crates", - } - } - - # POST action and tests - response = requests.post(url_post, json=payload, headers=headers) - response_result = response.json()["message"] - - # Print response for debugging - print("Status Code:", response.status_code) - print("Response JSON:", response_result) - - # Assertions +def test_zipped_crate_validation(): + response = requests.post(f"{BASE_URL}/v1/ro_crates/ro_crate_1/validation", json={}) assert response.status_code == 202 - assert response_result == "Validation in progress" - - # wait for ro-crate to be validated - time.sleep(10) - - # GET action and tests - response = requests.get(url_get, json=payload, headers=headers) - response_result = response.json() - - # Print response for debugging - print("Status Code:", response.status_code) - print("Response JSON:", response_result) - - start_time = time.time() - while response.status_code == 400: - time.sleep(10) - # GET action and tests - response = requests.get(url_get, json=payload, headers=headers) - response_result = response.json() - # Print response for debugging - print("Status Code:", response.status_code) - print("Response JSON:", response_result) - - elapsed = time.time() - start_time - if elapsed > 60: - print("60 seconds passed. Exiting loop") - break - - # Assertions - assert response.status_code == 200 - assert response_result["passed"] is False - - -def test_directory_rocrate_validation(): - ro_crate = "ro_crate_2" - url_post = f"http://localhost:5001/v1/ro_crates/{ro_crate}/validation" - url_get = f"http://localhost:5001/v1/ro_crates/{ro_crate}/validation" - headers = {"accept": "application/json", "Content-Type": "application/json"} - - # The API expects the JSON to be passed as a string - payload = { - "minio_config": { - "endpoint": "minio:9000", - "accesskey": "minioadmin", - "secret": "minioadmin", - "ssl": False, - "bucket": "ro-crates", - } - } + assert response.json()["message"] == "Validation in progress" - # POST action and tests - response = requests.post(url_post, json=payload, headers=headers) - response_result = response.json()["message"] + result = _poll_result("ro_crate_1") + assert result.status_code == 200 + assert result.json()["status"] == "invalid" - # Print response for debugging - print("Status Code:", response.status_code) - print("Response JSON:", response_result) - # Assertions +def test_directory_crate_validation(): + response = requests.post(f"{BASE_URL}/v1/ro_crates/ro_crate_2/validation", json={}) assert response.status_code == 202 - assert response_result == "Validation in progress" - - # wait for ro-crate to be validated - time.sleep(10) - - # GET action and tests - response = requests.get(url_get, json=payload, headers=headers) - response_result = response.json() - - # Print response for debugging - print("Status Code:", response.status_code) - print("Response JSON:", response_result) - - start_time = time.time() - while response.status_code == 400: - time.sleep(10) - # GET action and tests - response = requests.get(url_get, json=payload, headers=headers) - response_result = response.json() - # Print response for debugging - print("Status Code:", response.status_code) - print("Response JSON:", response_result) - - elapsed = time.time() - start_time - if elapsed > 60: - print("60 seconds passed. Exiting loop") - break - - # Assertions - assert response.status_code == 200 - assert response_result["passed"] is False - - -def test_extra_profile_rocrate_validation(): - ro_crate = "ro_crate_2" - profile_name = "alpha-crate-0.1" - url_post = f"http://localhost:5001/v1/ro_crates/{ro_crate}/validation" - url_get = f"http://localhost:5001/v1/ro_crates/{ro_crate}/validation" - headers = {"accept": "application/json", "Content-Type": "application/json"} - - # The API expects the JSON to be passed as a string - post_payload = { - "minio_config": { - "endpoint": "minio:9000", - "accesskey": "minioadmin", - "secret": "minioadmin", - "ssl": False, - "bucket": "ro-crates", - }, - "profile_name": profile_name, - } - get_payload = { - "minio_config": { - "endpoint": "minio:9000", - "accesskey": "minioadmin", - "secret": "minioadmin", - "ssl": False, - "bucket": "ro-crates", - } - } - - # POST action and tests - response = requests.post(url_post, json=post_payload, headers=headers) - response_result = response.json()["message"] - - # Print response for debugging - print("Status Code:", response.status_code) - print("Response JSON:", response_result) - - # Assertions - assert response.status_code == 202 - assert response_result == "Validation in progress" - - # wait for ro-crate to be validated - time.sleep(10) - - # GET action and tests - response = requests.get(url_get, json=get_payload, headers=headers) - response_result = response.json() - - # Print response for debugging - print("Status Code:", response.status_code) - print("Response JSON:", response_result) - - start_time = time.time() - while response.status_code == 400: - time.sleep(10) - # GET action and tests - response = requests.get(url_get, json=get_payload, headers=headers) - response_result = response.json() - # Print response for debugging - print("Status Code:", response.status_code) - print("Response JSON:", response_result) - - elapsed = time.time() - start_time - if elapsed > 60: - print("60 seconds passed. Exiting loop") - break - - # Assertions - assert response.status_code == 200 - assert response_result["passed"] is False - - -def test_ignore_rocrates_not_on_basepath(): - ro_crate = "ro_crate_4" - url_post = f"http://localhost:5001/v1/ro_crates/{ro_crate}/validation" - headers = {"accept": "application/json", "Content-Type": "application/json"} - - # The API expects the JSON to be passed as a string - payload = { - "minio_config": { - "endpoint": "minio:9000", - "accesskey": "minioadmin", - "secret": "minioadmin", - "ssl": False, - "bucket": "ro-crates", - } - } - - # POST action and tests - response = requests.post(url_post, json=payload, headers=headers) - response_result = response.json()["message"] - - # Print response for debugging - print("Status Code:", response.status_code) - print("Response JSON:", response_result) - - # Assertions - assert response.status_code == 400 - assert response_result == "No RO-Crate with prefix: ro_crate_4" - - -def test_zipped_rocrate_in_subdirectory_validation(): - ro_crate = "ro_crate_4" - subdir_path = "project_a" - url_post = f"http://localhost:5001/v1/ro_crates/{ro_crate}/validation" - url_get = f"http://localhost:5001/v1/ro_crates/{ro_crate}/validation" - headers = {"accept": "application/json", "Content-Type": "application/json"} - - # The API expects the JSON to be passed as a string - payload = { - "minio_config": { - "endpoint": "minio:9000", - "accesskey": "minioadmin", - "secret": "minioadmin", - "ssl": False, - "bucket": "ro-crates", - }, - "root_path": subdir_path, - } - # POST action and tests - response = requests.post(url_post, json=payload, headers=headers) - response_result = response.json()["message"] + result = _poll_result("ro_crate_2") + assert result.status_code == 200 + assert result.json()["status"] == "invalid" - # Print response for debugging - print("Status Code:", response.status_code) - print("Response JSON:", response_result) - # Assertions +def test_validation_with_explicit_profile(): + """A request carrying an explicit profile_name is accepted and produces a result.""" + response = requests.post( + f"{BASE_URL}/v1/ro_crates/ro_crate_3/validation", + json={"profile_name": "alpha-crate-0.1"}, + ) assert response.status_code == 202 - assert response_result == "Validation in progress" - - # wait for ro-crate to be validated - time.sleep(10) - - # GET action and tests - response = requests.get(url_get, json=payload, headers=headers) - response_result = response.json() - - # Print response for debugging - print("Status Code:", response.status_code) - print("Response JSON:", response_result) - - start_time = time.time() - while response.status_code == 400: - time.sleep(10) - # GET action and tests - response = requests.get(url_get, json=payload, headers=headers) - response_result = response.json() - # Print response for debugging - print("Status Code:", response.status_code) - print("Response JSON:", response_result) - - elapsed = time.time() - start_time - if elapsed > 60: - print("60 seconds passed. Exiting loop") - break - - # Assertions - assert response.status_code == 200 - assert response_result["passed"] is False - - -def test_directory_rocrate_in_subdirectory_validation(): - ro_crate = "ro_crate_5" - subdir_path = "project_a" - url_post = f"http://localhost:5001/v1/ro_crates/{ro_crate}/validation" - url_get = f"http://localhost:5001/v1/ro_crates/{ro_crate}/validation" - headers = {"accept": "application/json", "Content-Type": "application/json"} - - # The API expects the JSON to be passed as a string - payload = { - "minio_config": { - "endpoint": "minio:9000", - "accesskey": "minioadmin", - "secret": "minioadmin", - "ssl": False, - "bucket": "ro-crates", - }, - "root_path": subdir_path, - } - # POST action and tests - response = requests.post(url_post, json=payload, headers=headers) - response_result = response.json()["message"] - - # Print response for debugging - print("Status Code:", response.status_code) - print("Response JSON:", response_result) - - # Assertions - assert response.status_code == 202 - assert response_result == "Validation in progress" - - # wait for ro-crate to be validated - time.sleep(10) - - # GET action and tests - response = requests.get(url_get, json=payload, headers=headers) - response_result = response.json() - - # Print response for debugging - print("Status Code:", response.status_code) - print("Response JSON:", response_result) - - start_time = time.time() - while response.status_code == 400: - time.sleep(10) - # GET action and tests - response = requests.get(url_get, json=payload, headers=headers) - response_result = response.json() - # Print response for debugging - print("Status Code:", response.status_code) - print("Response JSON:", response_result) - - elapsed = time.time() - start_time - if elapsed > 60: - print("60 seconds passed. Exiting loop") - break - - # Assertions - assert response.status_code == 200 - assert response_result["passed"] is False + result = _poll_result("ro_crate_3") + assert result.status_code == 200 + assert result.json()["status"] == "invalid" diff --git a/tests/test_config.py b/tests/utils/test_config.py similarity index 100% rename from tests/test_config.py rename to tests/utils/test_config.py diff --git a/tests/test_webhooks.py b/tests/utils/test_webhook_utils.py similarity index 100% rename from tests/test_webhooks.py rename to tests/utils/test_webhook_utils.py diff --git a/tests/test_validation_outcome.py b/tests/validation/test_results.py similarity index 100% rename from tests/test_validation_outcome.py rename to tests/validation/test_results.py diff --git a/tests/test_validation_runner.py b/tests/validation/test_runner.py similarity index 100% rename from tests/test_validation_runner.py rename to tests/validation/test_runner.py From 3289a9aaa5a99827b831135d706ad9ed0c42cca3 Mon Sep 17 00:00:00 2001 From: Alex Hambley <33315205+alexhambley@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:16:54 +0100 Subject: [PATCH 14/16] docs: rewrite README for the new architecture with diagrams Closes #185 --- README.md | 501 +++++++++++------- docs/assets/minio-versioning-enabled.webp | Bin 87840 -> 0 bytes .../validate-minio-versioning-enabled.webp | Bin 56500 -> 0 bytes 3 files changed, 308 insertions(+), 193 deletions(-) delete mode 100644 docs/assets/minio-versioning-enabled.webp delete mode 100644 docs/assets/validate-minio-versioning-enabled.webp diff --git a/README.md b/README.md index 0aebd17..a47a505 100644 --- a/README.md +++ b/README.md @@ -1,250 +1,365 @@ # RO-Crate Validation Service -This project presents a Flask-based API for validating RO-Crates. +A Flask + Celery service that validates [RO-Crates](https://www.researchobject.org/ro-crate/) +against RO-Crate profiles using the +[`rocrate-validator`](https://pypi.org/project/roc-validator/) library. + +An **RO-Crate** is a way of packaging research data together with structured, +machine-readable metadata (a JSON-LD file named `ro-crate-metadata.json`). +**Validating** a crate checks that metadata against a **profile** (a set of +requirements, e.g. the base `ro-crate` profile or a domain profile such as +`five-safes-crate`) and reports whether it conforms. + +The service works in two modes: + +- **Metadata-only (default):** validate an RO-Crate metadata document supplied + directly in the request. This is stateless, so nothing is stored. +- **Storage-backed (optional):** validate whole crates (zip or directory) held + in an S3-compatible object store, asynchronously, and store the results. + +## Architecture + +```mermaid +flowchart LR + Client([Client]) + API["Flask API (apiflask)"] + Worker["Celery worker"] + Broker[("Redis broker")] + Validator["rocrate-validator"] + Store[("S3-compatible store
e.g., RustFS / AWS S3")] + + Client -->|HTTP| API + API -->|"Crate metadata-only flow: validate inline"| Validator + API -->|"Crate ID flow: resolve, then queue"| Broker + Broker --> Worker + Worker --> Validator + API -->|"resolve / read result"| Store + Worker -->|"fetch crate / write result"| Store + Worker -.->|"optional webhook"| Client +``` -### Optional MinIO object storage +- **Flask API** handles HTTP. Metadata-only validation runs **inline** (stateless). + S3-backed requests are validated by the **Celery worker**. +- **Redis** is the Celery broker. +- **S3-compatible store** holds crates and validation results. Credentials live + **server-side** (the service is configured with them); clients never send + storage credentials. Any S3-compatible store should work — the dev stack uses + [RustFS](https://rustfs.com/). -The RO-Crate Validation Service can validate an RO-Crate's metadata directly from a JSON payload (the `POST v1/ro_crates/validate_metadata` endpoint) without storing anything. This is the default mode. +## Concepts -Optionally, the service can read crates from — and write validation results -back to — a [MinIO](https://min.io/) object store. This is disabled by -default and controlled by the `MINIO_ENABLED` environment variable: +### Crate ID -- `MINIO_ENABLED=false` (default): only a stateless validation endpoint is available and nothing is stored. -- `MINIO_ENABLED=true`: the ID endpoints (`POST`/`GET v1/ro_crates/{crate_id}/validation`) are also registered, and a MinIO instance is required. With Docker Compose, start MinIO with its opt-in profile: `docker compose --profile minio up`. +In the S3 flow, a crate is addressed by a **Crate ID**. This a short, +opaque label chosen by the caller (e.g. `my-dataset-2026`). It is **not** a +filename, a path, or a URL: the service composes the actual object keys from it. -When MinIO is disabled the ID-based endpoints are not registered and return `404`. +Crate IDs are validated strictly: they must match +`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$` (start alphanumeric; only letters, digits, +`.`, `_`, `-`; max 128 characters; no `/` or `..`). This: -## API +- keeps IDs safe to compose into object keys and local paths; +- means the ID is treated as a single opaque token — a `.zip` **inside** an ID is + harmless, because the service never parses meaning out of the ID. + +Invalid IDs are rejected with `400` before anything else happens. + +### Storage layout + +Crates and their results live under separate, configurable prefixes in one +bucket, so a result can never be confused with (or collide with) a crate: + +| Item | Object key | +|------|------------| +| Crate (zip) | `{S3_CRATE_PREFIX}/{id}.zip` | +| Crate (directory) | `{S3_CRATE_PREFIX}/{id}/` containing `ro-crate-metadata.json` | +| Validation result | `{S3_RESULTS_PREFIX}/{id}.json` | + +Defaults: `S3_CRATE_PREFIX=crates`, `S3_RESULTS_PREFIX=validation-results`. -#### Request Validation of RO-Crate - -
- POST v1/ro_crates/{crate_id}/validation (Request validation of RO-Crate validation in Object Store) - -##### Path Parameters - -| name | type | data type | description | -|------------|-----------|-------------------------|-----------------------------------------------------------------------| -| crate_id | required | string | RO-Crate identifer string | - -##### Parameters - -| name | type | data type | description | -|------------|-----------|-------------------------|-----------------------------------------------------------------------| -| root_path | optional | string | Root path which contains the RO-Crate | -| webhook_url | optional | string | Webhook to send validation result to | -| profile_name | optional | string | RO-Crate profile to validate against | -| minio_config | required | dictionary | MinIO Configuration Details | +> **Zip layout matters:** for a zip crate, `ro-crate-metadata.json` must be at +> the **root** of the archive. Zipping a folder (so entries look like +> `mycrate/ro-crate-metadata.json`) makes the crate invalid. Zip the crate's +> **contents**, not its containing folder. -`minio_config` -> | name | type | data type | description | -> |------------|-----------|-------------------------|-----------------------------------------------------------------------| -> | endpoint | required | string | MinIO endpoint | -> | accesskey | required | string | MinIO access key or username | -> | secret | required | string | MinIO secret or password | -> | ssl | required | boolean | Use SSL encryption for MinIO access? | -> | bucket | required | string | MinIO bucket containing RO-Crate | +### Crate resolution -##### Responses +The service resolves a Crate ID to a concrete object by **direct existence +checks on the canonical keys**, rather than by listing a prefix and assuming. This +makes resolution deterministic and unambiguous: -| http code | content-type | response | -|---------------|-----------------------------------|---------------------------------------------------------------------| -| `202` | `application/json` | `{"message": "Validation in progress"}` | -| `400` | `application/json` | `{"message": "No RO-Crate with prefix: "}` | -| `500` | `application/json` | `{"message": "Internal server errors"}` | +Where the **zip object** is `{prefix}/{id}.zip` and the **directory metadata** is +`{prefix}/{id}/ro-crate-metadata.json`: -```javascript -curl -X 'POST' \ - 'http://localhost:5001/v1/ro_crates//validation' \ - -H 'accept: application/json' \ - -H 'Content-Type: application/json' \ - -d '{ - "minio_config": { - "accesskey": "", - "bucket": "ro-crates", - "endpoint": "minio:9000", - "secret": "", - "ssl": false - }, - "profile_name": "", - "webhook_url": "" -}' +```mermaid +flowchart TD + A["crate_id"] --> B{"valid format?"} + B -- no --> E["400 Invalid crate ID"] + B -- yes --> C{"zip object exists?"} + C -- yes --> Z{"directory metadata also exists?"} + Z -- yes --> AMB["409 Ambiguous"] + Z -- no --> ZIP["resolve as zip crate"] + C -- no --> D{"directory metadata exists?"} + D -- yes --> DIR["resolve as directory crate"] + D -- no --> NF["404 Not found"] ``` -
+### Validation outcome +Every validation produces a single **outcome** object with an explicit status: -#### Get RO-Crate Validation Result +| status | meaning | +|--------|---------| +| `valid` | the crate/metadata conforms to the profile | +| `invalid` | it was validated but has conformance issues (see `detail`) | +| `error` | it could not be validated (bad input, validator failure) | -
- GET v1/ro_crates/{crate_id}/validation (Obtain RO-Crate validation result from Object Store) +```json +{ "status": "invalid", "profile": "ro-crate-1.2", "created_at": "…", "detail": { … } } +``` + +## Request flows -##### Path Parameters +### Metadata-only (synchronous) -| name | type | data type | description | -|------------|-----------|-------------------------|-----------------------------------------------------------------------| -| crate_id | required | string | RO-Crate identifer string | +```mermaid +sequenceDiagram + actor Client + participant API as Flask API + participant V as rocrate-validator + Client->>API: POST /v1/ro_crates/validate_metadata { crate_json } + API->>V: validate metadata (inline) + V-->>API: outcome + API-->>Client: 200 valid/invalid · 422 error/bad input +``` -##### Parameters +### S3-backed (asynchronous) + +```mermaid +sequenceDiagram + actor Client + participant API as Flask API + participant S as S3 store + participant Q as Redis + participant W as Celery worker + Client->>API: POST /v1/ro_crates/{id}/validation + API->>S: resolve crate (stat canonical keys) + alt invalid id / not found / ambiguous + API-->>Client: 400 / 404 / 409 + else exists + API->>Q: queue (id, profile?, webhook?) + API-->>Client: 202 Validation in progress + Q->>W: deliver task + W->>S: download crate + W->>W: validate + W->>S: persist {results_prefix}/{id}.json + opt webhook_url given + W-->>Client: POST outcome (retried with backoff) + end + end + Client->>API: GET /v1/ro_crates/{id}/validation + API->>S: read result object + API-->>Client: 200 outcome · 404 not validated yet +``` + +The worker runs the stages in order (**fetch → validate → persist → webhook**), so +a storage write failure can never trigger a "success" webhook, and the outcome +(including an `error` outcome) is always persisted so `GET` reflects it. + +## API -| name | type | data type | description | -|------------|-----------|-------------------------|-----------------------------------------------------------------------| -| root_path | optional | string | Root path which contains the RO-Crate | -| minio_config | required | dictionary | MinIO Configuration Details | +Base URL in the dev stack: `http://localhost:5001`. -`minio_config` -> | name | type | data type | description | -> |------------|-----------|-------------------------|-----------------------------------------------------------------------| -> | endpoint | required | string | MinIO endpoint | -> | accesskey | required | string | MinIO access key or username | -> | secret | required | string | MinIO secret or password | -> | ssl | required | boolean | Use SSL encryption for MinIO access? | -> | bucket | required | string | MinIO bucket containing RO-Crate | +### `POST /v1/ro_crates/validate_metadata` -##### Responses +Validate an RO-Crate metadata document inline. This is always available. -| http code | content-type | response | -|---------------|-----------------------------------|---------------------------------------------------------------------| -| `200` | `application/json` | `Successful Validation` | -| `422` | `application/json` | `Error: Details of Validation Error` | -| `404` | `application/json` | `Not found` | +| field | required | type | description | +|-------|----------|------|-------------| +| `crate_json` | yes | string | RO-Crate metadata JSON-LD, as a string | +| `profile_name` | no | string | profile to validate against (default: auto/base profile) | -##### Example cURL +Responses: `200` (valid/invalid outcome), `422` (missing/empty/invalid JSON, or +an `error` outcome). -```javascript - curl -X 'GET' \ - 'http://localhost:5001/v1/ro_crates//validation' \ - -H 'accept: application/json' \ - -H 'Content-Type: application/json' \ - -d '{ - "minio_config": { - "accesskey": "", - "bucket": "ro-crates", - "endpoint": "minio:9000", - "secret": "", - "ssl": false - } -}' +`crate_json` is the metadata document as a **string**, so the easiest way to +validate a file is to let `jq` read and escape it (`-R` raw, `-s` slurp), and then +post the result: + +```bash +jq -Rs '{crate_json: .}' ro-crate-metadata.json \ + | curl -X POST http://localhost:5001/v1/ro_crates/validate_metadata \ + -H 'Content-Type: application/json' -d @- ``` -
+Add a profile with `jq -Rs '{crate_json: ., profile_name: "ro-crate-1.2"}' …`. -#### Validate RO-Crate Metadata +Or inline a small document directly: -
- POST v1/ro_crates/validate_metadata (validates submitted RO-Crate Metadata) +```bash +curl -X POST http://localhost:5001/v1/ro_crates/validate_metadata \ + -H 'Content-Type: application/json' \ + -d '{"crate_json": "{\"@context\": \"https://w3id.org/ro/crate/1.2/context\", \"@graph\": []}"}' +``` -##### Parameters +### `POST /v1/ro_crates/{crate_id}/validation` -| name | type | data type | description | -|------------|-----------|-------------------------|-----------------------------------------------------------------------| -| crate_json | required | string | RO-Crate metadata, stored as a single string | -| profile_name | optional | string | RO-Crate profile to validate against | +Queue a stored crate for validation. **This is only registered when storage is enabled** +(otherwise `404`). The request body carries no credentials. +| field | required | type | description | +|-------|----------|------|-------------| +| `profile_name` | no | string | profile to validate against | +| `webhook_url` | no | string | URL to POST the result to when done | -##### Responses +Responses: `202` queued, `400` invalid ID, `404` crate not found, `409` +ambiguous (both zip and directory exist) in the object store, `503` storage unavailable. + +```bash +curl -X POST http://localhost:5001/v1/ro_crates/my-crate/validation \ + -H 'Content-Type: application/json' -d '{"profile_name": "ro-crate-1.2"}' +``` -| http code | content-type | response | -|---------------|-----------------------------------|---------------------------------------------------------------------| -| `200` | `application/json` | `Successful Validation` | -| `422` | `application/json` | `Error: Details of Validation Error` | +### `GET /v1/ro_crates/{crate_id}/validation` -##### Example cURL +Fetch a stored validation result. Only registered when storage is enabled. -```javascript - curl -X 'POST' \ - 'http://localhost:5001/v1/ro_crates/validate_metadata' \ - -H 'accept: application/json' \ - -H 'Content-Type: application/json' \ - -d '{ - "crate_json": "{'\''test1'\'':'\''test2'\''}" - }' +Responses: `200` (the stored outcome, including a persisted `error` outcome), +`400` invalid ID, `404` no result stored yet. + +```bash +curl http://localhost:5001/v1/ro_crates/my-crate/validation ``` -
+### Health + +- `GET /healthz` - liveness; `200 {"status": "ok"}` whenever the process is up. +- `GET /readyz` - readiness; checks the object store and Celery broker. `200` + when ready/available, `503` otherwise. When storage is disabled, those dependencies + report `disabled`. + +## Configuration + +Configuration is read once at the start and validated; a misconfigured deployment +fails quickly with a clear error rather than at the first request. + +| config | default | description | +|----------|---------|-------------| +| `STORAGE_ENABLED` | `false` | enable the S3 ID endpoints | +| `S3_ENDPOINT` | — | object store endpoint, e.g. `objectstore:9000` | +| `S3_ACCESS_KEY` | — | access key | +| `S3_SECRET_KEY` | — | secret key | +| `S3_BUCKET` | — | bucket holding crates and results | +| `S3_USE_SSL` | `false` | use HTTPS to the store | +| `S3_REGION` | `us-east-1` | region (for AWS; not used elsewhere) | +| `S3_CRATE_PREFIX` | `crates` | key prefix for crates | +| `S3_RESULTS_PREFIX` | `validation-results` | key prefix for results | +| `CELERY_BROKER_URL` | — | Redis broker URL | +| `CELERY_RESULT_BACKEND` | — | Celery result backend URL | +| `PROFILES_PATH` | — | directory of 'custom' RO-Crate profiles (optional) | +| `FLASK_ENV` | `development` | `production` disables debug | +When `STORAGE_ENABLED=true`, the `S3_*` and `CELERY_*` variables above are +**required** — startup fails if any are missing. -## Setting up the project +## Running the service ### Prerequisites - Docker with Docker Compose -### Installation - -1. Clone the repository: - ```bash - git clone https://github.com/eScienceLab/Cratey-Validator.git - cd crate-validation-service - ``` - -2. Create the `.env` file for shared environment information. An example environment file is included (`example.env`), which can be copied for this purpose. But make sure to change any security settings (username and passwords). - -3. A directory containing RO-Crate profiles to replace the default RO-Crate profiles for validation may be provided. Note that this will need to contain all profile files, as the default profile data will not be used. An example of this is given in the `docker-compose-develop.yml` file, and described here: - 1. Store the profiles in a convenient directory, e.g.: `./local/rocrate_validator_profiles` - 2. Add a volume to the celery worker container for these, e.g.: - ``` - volumes: - - ./local/rocrate_validator_profiles:/app/profiles:ro - ``` - 3. Provide the `PROFILES_PATH` environment to the flask container (not the celery worker container) to match the internal path, e.g.: - ``` - - PROFILES_PATH=/app/profiles - ``` - -4. Build and start the services using Docker Compose: - ```bash - docker compose up --build - ``` - This runs in the default (metadata-only) mode. To enable the MinIO-backed - endpoints, set `MINIO_ENABLED=true` in your `.env` and start the `minio` - profile: - ```bash - docker compose --profile minio up --build - ``` - -5. **(Only when `MINIO_ENABLED=true`)** Set up the MinIO bucket - 1. Open the MinIO web interface at `http://localhost:9000`. - 2. Log in with your MinIO credentials. - 3. Create a new bucket named `ro-crates`. - 4. **Enable versioning** for the `ro-crates` bucket — this is important for tracking unique object versions. - - ![Ensure MinIO versioning is enabled](docs/assets/minio-versioning-enabled.webp "Ensure MinIO versioning is enabled") - - 5. Upload your RO-Crate files to the `ro-crates` bucket. - 6. To verify that versioning is enabled: - - Select the uploaded RO-Crate object in the `ro-crates` bucket. - - Navigate to the **Actions** panel on the right. - - The **Display Object Versions** option should be clickable. - - ![Validate MinIO versioning is enabled](docs/assets/validate-minio-versioning-enabled.webp "Validate MinIO versioning is enabled") +### Quick start +```bash +git clone https://github.com/eScienceLab/RO-Crate-Validation-Service.git +cd RO-Crate-Validation-Service +cp example.env .env # and then edit credentials +docker compose up --build +``` + +This runs in **metadata-only** mode (no storage). The API is at +`http://localhost:5001`. + +### With object storage (RustFS) + +Set `STORAGE_ENABLED=true` in `.env`, then start the local object store with its +opt-in profile (uses the prebuilt image compose file, or `-f +docker-compose-develop.yml` to build locally): + +```bash +docker compose --profile objectstore up --build +``` + +The RustFS console is at `http://localhost:9001` (default credentials are +`rustfsadmin` / `rustfsadmin`). Create the bucket named in `S3_BUCKET` +(default `ro-crates`) and upload crates under the crate prefix +(`crates/.zip` or `crates//…`). + +> The service does not create the bucket; create it once via the console, the RustFS +> Web UI, AWS CLI, or `boto3`. + +### Custom profiles + +To validate against profiles other than the bundled ones, mount a profiles +directory into **both** the flask and worker containers (metadata validation +runs in flask; crate validation runs in the worker) and set `PROFILES_PATH` to +the mounted path. See `docker-compose-develop.yml` for a working example. ## Development -For standard usage the Docker Compose script uses prebuilt containers. -For testing locally developed containers use the alternate Docker Compose file: ```bash - docker compose --file docker-compose-develop.yml up --build -``` +docker compose -f docker-compose-develop.yml --profile objectstore up --build +``` + +### Dependencies + +Direct dependencies live in `pyproject.toml`. + +```bash +pip-compile pyproject.toml -o requirements.txt # runtime lock +pip-compile --extra dev pyproject.toml -o requirements-dev.txt # + dev tools +``` -### Project Structure +### Tests & linting + +```bash +pip install -r requirements-dev.txt +pytest --ignore=tests/test_integration.py # unit tests (no Docker needed) +pytest tests/test_integration.py # integration tests (needs Docker) +ruff check . && ruff format --check . # lint + format +``` + +`tests/` mirrors the `app/` package layout. The integration tests bring up the +compose stack and seed crates via `boto3`. + +## Project structure ``` app/ -├── ro_crates/ -│ ├── routes/ -│ │ ├── __init__.py # Registers blueprints -│ │ └── post_routes.py # POST API routes -│ └── __init__.py +├── __init__.py # app factory: config, blueprints, error handlers, request IDs +├── health.py # /healthz and /readyz +├── storage/ # object-storage abstraction +│ ├── base.py # StorageBackend protocol + ObjectStat +│ ├── s3.py # boto3 implementation (any S3-compatible store) +│ ├── memory.py # in-memory backend (tests / local) +│ └── errors.py # StorageError, ObjectNotFound +├── crates/ # crate identity, layout, resolution +│ ├── ids.py # 'Crate ID' validation +│ ├── layout.py # object keys +│ └── resolver.py # deterministic zip/dir resolution +├── validation/ # validation boundary +│ ├── results.py # ValidationOutcome (valid/invalid/error) +│ └── runner.py # wraps rocrate-validator +├── ro_crates/routes/ # HTTP endpoints (metadata + ID-based) ├── services/ -│ ├── logging_service.py # Centralised logging -│ └── validation_service.py # Queue RO-Crates for validation -├── tasks/ -│ └── validation_tasks.py # Validate RO-Crates -├── utils/ -│ ├── config.py # Configuration -│ ├── minio_utils.py # Methods for interacting with MinIO -│ └── webhook_utils.py # Methods for sending webhooks -``` \ No newline at end of file +│ ├── validation_service.py # request handling: resolve, queue, read results +│ └── logging_service.py # JSON logging, request IDs, redaction +├── tasks/validation_tasks.py # Celery task: fetch → validate → persist → webhook +└── utils/ + ├── config.py # validated Settings + └── webhook_utils.py # webhook delivery with retry/backoff +``` + +## License + +MIT — © eScience Lab, The University of Manchester. diff --git a/docs/assets/minio-versioning-enabled.webp b/docs/assets/minio-versioning-enabled.webp deleted file mode 100644 index d69b2ddf67b50a260a9eb7cffb997b754c7268bc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 87840 zcmb@u1z1(t_b_}f_tG5#(k%^&bazU3cbBvR0umx1Esb=lfHX*lba!_n-T7Xeah#do zIPrb|%X9WwXPupEuf6xGgQBFE7_J%+pe8CLuPV=>jsO4v=ph#|B7hVcASo;?KaUD| z1fb3v*xOq}p#cCkwoVR;;zA_q8k!`q8vqdC9smw<;Tae?+6&6d%iQJue!qi%{7yyz zz`Lg*hP%3c;QtkbZfxRc1ONc#A-N5W9gS=ta47%)t}(KAZ~_2e>>+q^S10>BI2rh^`N3bU}jrm z3tI>*0D<{zZ0w97ur~yzhU9^?iF#K*hvDz)G5Z~CU|{%LodyPGzrnwZ1yT~yu!x1D zy|ux!Zx8>MiH)^0gkIl%4KgBYKiE2}~95M~95xLBylLSPgKEbC~cbk`q9A3)mA&4usq-|{#(E8dNT0)lri zu@x7Bz>sl)VoV*x?&xrrKi|d?0)3+csKMMq@~$j|CZItlb7ct#451Zh*~s8q{~==m z?U~pL{z@NEjESTAUEl6#IBIMnA_{>aV+X^T*s9!(7lH@l+B*r|wFM~)rnR^JwodNw zLMGPYcX%=gtnBEb^u4T$lY{ad9@0;6fW4Fa9StG<1SeP;NXbKBNPoaZ02zQVKmhO zx3?w$7YP3PPuM%0#c#M@>SF+yek2blBwIDX`ZUWFcjUW6Wjo`;^$vv}$7C%QcW%tJ=>3tdehH2)oaz*LYr zS;0bJUP$ebn!(s$JOBguF{B+qFh8VaRtQ%3*Ad=P<@+rCeoViO0Ptgme$5BZ3=aj5 z11|ti{pXS>v?#3K$NP=ezo+CceKq`z-rsxhzwQ5cV*xOR^i%q`+4!{wpg>Rws14Kz z>HyV%DgY#)dQc^(8&rD-|JuITx7juOtu@7O{jq|~%b$6M1i~@D^z)8C9GTxUZ!ll| zQj3#`n-he`0m63n&m1hw%$-OCAt#0jiKML&BR$C@=0~gmz}@+Cmj?hiRQPtT0I|D& z;C$x*0N(oB+uP4Sa8mUEz$bRdmQeKrN6i5Mpd$hR?Nvt34lcjc2fn*OLv{{S$m+Wf zAOesBs3GHd1YiSj0|Wq~04abRKpCI`(1q};IfTz00ImQJfDa%55DW+hyaglxQUDo% zT);;_F`xoa184+%0dxWS0V9AZ2=}Z2wg7v86Tl@92!sV912KX3fy6*cARX`#kORmE z6a~rv6@eN+eV`f87U&H00Qv%hfsw!jU>YzF_z74AYy@@y2Y?g61>gqo0C)iffXf?|T=fD(j~hEj&ofii=73iTYy4=NNY4k{h$15`OwBUBgE2-G~(7Su5a073*| zgGfOPAP$f)NDib4G6gw+UVwr?F`#r%0jL@>&qJVj&^G8C37cowC85=!&7fVO1E6D|v!TnNTOn&{1^NUA z1_m4E0Sr52Eo#76!nnhPz$C*I!Zg4Pz%0QW!@|Pehoyt%g_VOffOUcmfQ^U!09y|` z2)hD%28Reo4EG353{Dfy7Vafn3|u~39o!(?8r&s38ayRD7rY$2G5j<5F!)UPYWP0* z75Ga8bc6>8ya>t&mIyuwaR>zntq9Wyhlq%XWQd%I3Wyen-iUFCMTqT)3y9}P7)Z28 z!bsXk&Pbt1IY*AT=CxFwc>5#W8rh-8{r4x7voP7KoQUqC=$36 zWDs-{91{`|iWAxs#uGLZZWCb>2@qKlMHAH#Z4hG;^ATGRM-$f*Z<5?25hSrDi6dzt z*&`(&l^}H{O(X3ky(D`;rb70TtdMMm9D$sJ+>|_;yor2|f|%k7g$KoZib+a%N)AeM z%2>)a%2TQbRBBYOs4A$|AK*TaeDM6i`v=q1$khDQPpQ+XhiPDFIB2YBl4<&Bp=eoY z&1n;9`{u+QfRz#?I!zR=~EwPQk9vp2R-JfyE)u5yH{V3C$_Q>BU*YdCtYg<-}FYwad-G zZN;6>z0UK1$Al-7XNi}L*MK*Tcb<=gPoFQ9Z=Ro&-+(`ze^G#3z*rz#U`>!l&{FV& z;EoWpkb_W}(5Wz&u%~c?2uMU+BuJ!J6hl-+G(mJ$j7-c-?7i5YIIFn3c!LDAgtSD2 z#F!+Zq>*HvR#R0oTk}{;RO_wQiZ-iufcB`)106S=4qZH5Yu#EsR6PT| z5`AcWHT``3D+4)$bc16E9IIPvHS0nfI2(PNDq9R&E87-3B0D#`0ed?80Q>o; zTuNr+9VL=4_Zf9y|f9C}kK9@w7GgnpD5;t@=Tet3Kw9j5YTYE0{ zJj)&IZsgwNLF(b-vEV7_nf3zs!th0t7rB?8*NV5ecdieDkCji)OXio6FOPiHe5?Hk z{k;8_{Kfs>2cQHv1dP4new7jk4zvjDea-SZ{`F0eaZqP4b8t-XRfthY=bMLb;@;eb znuhj*+J^(3l1nj|_X`sl6R+m4vWF)6VKvCgscaWZk0 z@f7jl@mC4v2_uO@iG@i7Nw1SmlZ}%H-wC`cNFhuKPPs_6NF7fTPb*KSN{`8a$#BV7 z%~Z{7%i_q&%f`zN&c4pE&6&?t%x%eI&&$gv$ParD_1^jY#s}>W{U1d>Ru(W6q!nTp z1{VQ}oQpO;>3Fb2bMo8->WdMn6K2R9H^42YO3b0F8j>* zIj4rSCb9NjZCD*bonPH;{qy?c2K$EXMzhAHCcUPqX7%Rb7KN7HR_WIEFJfPs+XUO{ z+j-k-I=DKjIypKky4bqPyIH%-dRTkPdf9r*``G&``#Jlo2Y3eR2KfgYheU?H3`-7o zjmVA+jH--|k7NEp073E?=$qts<|+tP!u}tv_0?*$~_4-_+V%*|Oid+z$AP{xxZbdZ%<(V7Gfu zeQ#yoVgL3Z5F~{%H3a|;asU8i4FCYMA0j_!{h}X!%K`jFr-i`4U#~m#pWt6~ z$h#L183IrMk?9q-A=1ql08o?(08l~dN7V)ZI! zlgY{?r%Ypm>o3B9x3Il(w>2qxyzl})tWn|f>+9nZ+5yY_FfMay$Mr^=>-^3+ByKMT z+laO_n>8g9rXA0V>xZ|FM-}U>y(ybamtU?6j@8a&V)9+0qbQ$MhZiRoQzSQf* ztecPFczt^TeO$Ocw{_cin|8ZF62(;NK<-lOTET!iEm0y z64y{~Z?Bnd^{BiYAZ^Xw-X6TYbcM8;t8^R(GeIW)qDr8b`?wS4>0E(4OX#(OPm=3a zG}2PNcB&cS#R`aHcZ~Xe+yAdjvY#<1`wHu~STO$Y62ez)AG)RLZvW4cmQIz%&C8pT zhqV7XIm|`*XM#2Ck=Uc5J^znA^$2$$IrhBOh=c5tdRujCxA)to!f|(Toe6I~6@oTqyqBV>a!oo3 z!tfKr{1a>s46ZIg(-Bt~HPoqayn0@vrj&o1aHBpP@*}x=Pbmk)`}$YIyTh#;jxfaq ze4se1I~ANdAX(aH!d=eL^vM*gDr}fFI&2zRF3drO*?Y^ws^mt@7NW!Wh3LsupxGuN zO29qM^P~cNJ~wxf#S=6YEny#Gnx3ok}D#ZyQuDu=1QiTjJ zhBRqZ+)tM2JtF?q>|X)jcdMoDOFjdo*LKKjjq13hR;W(Uk}%LEioT%=WvU6VOf`ge zW^vf6smT*>q2_ns7IKP%k!J-6{)xjlC^b7dV1bEg?Ym_CM( z%LgT|Vg15b4Z1=DGmD;J%CVKbati6CR9!<8nhzD|4#)!xK5)6exd^Z#_te~571so? zPLQocBmaSI>fu&PNf3YzKDl~%p^bA$da+CYH0^gx;ZP)UwTJMage`4T`;&G|oh{GV zar|qwxj!cjK7Fw7cQ3kI{zKAh&fyyu$*3VP4E*1+mksIJz9XmCriLp2KCvhMo886f zq2-S;s|aJ8qiC5(YZkP3HbU3FM4x$8+9Y3`?pEw!ra|M zwbKc)+0PBC{Aw zhU}|+M_jKPgUamRY7-UK??MG7pnji|IQc~*@~i)J94x11wIO1_zqa?ax8ohbx?NDy zN+3d|GU8I~O)$~YYHQE;s2q>`)Dp_6^?G+TsrUX|qEdMG6a}_J)Wpb&Ka=Pz4R@kX zCiIsEmfYPkJVgC7bcE+Jg(e#dDU_WZ)&*MqYb#{x`13ilEL2E!qAhMv^2+l?{+$gI zpV7w#P(<46J(=^PNd4dIveAkb)N(!xG#^A;uENBUeVo16_!_aLuVi7==96|!U=wu8 zlYKAU34(mnlsAFaa2o1t172TA)sT&pS7=|*2|aSc_)s0gZ3lKwG3V~DF;b!&QXa=~ zvMZYiO&QrTE~$BvsB9l}h&znCVz$h;1LmIb=XKmLku)?7kzpFCi0isysoXU{^XS;n zWk!d($R_6XMZCWE{Lu-Um7m!c%ua^$bVMUl$0^-&eEa#WQ08=Rk}GOz6ud0HK2Es? zW(3j^pAWO^u9|c|m&kQ7=GJTvI_Sy^LSI;94#*Zj%i{5>dOvBM?qQ?|p4NuIvh=pdP+!l9u|kKKC{4ga-yx*@&%G5^k{3s_4eN;p!K50jxA zjjL9jZb}A1hrc~+#-LydnPP(ELvEOOJcC2(KD-DSB;zVxS6s<3VD0dbeo_B6Q&{N| z|H<1PAzfX~93NjPp$?uk+(Es`z?#s#%U)>t#3y;ns?d9a z>f=Gzm2mcx9v)P!&DvP4zGP|?Xg&A!m0mC*??`H5Aov@XaSyy@-n3=^kn9W2)oEgW zh~M3DRe%~8ip5cXxVQfRpS-1plP&Q5t~9}6Bz3YG-Tf>E{Dikgf(oWm=k~^wkiD_f zJ;d$abM=4Px-Mg%3FoEx5;#r_V-T_j4$I&bB}H3D#lUB;X?IN(LLE$eywce+Jv`cq z)hd!iscct@&BC&d3uMTeGf~Qe+d$6nbiak~$eiY3xB18~^yO15F^6x!KrAvW75^0L z;pv-U7Mzbawy8|or0tw$BYsLx9u}HytxWq|FUDavmZu7enM@|hhAcai!5b{^d(k8c zSe&N~mRDkubr|`<%o-HJ;+3|TA-&$Sz6jOt-shYSjV~7&uTHOx8ceqJI1EYg$;L)v z(!Mm*>T|-kCp;Qw_7eC4c!fMsun`Vi@Gu7d{^OqOz8&>fEBo%VGhJKt^2=sy^+Yj99Efx& z(NF_w_*e5gJob`oR+xs`4AU)&jp7{)Rv^EiR?k3Z4EZ8K1YzvF7rft33$GfVbzsJS zl8D)Jl^P9j*EKp)#l0K76L-&MU8WmHSccOqQeX_$xN2FrW^J016yn8-8G$9((WH4g zzRc2UwQDxbWJxRWmFiO45e}U&|4N?vRobc{#%v+PR(MdzM73fxB?ea!Jmnn>8&tf$ z!IQ}w@R9v=oR)Y&#IXXU!mBT&oY2hX)}eS2EwT<)YIp9cOa1U<%|2n=DnnD2s3+m= z=Cx%ZQ{NQ|LGT5EHv5imqbL2d;|My8C!sKXVu8MA={M7;uyht&uYHd!Y&CPBubMfg zl-o%Ukv(3H6l|>B9os7mzI?|mOrfNq|Y&1gT#XP-yFn0lmKbadm&Q4 z>y8&^yZ)fq`ojK>lv=K=h8zt|pJMIg2)_5$83+$G4A+QVW6z>6{BIY5m3m(8W0dSu zCrYuGOqon>hi+=+V$Gi$Yy$3Iv0{8Zdad7Sa-Uz5;O+V&2_YHePVHK(wht{Yy1ZEw z$f_?vT@_h^6e|?cLv~*GTRT_i-lbE&IXer3O23bg8fj{?D~SdO!Pr^BfBQL2car!61O(8^^_cq^dgHqVG>I(6qXQ-_OjTKTsZ%_Oc6cO7I`dI1 z;&>YCs;s#yfb2mSMT>VBWqd3AUy4$HA(fr_A^SDx4)LCi5?U6vJsU*qo5jxX%reMa z8tY;c8{fkzeH@zcB8C#`2vx~nKP?vuB3_*Oh(3Y+Q19vBu^%=9ko%X~;U}`6%Wr2O zbpe5YIspGixjWeXxr_-p^@ITbwDHon_Qiu_zr#qs!hLRmB!pz%g?Ap@iGGF|i7e0V~PgmUXp4c?r zbCD=J26?o%XYVf45S=Lso9M|81GY&%pM4QT=~e35ksm_~B?OMnO25kLrO`qo0mNw= znHKDZ&Dre5hnAKlNq5?$KZy{htWZLtX|1Pmb2TWdsK&D|xfcq_ zS+0KfY#$}LzWr^iCT}8rL|_i#q+@QlzZDmPmnZ>aL%9{#tQjXgXi*rc&|Kf~v4N(Z zSg{;yv&u64*;92(qeXuyxn6_TFa;+H&*!%(Halg1t401n$D9T8UfhyVd~I+&&nPY3-n<RYpPWWt?bgy*qE)T#PvLZ5*1 z+dJTTJ)CRIqUD!GMC*ch8;p1)4M#vTZ*mIj zJHKe8i7;#X8Ndc@1!T{6>YoG`Z>L%T#C-$r=XpQ(8y*xn)mS=EBlGzvm|%ct1N~Q?fJ$1QYRbPS<>P%7y zor99Mle#$*o(FyUqvo0P!8EGzm;zR{yJYwOlPS#51?24!J>PiskhM1_DI6Lv7}y4# z@sR5?XQV`kGez&S*-x*_<~Pbe09Z*B^yoaHW?+Rbhltz&^_M zxGZVRn7&=U>;;R!x*~s~-`mpYQMypWGy+nT`2NPO^*dwGDLvg6qs;51RZHmC`m}a! zjMlU%wmfmP^glq>^a7J<$Lu^KRd5}G4j0W+W%MU z6DuT2`!m#Mok@x*HygvFd_5X)OV&wuuU!zWY`>p4Hj7Yw4|?`$X;xIgHWTYv1@3&X zi;w(Xx4~qGMg*NnT1TO;j5n0c;3+<*s~hE@BBATh zxsl2uFBV5Hz0{~t^|($;K~G)g^{qxyR*~7iwg?G*NuY7hw*sWeed2w&APNvX+lkX` z7XQH1vxGJ_Kofy7!D$>ymnkKq7caif0(Zn5c!q+n5E@=HGw9u!VJR$a@4YY4mDo0u zAkP#wXt}PeebC?R`pi%EA#`{<+ovaf^gaq|M%=Vvh({BJ{tJnY{*(>E1agh?%!^UMeh?+iX>AumBAoFPTn9#UkO90>88gFJ_;pD33?;;9R%$4;`qob!*Y}hNfsxyV28Ha0l>y-qWAZ}`M6Qx?e z!_tEusFYqAW>0PQSD8eC_8bs(dgi?Q**Nlq=nPJUmV~dJh7zBxq2>`^WFQC2;ORDn zE6_(%Pb!B|tITq$5IILa{^OJ2AWXj0qIaJI`Fl|#=8eNYuI@h?oaAjJLM1#Kjzy@b z9StU?2xXIuXtht$geW=p?m=5!t9|&g5#it>VyY`WFe|;0&>`Tl7(oOx3zv{?45|S8 z9h_seRiFq^{ajju97N4=?C~TpPk3Vb<|Zm#JzOz5bF;tLV{z1a1bFhohUPf;TVM{W z%f~u*e;$ZC@a;O=FBojTcHm zxH6XnH0be;5@ZX$ln@Y#s-7BG-m1!=e*^5GF4c@ui@zMK#peW6u=S#^JI%CAqC+hW zKe0R3$R?7(l--3ns5$eMkl1O~L7^bhxQUh5Lfo&lf`#)Aj_Bm$^WFM8^JsI*hwW9$ z;3j5D+YLqjlQ(!rtchbra1qtAH%ymBk3^LEs@74hYb_Qd3uZOW+&%9Vo@`bU+TS{S z+}l3hcz!iaZ4n@fuz~A*$g}#ffAfmd~X{Zm@N`cTw5;Xlw~y(UE%o)Wai*mLk^wY^V=2Eo@GI zE`sfh_1v*oo6wAqBw4J$(kXvrjlh8s_OVM7_Bj&_e6{AetQBPYCnibQ+$p2g9+ZK7Tz=l4^+8^TF(a??1Wvk#uEt|ti?vRLjF zJu87C_63UhL;L1W9cq_=irC%K?pmQ;M`NWZ{rx?s+#`f2wigfAC$v_WNs}S52bi4x zPgC0XsN{WPr~J=&&6d_R%YO@J7=$BJxwWS zkTkRY+RFdO^XkqAFi$XcyL#u-Yw6TAx~DatJ(X?r?6o7TBD>h3S^M1adVN^hIMZ`# zMPo4u6?b9k8ee@TKqvlbSw-@KB?sapLHVMwtNMIF4)wQ%Sl^R*h6Jlr%W#HgbYI*7 z^DMF)A~+mlAoKDV$+`VYQRi2A2<54igsAu<~* z-6#&s08C=L@Qk`&Ut7^KbLx9lpkr`tM{$Cw<-EFt$|v=k6cwvGH8w~E=iPBpsCEH>g62*djYh@?k` z?~}T8!$PUiyg%@0-OGz;kNoe=r{CT}WOVMW{&`p80JsX-3$g{ap~e1%cQvx3WZVnd zxL*e`wgk#^_UxVqZ)qO|Ul0pMYMO3qw&{+>WsS#_d|k+>Jy)|1#ZE<~yAJlD&5<(i z%H>4a=&wC^iOVALij|gY0I>(Z2nFh1{7pmVeEeKEKIm(IcLHjQkE`y)3)_Cj?DPOy zeLenMKGJQ%c!Za$a~7g(CR2g=b<36-k1=ER+PihC6|!411d7dNe%OK_A(>Wium>;+ zt&Xl<*}~R+wKN8ME7qoMQm=;$Ug6W&G<~p zrR+eJ5w&T3^j|yO=@ujAY+-bf#p~p1Z)*LS|5)>Pht0P|1yN<9e;kwx_VVC=(6XFi z$wCC)dcBBX*_0;ekJ1pD|GD@1HD>77@6JF7mEZr#r}<}>!{v>ne-qlCHicA^W~zP0 zPdRQD`rhLoy^PY!Ypt34_i+i+#U-DOMAU9*O`k1DXt>wdIO^e#8;Y%W~+y&1Ewi%do(#+>-b7T)eVb(%xQ=Ez>8R(k=T!Ej8nI9?dl4ihBkgqR%;CllpIx&i19}?O`NKV;08p72l*&j zESBFq{q+vJzjKDqqT~ISKnmSgtf_0BEORGAJJZTCdtEzEZmz(|`LF27&Yzrq;TFzG z$0A7!CPJu7@0w7WRHf>kH8`LZQTsX~7U#;H_C_Uox=^dKpcFpZ_+v6`uFjOZYenoU za*H8J(-nr|zMB=!NYQFUxz$G^2yVq;54Jp@j#wC(7pbAv2MTQWYdgXl^N?R#9lgN| zTGnkQ^^av0kv8;>J>gxF*-;lrBeP|yF3DY>se|I%-n)6!piCr(`!Nq6(1xDIG0mqt zb*v<0<5;*EaIwHQ=I|7AQHyqfv5$qt@lkzqS$IN@%fh|savw0mCKgF8L7G$o|I%v_ ziN&%pUJ|>~Hn4yZM|eWM?P|vD5TaCjz&9<=C@om|w_TDB;}3S9pD|OFtNy{_(4)XK zbB8t6k1R&n;rpB?&sm`Lbp!5ePqXwwtd;#My}wx39X=%}{$_%}^gH|)UgA3;CrI6(=`yPz| z31|2RH`+Hc&qAYUUJr=KStKdl+PL$al=X%8q#?W%PGm|@g$AT>NfcJlYnCYy6>qv= zO1JE~QsFD5dPMTVdJD0mV?1qLzVMKrn??C>qVZ6Ws$yxXQntC5*@ToMh?z_JZA0QN zne>S>>?2Ak*D=IROn=g#?7XB(qX2f?s2d}rTV$$!TuKVoZw}l;i($m-!>F8H zEx46p`Z4hRCOAH^)*Vd};>ok}1;0-HZR<+Py7OoMllDr8q?vB%WUjye^^$6lJU#Xy zJ4Rz7Ob2PK*pPAp^7F&)!x#c%7Bzf}7XWmORc3I;`#yxv4C@s9X}fS^i1;AV>Y7hJ z9lq9HH~1M9ftCL9dTCngRSg{QGZ~wBq%ty+OFSDGFvZQusT!Z?-oCdPW$zG z0E3Y|lLr$q5_^4hPFku|hpstgRpD75!=>j%d@0*|=e`Mx!FdHU2e?wy-M%M$z8~4+ z64oeNMMTrbMChK?Jb~T(fLAMHU|;YNt3E-{Wh2Yb6AeB)zR>0&>M|w`Gq|Mx5)sbI z$S$d@%ax4(sYgo`5_QhN)8B38(eM9-5#1;)h#Iiizfw7}YkhEqF|A3Udy0bB#0DM6 zGWpaPa;U;U9nXBlmUgy$g6m*^LHIVz6D5Q?HP6zgDeQZk)a#$Gu;1~)^yf~({t-O% zcJ2<%!ASXsU&PWji={DHcx>fEkEO<*-K&m!A17O2hu1QuDN^Lyzg=wM5iHIZ!&ne~ zay_k=hFBBu)QhRQo)nqB3%Ta0%;jn7stufByAO)3?2v_F*i^`;Xun0{3++ujk%@%) zyf8}vCQF#6{ZfYUt3Z8%oyk-$l5~RWiZ?BMCbahTMbaFqXBO0)3n6r8XqmqcD=`%L zaftsMI%V9(drLY+zK8pPE{mtk@3cJt`!x+Qt$7d(x z=4g&Y8nwNC*1R0Y41H6eNWAQDmgG>ZjCIngvL17F$qmJR)L{$W-2+S$5ZZ@2#x~sK zsc^VuoGFylND)q>NrkAf@^aq`$Dp7x7P({`1O}u}auz6-0l8Mt9IlJUpAQIWpmG?# zMl(yk@jsck7|U@ueNrV(!JZOw)#1&u#8O^dx}5*elo=uC`FYP`KiJTJlZH|QJF{I+ z&f=06&1>}Dlzg_xI6NAgu>>!(c5XNz1soi=@i2%BgmEE05hO?d z-4=X#l8j``_}`4MrCJ4EJxc3R`3n!u0mWoHqATO?<+n=|^!Js*6^d4Z_@C~_#Ue-3 zSmm@WT7w(Ey1ne~07lCq-3!n^0 zvgE%7h8B|cUf47Mbrq{Ww%a9$U-30N>OQYDq(h02p@~w*U+XeZ%7P2A&3n3Rpr4#X zmBFV`-FLvgGKrt35_wNqo?Iq*7=fzTzdtO$Zl{^AY$LFXoBG{-;}7wAL($%gO!}^i z^p`hr463*VNjPsGs4uuhQ(KNpud@`S&LU^zedq;Dm)=@We;iMw%@E`oz?YRqd{ou@ zVHNP~cS70Sp;qM@{g0yYkBvh`;_P+D_H!i1-FGfm$JAB8@)Ez{-a@7}h}FmiiGZ)I z?v;rH%I*>8fS@uYeCAIPb9eek3&=~)jWhQPvj|N=%2=)9U{DnqC+7SyKB5!)FwWHH zC*;=gyDKT!*x^HXUBjal4CSg019^pJx}nZ0@}tArAHPQ={K@I~;}N#P-kCVDj@}!_ zNOY(%A0J}~-z0XO|9Eww*62vC|jjvOFr#XEORfYH} zlaWb8Eo7>T^pW_k|ZBa^cDO3S-Yu__@5~D}tDjur*d~v=2jH02T7t!{o2*Noh@cwTE zw5};9LCeS{3V&^XO(kB*Gy8iMl74Y&OWM#M zd996G3sL4d2ms7j4$BT*05tN>O~1`uNu8-gnTXe)6=zxo1Tx4JKm)J zyNL1Asr&Zg1@T&`a~CxM=x=-`oa#L^f6&(d*ADi(fY`0kvoZPIE@8CWxXCi@VWX)D z7tig24%~#dKUWxJv9Mr~#U_St%i9x8g-s{lxQw<^Qk>LeA^52uZFzQ*tu!?doF%7q zeJFxNc zvJ~mE&y)j>bae;bCE%@`nM?f$a{ZqPxHHoXBTF_u0#F3U{6*aMANQ<>v=?M`Z}8S^ zF}5O(nEhhk3%1}zQ&IYi{* zpyalr(WLW+wpXv<{=Hf|m{hGn1m^{oVzS|_CNpEvyfHX8_hN^sOMT0RihVj#Yc8t z-N}R{rL%!8Cv8v9PgCWDi9ELg4#I8d4dF2o0W;5;Ol*dr&pAJXkiBvNvIcvKv-DAL zrXgy5jlvA6`jrULD{vu62E`FU`0TN{7ahn8EjxvOGkpz0By0p2~k+6;YglD`?)0#(;pi6H#WgvnVkML3br3s#IQ?Y#7Rn`Yp!g8 zZaEXJZ_}%A#aP$B^MBuMD*tFFpr|l7eUL*Eb+7_qG9>4XxHC3=*dwdwg0eMKE+U%A zZZg|}Wn6plW8DTKAK4q9{`0p+ukFXO8!~>z&wu>b#2EEgU4P@}Ja)IW>QDZ=`u=qQ zlJBZlG-9CMzmK2zS3wqkmC3zu&lvV^4wwJ*G0^wSB>=#UTr?}kEc=B4R^sh_?7D0k#;6ui7Dq3wK3>ux$YH zr#B0Ce*wbOq?^r{s%g`iOmm8|3jAUupPg$WairMjtzlCvsJgMd0cb#VphP?OWrs4KFt;r-OJDjde1dtnJWaY7rGt4XEE{(eSm@kN02M=lm5fr_Ii4k~XFfHP4HaeF88L2%lq;S}W3ox^qRa;Unn&tA+iZbsxCkk5 zdE=Ej8*|K{*QBc15cgCPe}lFSO-iN&{os+J(O?-gh{?Rqr(@?yLq$uYIX`fSk#mYD zVDW2@T=!`{VG`5Oq4&#G%(voM*kosv^wOJMuOy%Q1RBi?5{Z(Trnm8K+7dMn9?FIH zemE|U>pzrw7Q37nt|zCGZq+ZqgI{V|uEz$F-~maIj|gB8eSC|KFOI?JG?}0_l^4wJ zg8Navl0@LKhj&hHF8h1io44!C$buy>I*8@-oZdhv_f4IRXUUG0NGVqnWf=pg0RF~U zGw;fAsw|D{&BCEcTT}fY?Z!dqFn=ojSH3SZK%Y0IuZ(@2QyqQ6;l_!i+@jwR4qz{K zp;RaaH82h!EIH}x_%6$TXn@%k=3U9zF>){ZLiqW9be>}l*wswE6Q|26BYXXRmfgOz zn&%R{bsQD^jf*FtmQt=;zSOk2pysqSeQ3thHv^;~Z)$@VtsG4~rUjbP2X705DN)Q{ z7C7h$nkQJ2ORpNRgQ0B*c0Efj&}KWKdq{4c_npueFu%jDuX-fX%#S|To$0^6>3OJ+ zVYYO8$%`1Z%cSRoS-KQ|HF1x;Mz0&^Xf1`jPMk)x7|KzZ7REJiq*71B^J?g+v_tF& z^0`*8?3+^2Mq0#Z)vQAmkIaWFzxvY0<2ds}C15uXm!(A7{w_6cj?|uEqda8jAeuchuNwfAc2%&F8 z&E!dB&Nu+8=-SxD0g%A-IqlQL7PKd*7UrSiA(}`L({H#`hU2V0jWA0v-)F_6nXxOHPLT12Auj z9jcNVh7$ZaTZ|R!eo9iMfQ`0zdH0w@z5nGV~q}*FI48p5B@= zl+Ps%36hS|8w&KU&wRs0S>HC4R-L>t<}Ln8DJ3Yn^J{s9*M-HCuOF>!6C~?`+;{jC zP@v)`1(xZg3{)nUJBCfI7FON09zUN|5})hfyWf>ss{2%0g5<3s&U(3wY}lt=_X8~0 zCN{ThLO7D3g?#k1c`<6*gIhU_+TFB8i*_rWE$j&igRBVhvpp}749wZ0&gNI9js;b; z>VaY}9QCiXVvP+Or3MdNgU%=41T*=H_=SMq+Hj`m-#)m>;kzj8rd@cC*n3;*iW$jF+E3(UCk2(Q|{Yspv9nW?abeuym|>qFxexwFbCgfSTA7!O_ejow@C zwlNJ!*Nd>kG9uCjJa`kt=viVYaTQY0K-qCA&$&i8KD;(&CNn8#Y-Qf`CR<_ zRu=JD;2zd!Hb2w0f7d=MvMW>v&-%gl)~J@z@&Lmbq2qABY+dW+$xJX=-5jG^!sU9@ zu%4C*J{3s(tA+@=u@kl<;0m!i4R~u?bGTi-(r#%*m9uV%;h3ggQl0q{Z<17UGm0)a zMJNLd+bVQMFU~zuvHt3+GVthgTf<3*>EiP-9Bl2?gYJp`hUlYefm1Umg>DJyuFlTV z2;^XFUyKAedsom9!y738+S_67;(K7M3;@z9*HZ#-VSQiA1rkEKegMErFcQBJ@hndN zt9R;X?bb0W_=Rizr-A38?0NUxQTH}`N>Fl`Oe!~eUqsKJUNYJ!t2(GR&>Q-~W+C-H zcFx{kuYFft2<`p8Lxi`fyk8%xl-SZEjzJgETTfjTzPx>d>!My4b&S*Giqhf0(0)vKg!ibucM!G*z79B8}dVCI5y74&x1%6wlRUQr%8;uf;xxLGAC4P>}xLOao&t}VNu7cm;IuT zKZd(ifg*=V=3)c4-rD7himeu?KCZ-5>peB>9IzYM_XHRtuWkEqzJngjWa=aKZ#>lS zU$W>ifrlpnhe|57%e5=UQwQp*y1pI3*H?V!hFypxp0NlIt$Q-I9ONbKFeTBN9})OY zN<7mO9{~$w4wd1c*Zk%+Vq>ac?GZo28)M6>mM`|LX_Y#GCc{TZXZ-`D-~rkQJYN(Nk?JxY~q^U>`cZ#PRst65y@xG8mvwzJtb#I#ib5%Sh95d&UlUgzzeU zc##lDP>{hF4EYx#0Dzd`DR;qNf!yd0K8sKF_ z44pNW)8RJUoC?Uat3m0Z^?Rm;D>CEu)^`6g=M-QgZyNvPk?$7IhdLSfTYX1nZ#$Z= zA~b=Pvv21W{DM(yd=eu%yjI`SRGR2hIG%y&GA7iAg%ttq15W6YlZJ%L zk}-DszO-yK>X?GePfBH)r_qgF;( zcu+pBhDj)TdH8t@0|~YQLs#ZUa>p~O_>e&%4gnQx?uhIsl5+w!%ZYU4lI5>Fm9Vn# zJOHZPq@t9`Gl=(a1eg>(T0TcTGChui&&PRkASZdM8~>bo!l0WS!T1(Pd&9Y}Z6dUg z((FV_U3s~DK)x;BC9n!2d8|aW8%FTB+d43wKwX+*s4PH2P}GhA6o=$JAd^_fV?Xq; zn9TU8DU}sTltXKIW?^Ql6|=VyburE>R5D8eyY$l+J>z_E9iK}dKBo?Jl(VWmPGqwX z)#c7;;Ygg*MY3tbv>3!<6+noKv9))k_i37I;_wg+pABcPz<9Z*3q!Id57V9DtZYdaclpYXtuhm z+(A8xLeFpD$s3GNT#gMXz_Y5H*>!7;nI(H>PG6Ehh5-uNx1iQg-_28r#{aT3zot%_ zU?K71PVlejn7w?pL)&sHdmuSCB(YR4F7X8_Sv?TpgJuw;+VY4~jhC0;Jle(jIYscFlY z;1mX$-r3c5K(zJxL&8CLt=S?l8Q;}UDJ9j+GxLdNUGYFNSSCKvS?Q;g+!xkd_Mjn}4_ZIp4KD=I+E5>x zSuHZ%{lrrIGa6#VQO+0_OC&h>RbBEUblsrWTpQXThIVf|6(pR6ij_DUDYL=XXT=+2 zNLhE*(@#)@tF3JPo| zAc4EeWVgW)R8@jZ4E^D7sq~<8s8zib;z~|T(Lip?;(RNC6LI~hv;rqy+fwyj)(-)i ze=8|}&_v2b;YO6KDYm*o4O{%aJk)&~(Tdg7LydGnKt8a+H^v&XqqaI^R=%>RKk1Eg z6rhxsN}G@Kxmf3o|4L4MQjYadyceIHB-NZaktVY7a@Evm1!|Fpm~9Ez#FSsZ`$H3j zmIzN!S8Z=c;uH08?Y>8pTQS4#$yeRV3oO%0)OWT$)HCZfAuYItfbCI{?apLG&-MKW zW5MO~DIh!Ts`^fm=0YEIgp3xlQdyTo@QM-E3msML?qi@TxEHoU?#;}znhX~TK+I?1 z?$+Fg0D^cf6EVZ63CR`Ap>YHP?UIA%*MGzW_8AP2nOLWF4vL>DJ>5G)F)`F$v<$mS zOG>>O2V$I^_G~XXtVt_OJ0M!*zp}!+av_0E#0(;KaNP!?ZHz;EKQ|coKO_}--D#MdUHW&YJy| zzJ!Tb^sJ!XHCmhUma`lfIp!n4m2uRwmTQAbFi+#X?v&C!r!FdK;pn*PvGmS&E)^*W znVgKmYTO@nbS>|)WlEQAr2Pr&$8E_(jy)&DaKTm!&=|uO7T_2FM$Wk1kdKb6Mb3%cJ5jshswUFL5rGLnn3#B8gv1Yn z6{pqS>*fJj%|F49z&yokpzDCy2vH8AY47mPKRp8&!2X1vflP)KpF2`SqVBZJ4Y_>V z$J-k#DzTqghnrk^1sqNP*L#Y`Dx|yjgj7FqUVF{@K7Pj zow!KnIab;^jk@DXk`ZvE`*lK&eC_uHeXT|W3!h*dXSpsI$wgANCWV~>5R-C^p{`iW zU?AAE6!{RvBULfXMGrD-OlK3mAPfjjZ;`Y6B&aRcu?<&1eyKs>Gpghw^VD7*YS7oNudFF#jOY(Xiprx4`ksPHMx6Q|RVaq6Tn-!9!}q~s zpHu>0JQTh%%*#Bx!8siKc2lH7Wc{rN+zKXWn#c0^fMl{QugJmDpG{#eW z>IfHY_*7T~yFS~rF)UofuopxYYtni#l^8LW2bO1w`M#K3TACurm%?yBlx3j8*OEpT zP~lhT%UN|!7Y{xI5^d|JIVk3ux-hsPec!Z-upV3T)bwLT=?r!_`kVdod8&OqMFskp zGx$N~xv%JtR81VRF>a{GWBRaF>1EuAj=cJ(u&%fU5zx{^$U{m_O-LfG*=PovOL9Pz zE5uY(idI~lO^*=3$=Re*{1M>Zy6b^b9({fB;-xbH-6p@T=+S>HT;l zI(N|AC9AhBiY=&PHv%~e2|Ltr<~MRWjevn3c8<#3`)g7}F2G^usj_MrxS|G=GB2&k z$X@^csI!^0C6>fsh(1Q+Wbg@<%in4-p>#)UO5*3qsBJTBG?usm<%}HeEE8p`jpD41 zH`wUJf(m98{>wkiQqGVD#|_(4-7mk4I{V+>x%=;!+fJ_Oup`_%GmGwfynQ=4RqmGQ z6a>mql7VBrci_E^nuI!RRfiMwE8o;-(CUdp{f)@c!b(ILShNz{Pc zqc?j(idY5HXabF!4_3~}2mRZQ?OQ(ZuQ@;oB;DM8h&ZhHAkJ#4D7EK9-(eB_a?scP zJl0Re}|8t1ktHooOyF2AH!RHbPrpsEn6Nj3)2n&_{tCnW_ zF@WHdG~t|X@(XpNHubPM4Yo&(*P5vqFTH3KOop@(9NwCrQ)l+IN7HDMH#n39XZQe$ zYxqpGodV$*ZnzMf@6qlETAdsV=T*_Wwxovn-EeE+dL?5_&3d=dFQWGWOE`V9mfW+m z>OdIZ>Y9YTgGe>JAVr75&+)#uMZEIqa6?^fShQ_IuSsay;>tw!`hPU$l(&;MXTm$# ze;(ZzGV^JiJjpUH8y+#WDGrr3P{(|kVgt0U;&{})HU#8)tnGguaX9C>-8KUOk?{SF?* z2Eq4=Lqi zncG}s8EVsHPwrPPjMVtk)%9PD!|pB{B1B)}xOLZep_!0$`7@!Ap-nNmACfp`OD#`DKq zD@w+iF8pF`-Y4oecruQl?!vnC?me&o))*(i8*OHpyWFSE7ISTX@e*rl{GQ_UPr&v7 z&|6h(4453=fSOt`3VOr(0(;&(Ccf?CmS}hAoOtZhqN1qu|F(33_N&l6*S{VC`wPr7 z5k{NxoH}Zvd5ZeyeVI^ve!q=X`61ap~_>iMJL$ zAQMA0_R!)K;5=MIGnB* zsQ|6+Qg$sf7#L=#Sj*Ka2H21dUZqZXEBILl)LE3+rdP-)p1Jv%8qw83;JWaOse`l(++KF@o%OB8{ z_mzd9!L5|do#CPhNU6`bPTwlRkys1Wc<8dke=o_P5xN$);0=T(f4wO2A1g*j z5>~q@OkHPEZww`jd-|gKYKyLe*;B*A1mMJ2$HgoeDt7PP(ap`6du-#Zvj7*Z@K zYM06nVrA#(tGlk$tetk>7-@JvN_aZ9=X&dysjDb>+1+1p2wE z!NFo0YkRt63J?_4&0iOdbWtx=P?ohNo)8Q8(;K3Sl~EI&A85TcEZbkyI$Hk)2MmxH zc}K5OV%QYg-N1fv(}D~ajB2@KO{nu`NQ5=a{f~|FFD=J~do@0D^2<;?iSX}Z#}qCN zePzB|v%4nX0mJ!-#-afDlk!32xG?Mn1opTkg#${gWg8i2+`xBu6)9EQa$ZR>j|lO( z-DM}n(g~>nYyYS?(`&SGP0RLM{_$Z^mNl@V(R=TC`0C@>VdDjEwmo(NCd&x{(mWB` z%39*gNu-tm^t|iQp~1gf1I^%_AYS64Sgru?cd!)ML;$*46C{`s3NS}zt@cQ z_MD^Sg^3+V#^r@hH`FC~wZUb);6>I_Pp?fL_SfSLTWW1q-xb{10*FudPYJn@!C`mK zTK}Z+TI=gS>0eF=(`sh@$j@umTu~RSRxYxnysPgPu)s`OZn-+OTFi^U3E)yc+E}_v zcAIr@!aV(5I=QutI&B`0stX^C>zQ{>Q}F&?=_31B><5qyhUL z5Lj-g8&Izg4XMAJLhtpw#iP#|7bMe$ z(2VK1uxbueYPW|Dz;yIyNM495ljm8rcvZM=e}{ivj3+)0)UJKZ({YfWj7lRm0CQ8! zv&VjxQ3Kjxa9PR2*O!!FmQ3;-E`se9sY6R_1~>mB8rk#SL9HI$3lh4KqI}PtlUi?b z=kz^eIJEF3t~(zx(3UZIIVL0-yI9wTuIOff|YI zoMfK2>$G)gUG$9LT}MQ~7Tg_ymfk>GcPW-f3J}dvD`XF7ER5&DN|6Lp0JTsMA0x&c z3cec2E{3Mrq5^acc?>T@4oMZKaGQ3xgB8*l z9MxxO8z%CnyXd&7yR4OZcVIi-ZJ%8!d2W`tqE?rawvSp%H@hlU;J*n>Cxp)X>Q}Co z=4`q%t2~d(VlS*MQ_x+vvMAR2my>`6jdJqvf?~-ri_HD`c<<_fGVWFV^vm$m z&(DxQvZ|BMUei*+6?1SuCoujK-(e<!6Ifx7M4er*YE>A~nhCi1gulwn;&U5w( zGk6s7P(zGfv97dvMvT*nSUua*%2K^=$$s3f`J2a~84lk!wu^BZ4Zh^Glu zt#CWLvt1Prkon-(Rr5I2|D4h=_?W|*_Dlg0KVWpnKc0l;Ofrfh0y)b36I2@% zWh}w38?_ffR{mUd`LTC*6;PG7ho3g1;b!^khSF_46kUAM#S>qs477NGv648kfVHY^ z;?1*52T|(GUik=x^_1;>Kk4Q{d3&i&8Um8}JU#wF67YW=*z61DeQk&Q%I2?-7x@Xo z;H0sNP^0%8P-6O^L2xM=sTziL5out*P8k%)8IvkB2%|wG z;MR2B(Xjut2ypzG1y!n82zMw&XS#vBVu6#WhJ5-iR(7F^5Z3U1ok*iZ;|*b|(1a7T z!J7JJtALD)XaQUP9(Tpb`ob_0$FKttp3I=_a0^EImk~%|m-+V~R5ejv=;qw_{hmP~ zJz6#*T~jvf+GYYpQUgR9bmB5k%YsGZs-oOa(5VQTmb_Gpu~Rs}Q}q~V$*dM2pE(a!{&71Q4ynFo>57Y&=A@DWc#pLnxRQiEX~tvz{R86ai|ic}akCuVV20&=0P2V0kX_(m&GB~i7NvCT(*8Slng3NWyfBomcoNGo(f1 z8cGWrjdRQ8aey_b`h^S-m+lJ}D$vh?MtlSB73x?MufEZgsYtYWQJdLz2)rq(&ex<8 zm+%VTBBN@+ZE4Un?tGwhy(b&nz)w;U_xP9rb{F7;=Lh?i8+jY!0^)_kY8Z^HyQl{( z5X6B-s9*p*uxuZ2Bk{i0W!{d5gfhi46|U%>O|xnRWB|Rz|Kh3H=|v$Eg^tZHOMcXP zH1mfa`PXjt_879C*uxL|lG^{e8>aB;a+&};OVMm`-FbqRHM-)Pb$7;6~_cid-5>JMudD{OnEn zXdJ*}i!D##v*M<3DPeD}T76cDZSoxb*nt)%^WXHBNaMSMZTu>RJGf}6b8VadRRlJp zHYKtD#SJe4&0-MawQ~A%c!~;m@+~3rEMJltygE!{bas`P4Z)=n+E>d!gScoWXzH58 zlc8h7pUzJu9N4%m%U6H{x}dWD{3_Yn)qIKZsESUkT3pL!;6ds401v zRA7xci7O#BL0^=CTJm(vfgbj|UO_X1eYxL*Xwgul9-qIO^rK#wgk*ZKpfzj0x0>B~ z{%}4W7=NONwQu~cEHgO)1!&g_n4{NID{?>G1gluDr(}S7`S1V$000000050Z%4wR0 zO9GqGN)cQ{T)kl<6^`+z4v>H?aLm25bq z7TSg934nLolPc&eqr`E&rqUihiv?q4Y4?ry#M8LdwVySU&2%-uZ9ref@i)a`DM7l6 zsZbQYXj6DoNG(VkNlbOfKDRL9`p2Za%Mk|Kp}gqmiG2ver)>5CZW|IAmS~D&tP%S% zEZK`hs36n2;uFP_BS#8F zkE61gMLi5LqorMVHWpckv9_ouPH&q(bMGqos1o_|tu9--8(y>~7)nAEYkW z4YCIXmkXm|06~qLJY_Qj&wI=T%2h6hobb;m6K4kBV|GV#S|_6+d1sDi>iRZbSr2Y) zdJ9sWf18l^=MMbg<#Ro{2c3IB7jdkg^tYwiUoQBPs3yxPQ`579N`)=JpQtIiY+>HpY0IFs{g9EIAsER+_on4bOT)Ijbr#KYGpb{*Z#gXW4B=9>Jc@^ z`GxE07&w3+qt7>woU6Ml^Uuq|EK2h0}}uKtqy@NjI2?L_J`CNJRBftRvJZU3t&=;&QF zC$2w)riI2(MwG1@fmp@uHBs^7$KaooPk!?RC@P=p(7f_7dIg5WNw)p@<&w`f3@|&h zZxFi;>C#gur=f;K%JL#(!&P+L$5_c&ajpZl6~5n>@M=Cpl5{xteKubVp(~Dw1W+GA zEMQ~pc|jEuq{00tTt*rrfpK*fK_ZSv^29!ShkjGXP+>W>|BY6GJFT9wlfaq~ zp}7AD&**z^td}=MtA<}PS%gj-f{{&5aAO|!*V&(oh6c(Z3$1HV@JfiV2$pl(KCmEw#P&&FNl&?Iu2Yff5j7pE4VH-e>^#~byKIi|g*Owu1otLsOMcYyulyGna{RT_L1H~j6o;Cf$9ZpIL+&bMxa+7s;+imfm| zLgjntW?Uf|^@3Tt>A&OYZ@)!bej9xiH@BP33H+~gr(K)Zx$sF{ocx4uqXS#|@gn0fZ$Z!bXAYRj5*}VU+WTqB2H`l*+F2Io-X+ z?tqB7vuY3i65-muZ>rXF=oF|h&n$fy--pQ=D^azFdB{1-yY8o@jK2H2N1=Io2zqat zBSCkh^YyARm5W{aeZPX`3V9WNYeO@e7-OQdr!nO|u?Wj-%lG@lXH#d+VY3ee*If}d zxGwlwP1E@^d=~NaZZQ8Dy#!AmmS4Y0LvpL#;n0i=Z+$re<*A`|D`cJ9w(KlXKT*gK zN(3yd4omQz?^&ws?GvZS6IPc_B;?RZ-hljayPF|Z4jp^%7XN`;^N!a@p?b+Rsffh(b(lXeM+31+`sl5C*~ zyi)pxLN#@Hxz4T>!m?%dr13+ieG>9RoRrxfW$erQuEToD0h;d$rmz!5&Y`)WgDQi zep}z7%47*K;I??+unYLcnZa%O-48=32A}o}{9A`F{@?BMd#X4uw0T@Z#Gn!ppeLbA zexNQUYAN@Eq$Yb%2lT40q>Vx(xPMz(yl#fT{G?u3)0?(eV>^zt zl!kGoBGw9!)7IJ|DoNa~dY`aKtSF5J&&z2+rPn!=AVY+v44mo30ZgD@OgtwZ$qVI5 z!FJfyN%1|P51>gY?_^9tPU$0$FZZcc%y(zQq=grx7fiHb5mW&WMrV-{PV0U+WjM?? zhYq85Nz~?D8}3~W-P*|g(={e1YEgC^KHepBEUTH^Z~u!2woKo4h!EC=bW9&Gd<)#_ zy@{QZLvA_erNX`BT7;yo%>A2ag1$ptn{8+avndrh2@)A`=MSJrbLr<#%9KHL856b; zFVIKI9>28GeIPqkr8PCr?~Q@-EHGT`2B1N0u%UuM;`F=ql?1geMFmBkg257%o3P9k-UBYL)g{YGwcjA1S!=l;I+97Wdh1xxe!04R? zj%ZBqnx8lC&iz5eAO<3B!OP3nRrXF7r|`sgNKVsF1-~+z(l4a`RoGhO63Hii!7+p= zK*9{X;S&q0Oe1FzMP5-?8*oVQ-h%S95`mlm@WH_`8F&l;kxB@5#sB~W zV{ihcTzF0vS(s(^U88IX*s@o3;%9~*Fx)tb4wawdztQ)_ zoHoHd2}>YDsD!g=0`T4udVOUlh+e5o&eik=fK&(yB~mazhz4;czV|*;BBej=Tp%_8 z003e;TY2{?H=a`0Rsa2O+uW&_zpLVvo5QAQwz?RiCx-6$K;b+pk_)GLGe!?+Mm4THCxZT0sxTP`j2tENdMFx)uk&hQl zWv!2L;MS=R{l$~IYJp^}jab|{yQ}_+=YoJOmU3oHR=6W3&NJts6P-iM#4&}F0*JLK zvZ^5k7ma>t1(C-EwFoAc2_zk5PCWgULD1QnD_qQ)D6D}qGn=3=W=(vo|LPTcbmvlZ zR*Y~68u`QWGHYy;r1!qg!SmbNkGx=$3bax~$oRB}8JI4ws{P!)^n%h@LPaHQW|qYy zl^30F`&>y|ETufI^aoH|j;As^ox%)_R7%3wH z$j2;cE6FQ)r-eqM;z4;>-0q6kb~~{hyCht%apsRd8mAY}l+N>D=uaz$JGbMK8TU~n z+4rbEFW{4m{32`}+^~u{t2oLic7`ITDZt)`Ar*4M0giI3mL`?`Ei{JruYnO&~%p}S_;(E zL;nd7V$aYb@*irrXponZ%=>y8HpY;3?HyVzE_0IiTn^7C8se%9?HW0%_H_>QwtA40 zdZ0+iVpmK2ECj!GX0ezNv=}Lxk3kXkU=2p?Mzo?it>*@T^-e_D;8=P7@}=IDzxvm; zCHI~~tnn>dKqFW=EA3vv%<~Hj`L)hyp?HsKQ#ql#Q`OMFtS)+(`FEODgO@wU9~nPQ z^bnnW&P?7~oEjIxWt;^g?PM%%1Zv@k9`_@}1GNos#p+80UOurn=vT6cTH-{i4% zkdMO$TsEGQ<~GMx)#j&#JA!oJQA4+~(_w*Wt|xasJuRCIZdoC6A628#1$;+NU9QlA zhxEIpDE=@1FU$@ILR`Pt_R4mv%bHWiiRdLoGH2flXEVhhayr!tla>7=_c7A$nX4G% zeO*bCpFiQ(vzjj;1l7-==?RJ~%`*DcuH2UKfEoFzF$k}YzHe0+$f^hpf%aOl@atWJ ztw~TRtToOwLw@ZI0SaQpM}XsYVQM4wc3U+LaRYD4)YZIE^)`D#L0v2utoX|1%jn5A zkQ{mX9(x+K;zRh|mzB?=3h?)jQI3N#UeP%plhu!In|9^_oh1N*2P5mU6&U6uWbQRx zB%A0-Z@#QnI>VTvRo~^S2VD02>wR%8+!u}}l=4fL@aLNbZ!aFv?5@{{!c<$j1q0n5 zgz0i`1|Bf`_+xcc5#4oQ1$rnUSPmXCd>oc|jKO?H8haYB8lE;mW_oqWU1_KX?gy_3 z!T3Iw&~PYT8!FeU=_mB!<;>XO!%>|BqS+Co&BI%+&9yqYP_<^dorTRscJUUy+3`Ad zot0mQhfsV@svHjQNr10uCH9@od$U10(GDT?v%UhF&DgblFk7G!j=#31naOhP?GzUL z`Hu{64T{=r_Q{j=gkfC8yvm?B`dVt{4Q*5F%&F5Ef4w)MwU7vYGUxw()?s8If$(_G zH1h449`4Wx>0KCJAm)8Lj$um-R&-+Hg9rd#DW3o>D8BIg4>Bvfj&|6Ngs>CwV z^9MBd{ZoLa3iRR8)bIp(*w*!e;L6W5w{*lnWh+~CXv{ZuSpj9ixuEb^Hu;@typ2qR z&qrqWqjX9RGnupj=6%w*vJ%fQISq`-){hhU?3ORe`*!2!(@ZUF#uKFh*TuGs3I*xo z-^;|aGLPdYfMwe1$1r(yRnqstb5Q7_un&LxPhELqpQi}5y`|uPP>=dZVh1-zeIUrl zn>a6QE7arEL;wCD6igU4#@UPTL?-7)f3@2heO#$uXUNw2xO^b*@$w8hf?FjcHsDfC zueT1FNztLST+AW!N#CwcfqJ2%$um&CV)2E*juXpqmci5KA$ouxSzElzr+L@D0znKU zmO|uYF9rVJ3x2ELi7N&@kjT{V)uU-i;0?xLpevDXOfct!Tj&ENwRdwld{E`h7V02+4sw#7G~r60L3_iT=_9U$2OqEA_|6 z+j09>jh&QYd%t%&+}J?W@tNT#IJOJ=(Z zUl6{CqPEo4i<82f!`5wuFZIuEDCdVxKE6*kDH4AJ`6j1Zy4%}%{p1e_76G>!s-7&X zYe&hLOf3SMU*m=MdXv6)sKKa;hs&yoflY295`Oz=Mwa#a>JB&bssB4%+w(Q=@4E+< zKpEQdP|MLrI8xkAr#pH`)JLbIy99i$|351Y(XF`udSI)hHFaY1s5WwD07AcPe7&~g z_uTVYuR-yl^+A{@dW*c1(2^r2Y}VkMSY52WY?%_jIyUOp1TI+p2F#@xhCXn$jOQiO zsqUDmaxs1lG|6rQ+~$-7F#I8FyCWtC+F;BIb$L>Wmz(#ID%GQ%$QdVy@qaw zHTiNRO7jsz0Jz2NAHhH0;Q8xXQLjJT!>%E{2HVjHuj#@P^t)H^e!bFzuPPPMN{pc; zBUp`7iDC@*tUSi7ZWlgU9V7Xy zqGO_S`glv15I#quGl|m}7U09%qNHj!qXKZMCI`9yq1sgq0Xlry+<#vWb1|>HTT!&q zw<}Qj03%|iJs+-HAtZamC?Zo9tNaTpIkE0R`|FB>#@ptCXjf+hDuhwgXj?;;ZO&SA zk3~vlhSgZi^7Y7V0XA~G73b2HK>8x;ni1!~Rzer24eVG5Ohbgs zH93DCd(}N6j$U^wV>4$x*LtGQ&y2*(#>R2KgK7YA_Gy_fyv;Xa!MYd__0}lUL9@~d zT5dLv&nJUvY3%HZew2jk9uTft9}pqXvTN{y;{9G;d4rm9w&~|R=ddpww)WCIJaJ{R zBA_BGs^7`8%?;ixNaf@BbHt*Y)jf?6Bs+JM5;;7@E z8qnVF@o!1Y<>*IJ`uwm&4`mW(Q4r1yO+MMDuyH4P0(>hPk$0{W4DLz?^iwNO(mj>% zEf(xQ%_G8NwYM1DYWT<|N)UWK1cfS{tU!N!_D{dqqTwC5Q0k2oWWn;B=7kU>lO{m{ z1n5ID=KA5^-KT_$!)}4p5;x|{J>$*1xK!WiTw6^aw&(h__Re*$bH7q)Tj|^-t*x3F zvh79o^Re$wR?{&!M)u9u$=RoS9bVK!;FGi|7I|DeEUoahIuH~w@Me=cW2=w& zngX54O05OtgV+b|w}^ALCP+RPw*u$Z|7h4i$fk`wBCX2;1>+@7TTc|GZH4>9-X8$) z!ND;ZdfP)aH5P#l8BcKORBj@VuJeF&Ncmo6wtIgrLap#iBp3e2X7|H{rWRq3D{xWm zC8m!d=jJPP=9VtA5lF(5O5G-k=Rb^6CE}7R_>Tpem|?G*yj>z^TI{=#dn)`J#2%Y< zceyc++6wU)BVr9A5ZJ}Am*3$)nzyxxPFhKaEV!MZB9;jTOb9xmoI)V8{;W#*@qwaJoWtjp z(Cn@vvOxZo&bN?ej@@w~hqwT1DrLuRGk32-UpvvD$vYCE=9gL_g8gQ=yL2HuF6TjQ%vsyi2UW^cyDKCa*#$X6>~D$n2~VPS2)(37u5 zKA&p1e@l{}fGI~k2LWep_&$R+MEHi{@~9OBYVfXFWuz`)IVTJ`o`rd1lxM-h&Xggu z=;+Q-s1Sc$axq)I89&1&VKRdAR>G0?b_O1TN^XfEyTUZp2Ejv#o)ej}d`eE(V594N z!97sLO=npW8EhdES-@oTPzsGO@)F07zI?jHc}~C37_4Y^?CrCBDE5w3NG@`#A?@c) zWAwm+JpQLvNIgqX&oDjDvmg4=2`HvWWAP^X5-^D{(9_b>#4R-n))zi2ED})X)yS0o z%D^p71#DnwFMh|o0PWQ7Eqr*}%6>mYQ3+0p`?`$LBe8rZH7vmaXLOpx$G$;I-3Ww< zT_*+UXQiEW-|WqHum611nM;J3PW=O;8jbRu+5G!fFZBzl1!_N2niM|%$b@pbU};iJ z<3NO=c_A;eyeexuTzW8blpfciGj=R?rwlMGeI9$i;tk8rs*xW=1snN1kO$gMP#Y^q zk5ODlkKx)?67IM6v`cfjrtD+Hl7u}$W>zFYK zG(}|G*!I@1OzTuj4$_}Ln-82ln)C)kY-g-!pgb60{c>!f0SI?;+FRBhB=QNhK(K75 zMR60SvS8W1f#8FvHKj1nihjP<#Ynj1JuMQ!39_XHiF2r|kn=2hdc4%^;#$>ffYdwC zSgclLb%}L_hzYb^tyDY~*Z|+ceUp;ogbErlp$GfAl9^MQ6b10>59uC-zPxwz&2<0Y z2h$dOh0U$eK%Wc!Jsv_qp#kj_VNv;YA2erLka({Y_DX7eF{j$@R)S{7N0zi&>_k8w8u z|HJL2Q0o9=*C^IjIe$v*RyGN7JgXZrk3NvF3vEMTBbADGp$e{q{SWpA^s}f((<|iCl5a z$x#*Z5x-sOqCkB6?0Pka83A6V$PHkz!2}7{8KR{#Re_=6&d5_3l8hNsB`|)xm~3AC zHB++qvp*Ny|AL#zaD=Ce?nkbI=kA=Dst8`EFQebTMMeGPN}9?C?>aAQ*nDwwWp^RON2zH$MbbFkaVXTar9e3MPvv?v-XYZ7X!ZgldANlPR_ zlkFHtyV$avybAMJLo%*Ll-^-XD(-Il>(vPoqXnUgXA3^hQCG)a;S0aUpfBEl+xQhi z(!xr~8o?mB^zJQl;LpUlAI<&ewQ?JP005ChB*~T+7XOr16F(V4S~svrjG0SYV|a_u zkfIN($HCd2xe-XHtzHy^Q0L`$v$r{{i*fu&Lf&4b+dIhsS`z$wEg*#(C;$Kej3592 z05mWF001Lk1Iv3y9>qlO>FROE7qQUfd0hpqCzl|NrFo|}iMv2{v<9);8JrnK#@%aTglc4sMa0;exCt zsy~4tmGvuu8Pw5ehja~1dRtiC=5@EAl8?D>+Bjj}kxLmTa#FY#Cm;eG5-oepfda%>sRV#`Y z>@N+~mCU9_`#TjQo(-+pa6KLppuNHX1)!RdQdH(kmp27=!j^yJ4a^RqUT-Er4OpD* zqZxsCiGqsi>dn3qSyR0g185J90z{yiJ0h_mj9*%_G*ydc2AShq7V5Tp$SU#m60%%$94S-|jTfbbKyGq(3x&=Pj(_^A_VK zL2c=_td207wuRM&zU+b&xH>&E=S zChQxA0B}H$zc!C=Xck*=eDHizWPoa8JLcW0ZyocV?>Dq3Y2eSpLTm#2EGDL-(OcwH zZCDMhItHE3lnew&%@fkg6e&>nfwZ;r`b4rq=qREVR2x@E-SFc=hXy1xUN>l0^b*lQ zGvqrh%J;6DTcj*}2`UawaF`g?qls0vx6gU-nct63jQj$7Bxycdu3Dt+Su9eseB7$0 zww=0Sce1Nu2(KkLw$7*u$hTz{aBiJksb+p`^x7z&G@yl&uhk$rjUA zIHCuTm&$O2OKoDC8^HtDNDKgqRczRR{?(n7Bf>15t9ORv@qKQue9>TG0 zw=Q*oP5+TS-1i|pV0a}lkNnQ*gh!}2Cuae7CAhGOx_cPh)#Xo4?$4l@&W(gS45Wu) z>+Qs7F6VfKJCgf8IJuJ-n`1fFK;=;0$OeT`1Knj2uYams`8c zP>{7f8u!L!QN6E{hj4$3OKnMCNs81+6FW`Z8gkneM*CypRTB%DV+_Mq(l&I67l!g^%MsC2_7hGSCVPl9&C_TT$by|IA^m)fKr0c*mp5 zdiw+M4Cmo**vtd_UwQDX^EtQ^(Frafb+l&)z7X#g9FCQV47!eNI}X_PDOYe0+nRoQ zeYY25Gxrq0qRbA>6|#2bngc5GNV_~E6Xk~khqqa{+##HW9>gvzJDo0anz5@V+D%fO z5Sm4!B|o)Xl{3Wm016$EdJ{c>ti2w`|Nzc1W3lW%@^LV4)^Kb6cEgXhoo92BU@#CzaGII=kZt z!$f8s(A_tOQ$20{7Lj({r!Njj`mJZqzC}%*Yr9`=Y2=X z9tUI^;W!`$s2qqVYJ%GVk3pCak@U zpDHTcu_ZF=#{6xm2s#!!e=OGO^)lxK#8rR}pH)zH_om|)%ejrN@j`{Q0=! z^6&N$M~_8O5LY1OiB7hXWrdp#1&d^bxF^-ZI2* zxoOkoL@`VO>TBv3Xh>=_L0Gt4#~3kmX_KLZG8Ja~x_65=BowDF|Jvpni4a@m=&8Zp zk@gC>g!2HC1rrA1494SMWjIE%5sql+3VE%K^x+1r2XZb%`XGkclqUg%-;A#C2 z*4e$2=GA57|4O|sY=%)0KqW>9vNsXkc!3Kgb{->FfJ_F7%K}!>gxv7_ zVij}dKJEGd)6L=R0O{CQvoQ&I4qMQe^0kpIhtk4!|M zxaAEOp&d^(3GbNGj{K~8n@jkc+|OJsac9es9UGtGBK@G*Fc_-|dnhvw^)OArm-WP# zLm*H!J!L9wQ27Cb<9=Gn>Vq6pc1?}<{sNe_AMCqVP{t=D`6WYF7y`!HPHEPRDfv-k}83no6duHU7 z1?gXnl#xx3yBM~IqSpuaoXs84IoCnrl)=nXAqt4z+B09b8dQ<6t}_0_Vszw}Wg^Rbw}w zOKs$Bv!tU~`bFK?pY(K{6}i3I#ERCI{wGZ60UE!*w24!@(wOt1Q(9`gW*~94f!N~ss>t<(- zA=;aKyp-5uaMTXT5%I+!$a^aN7M8EfP<6HGwCml~nES#oi!R|$F-qT6c-`|!jxmw( zt6lK%bNf#)4=o-|gQt3~1H;(J8-5AHzu8Ew=eYh~<;Zwkj(QuCDXTO}&LuiLx2)Lm zK~tj!p^uRGjWFT^AuCoqOQm(eNfn`%P_VqUg-h>K>?~{QzJFP z%%R{9f#1b2x;J#2m7%(7ds_}Jrd3QIJ35FWS_QF59z4sXZe^kC0t!Luy@7l8^-QkU z{+*33OO1MdSA$78DmD^O7wZiYMC3@2_IT_1`wq+}Y>nctUr@szo^@Br@>6_As-oV! zcaFIC_fTMM*pxZV7U9?r+Q?8I4;@|b=BwttI%%uO`+2V+(2S~vL-NF%yv04m{rU^7 z-41|{DjWX~_Yy&40jRQ1qGmg5ZSi)h8_fT887QsSL zdrDedmHOvCpLNB!gb>pY->INo$J?3F;S}7pb_q`ib-yG6!~L39OeRQ3$UZ;Xo9uQx zXc}$5bP4eY8W^6vXAiS_X-=Ber*XW!iF;R40A%(kd{KnHu&8K|e_z~A|L~n=3WiDT zwr|KcG3U}&m3HL{BGPA?|NW7}v43tVZGT_Vcgz(kIJB+f^!{{=Gri>DhKRCrDLuBP z%e3VNZ$HkBfVm&=HzjXxOuJ&TdU0C}jZLry?uiQxXBMyl1iniYlxLw;b=YoWwp(4e zS1)zt^y^1;-JN>qdXSyBeNTaZIEU&`Q*40ylS^tG#wXA$?-gY#G_6ZO{_yq|BaSwR ze-#C9^FsaDH#fUL-i1*>I0|tG^CIcAJvY%Biwro?NXV65cg3T#KRL3v{a}BNS7941YyIKqcl*l;}nU5KEAv{P8u$NliMZHKb!_cm`hUbs1>pnuw zhe8PT8m(ts?&}ZJI-3?#rV6)9k_wt7ddt%%N4=ivNfhPO$~AUJkB$$7W$$Be}l)ljjqDDU##o zu0qS^e+>D=&4}aHR0m8X-4MeI7uNS+74|9@mSJ?wD31xv`^6+Q&k}KKGHBA5l zf&k!yv=1kGn*ep8+dub*u&t7?k2ig|KYia?Ogji_SBr|fC(2wnHo00**LJOANi0x1 zc3F~20~_7NKS8cC88rWiV#$}^p2k3*)>kmZg6<7o8Z{y_RF2o*SzZ4|Bz#Cd!)dOD zxHw{z-JM%D1nhh61c+%EIhq2GfRmZTpY{ z^*wiTsg~&!DKx_O@2HFk51!7R!$7^cKh9Z-*^mNw|p{|4h$^gf?z3ETs?2Vs|f)}uB<$?uG2CUy)kJXY;# z{ju|96E1;qKX$2%fh7pu`O!ZI^j|!5F%jPYa4t)$!AQrNz;f;-Dh*|{@SlJ5#61yk zs9oSuObwhu`NgUQ!r}(6wWkp3k;v_A0E@{(+2{UgqD6Mt#muZkx>8JFQCC@_0z@_j zDbTZm9EQE8(BqyqcvDB0cu`3;FagjAEPE(9d!`kE*moM4gTVC$6iGYAJ=dP7l>YE& z($~6NU z$`MR#wIH^t*qJlX2umm{MfMb;x#QI*+x*jvn(E*>^sDc`R8+lJ&`gL@6+j3 zVow;-eu{BIk>UuoI{0mC=*n2sy2~x2jR0x{@!mVo{*1w7z%{*QU z=_wx&pP7F(U-7C$e^$;*wquiD%Gmm|n7vY5sW)GLgDM6kRNor(ng6`4P%9}7I>-`# zzY;=zD263Ub^}a+taYEsQhLl~sPoGMXAAhEqL;wBOZ{NH$hnBgFkj<^KHFJma1{+( zwAlcVe*lsZDn+%nVKsD9O7u1LFB2`Ox;HZxgaFTstx*L>&qhO{|>H^y^hp{QMlwKd2wo29Pw z^3ba}`ejiX+bBo8{FqdpRf~7*wZ?&$f}-J;d#^uOcp41Y8~^>34kx-y$c6)mZ1Ftnmf=4U1-AKYCKRt|rKdiRqA`7@Rcxcp! z%}}iDsGIrc@c=l5U~1wbnHWHN)R7VojDT)q|;r=FXjLJu4I(bZ`k%3aXE`RxvzkHrO2CCC2R9eNri>;UFAxREWS%4 zhLk$Hp)larB+nqWMcGL1^?5o)VjX|k;r9i}@N{J~O zfPxy~e7D63SP@DV>D2k&nWask5DFXLLAHAz;=A>Wlh;Hos&b~YWY|(|x;-$k!NH5z z*m>~~#;IIj;zsQFIydnU`VxP8WfJzS?R=CwgZzD5AjW`V9eWRJHkByrjhr}u<#s?4 zM4zVt^iZ>sq4VgeGa}sJg>_iwZgs{yD+3ymla>z+C+Fjfnru)0*d0FQaALfuh2&b# zI#%*VKNcuN5e6P7!FE8s_Ty#tZofz!ZEo=}fH) z$}~djjBq3Ko>(^)vebCp3R$~x;Gj811J3xfmfU*StT(CPR^5`2eM-%T2_Z9>Nk$Hc zq>`L;D)OVo9NtT;`M_t;q)c*V%<}_b!<6p^bCkT*y(9n$K1@*5?Aq3#008H3$tSb| z`Ae0icB2t59-6hzUyv=i2}Sgg**wiepO1E+mjek*qBBhZeN=urt#ItET0V0P;k1di zAj~CJ20HO=98pA*x?)w>kf-wZp3W+nmP8}qh5YN<>J`-y|BnA>OkfH?cPZS?Mp>+K zI?!8!op`kjo!w8NzVh7^w=smYP{8%Y#M4o@34`wu_mezg(P}mhHb+BCPxXWZ;Tb7} zaU_2o3=ITwD`U;3hJ<#jLA(4PD%k%54|W#)m4~{<%Gtk;nbU#yf#YL66|^7JHkkYj z9N>i`JI%jV-e+)kRz<^(Fb}ZoD2CwO@YxXy9^Q;eU;L^8QG0Syx;dK&tnz6Oc>MtU zghrT2U_M*$xusva(+0K%G{%;omu7!<*n|hN06d{AKMfW%^72dV)6$$lbr?<6-o=DJ z8ldY^PSHIC78Dn?_r>51@-b1pOMI=8=SL%G=eOc!7Q5dSd91xok-`xuj#WVyg4EIx zv*m|{`N1(hCU&gcZ)PAAE0&rXR_}R6!JrhRz!y!Q!JBRw5OP8p_l0@N07qOsBRq94 ztJ)c(<}QPqMB6Hkiq=n_ETee`iK}ND)kqq7)$vu*E@#R78b5#Yv0ARspmMeE_MTGD zl5z@M0lh^lXcPXF00}>OoWsACoSa-l*8&l1EYW8bzzkgNr{j zkJrOwlQNkuXY5~wIJS@~xlG2rrpt=uIa1SUT5C9_-2&!CIW!6cR8($-$5;Q{6tuz# z5+CJeF~bh2vaC=t0-m$^ScuNElE-t@4Sm1EHh&75S9ZdP9O^lTa}3Y@j5=V+DVy^y z>?4qz&4u(I!}u6N){=JX?Yr*8(`WkNi9Pjt-vdm=fAheLMfyMP?b=NF8mRfzk0A0L ziYb3FT+tz-CaRf&c$ZhE`{D;l;-b{~Ez&?A5I7NLdpk0RI{C(PAp4$u0-0mBFA+8qe0{>ipTPiJkI&*c z_MSr&smG<+8*|F~YXL-=`?xl~opNS;1m=B6A4Q3k_vB}1)05B|v8Y%N3F8m_q`JsU1evopC*2gq7~>Z^w_Xn!qxVwE=${ z#Mk|(f1{bAkd;j$;AmQuMU&*bD}HZ;E7BJBg>T^n%0^5#SiHj`jbj0^DcvXd+S))U zXhDp-P3|{D$2$3f^F_Sz@%x52+Q?jo;KmR~Pg1pr7K^&etjYJT=-otk$YMZ-Pt=KX z*q#oBErG6;gAA+a?Xb`?`-}v>-oNh^-;DB{V`}E*a%uy#Hi;IbZ)B0Ka%seq7`gCF#>j%;ccLY%1IKfrAfqp zS&tW7pAZADP%ZD4AGtuX^Fa!l%3LGiVU+<2ts;6$a=4fXBNp#d zjc5v05>~s70x!Pc6zU#gXT=^gY5s`kAbi+;{B;p%3PoUcZN=tfRpYpw`^?p_O~!PX zM$LI^O-I4MNVwv8EHV*rS)hV0!DB*re4nE9P-j~=teJ=eyJ2;eW%jH3EOGN11rHQ} zzFguj<48%NtFt`&0c}PWQ_n0@-CC0yymWO-Ld|YH=L2MvZSr>DalBMyyCB|$+_pF0TVa8`L+w?I;Kgvv zoSw@I1$A96JX1e`M$2T*535nHW|vSa%pMBJ*lf^9U0_?VRlyAO0rotf6c(Y$ug2V8hv123DkE$TQ!G(XC8D1#~$;to$r&!{s z@o0b7ywc;moG7OP!oXVJ>=$*Qd$VA<_p`C8e6mZF-RLNkK6y+@?XTHfIBl2nD9|!8!-?(avVV-SZQp-r6^(};| zx!Ib$Sv!)fqv55-U2m$jE(eLej!HIUxg`t(z;@4eYLTsJYP z5an$QzWd26IVxkPTyZU@~skYf5 zmhnGhRvKRD=>>Qfy*J|fFs5U*@j6~Vv~#F4r$Wi3v&#`d6dMQ?4s5Ss{%Rme3e&VA zUsw`*b`21iUkfF|_rL4si~u0{$D<|)^L`LAVJYD#@C0HEgPF+NSlyA_7!Q}f3^&xN z?lHJ?zN3~5>&x*4;u6sA^w{moBRkhWT%(x+&)M^CT8v>OXjAY_m5A&mP@EWk2HF#S zB_$|piae>m@`{^L&@_jsOGF23Jn7d}C$Kxd(nF(bn%SSARYX#Q42ZJBUk8;aCfjVM zPPoQ7dlk23Yhdqyb&Wzs!W6r~z|*nz51Y*I#Aw`FC^G}!Wh=o`(jiY=i=U$5$#%{n znmK&(HH%NEcM0E%rQS3MK^5}QgLH(oX!va}&s78LWqIY><)xbXbJp5oHXCK@fv+jw ztBR3dW17mIo8ERXl3qF#um=zFh_DfuX6+E*;y>g1EIPYNj;0giINA zhrxH{xbj9pR;=a;s3-jQ-RTJ01vvZjz{1=0wa%~zF9AxW_DJ$5Rh_XpdU~^6+Hy{? zDc%481T4vg)oxpC21$S~P{%g2R%F35v>M-XL;#HyKA0FtQ{XS>92<**WY}$-#Q|fr z=j?pa1Jx4z1+zjtg4D7;?mdlVwaFN}a2MPC{%fPL#hHZ2QuB|gOsj7H45X}%e?SQ3 zTWV1zM8pulm@2?PflxjJSWwW!dg}4j9@*hwrIDku)!nb*OlSETQwhC zn}sr3!roKaf{G@4b^o923n52JGA>BOdr6EoA`knRjjB2%S4*-^af7$y@4DR}!=(jD zp51(!y@BK+#`1)7$;Cx+mL9ey!@jBZP9THcdmR5Yvz51|JcVB}N2a%Pj9IU$tlfRM zF|YWFCu8UQ9$Z!>wCq^;s?N@!PJX9xVbB*7p2-VcE0PF?3*CSKb+kY0F3Z8Q2#aYH zUlt9E+?tZ}A&RZ(Ji|{T1_xdtiw$J;{H-M;TT6bw9m+0x4BQgmf_*IN`~Yvq7CrSc zN1cW^wFRwb@r2oKXRXeHaCCQ_acG>1v?|`KAVFyBNwymOKskZJwUI3ez}&kVV#A)7 zydL2vn^Yz9%A3FPJ2uFnByn_yK_q~ZYgKK(a)<&Bgqsu7X`Z1n()^3sx>L?nz>8hu z0GV%&xXeTN7OH^#;GKQ;mG+VG#qm z_HVW%@YfQ%X&3vMC5HS;QYdt!*3wG0aXcXoXkKZz&JA+o5`F?a_X0josdNaW9PY$y z7`Hpj)}+W7T1tpkCYJ^e1@}=PsW7U@bnjlHS~<>?i6o5p7oL2p?@HWQ7aHyOpaKjA zA+cwUhqV_6`|N`s?bYovVB0GXW&`qexp+)bAm1x6{NJ2n2$F$yo-!1Qt`+!usk(j* z)sb*U98&h2Ydm)_kIgS=tGV$G709|NNm(3%WO$Y#l*xj@JGBOAA4!~>bCDY4ys zz&MuM+Y0~y0*0UcLjZIS+$&5`{ChYS9^U^0gvIbB3O|qhg;roBIW&FfLc<0n@GK%A zF`%C^zW}d6W8>-is}%A02CFj#%_8pmSF{#*o;R@R?twLsg^+tddcVDWh#ZiSwvw}# z%kYoqo@^O3%0O2=Cf1M$w!)%Mu=G9LCY2)0p}wfhv?5JXc_+>(Kzy^5X%NJer?rrx zVIq1W6a40##BOJRhfb}woI05%I^!JPRM~V+Pv5vTTRoV2-t3Ye?~66~Ew$>=UO&W7 zRjH!D00000GJ-mR?H!ww@6C?NoR0*(;fln?O`8XGf((BHiv{)eCeRNEm{8^t36f)I zO9JeP3%e(`GCg}x$$zhtYqthJHih5L7!>_#i?iT=RDyqeUQ-tK>Dk~2L15bnpnHmyY{nx=)~gF8g#FjHS8>-*z6e@{#78s{z|e8Z!D>dGY?q zA*$wR069PyAP~;5JYO{SqE2@$ijB5Z@R z_i-)2ov_ozn>BuSrN3~L?S)%bMbl8DcGRU@q&dT3t!@LHL31Se8lib6It~jab;ps& z!-=rZ25lvy=~gc({PmCOemJU%b*W7k#c1HIuY}_h5~bS2aX6jbWdkn%sp&{#MD~-j zCEC!jpq2gysCEh66)A3mb2L;27gq^bRvd*M#4ap547ly-Lreui87R=JW|RQFJPIke zX@ER%2m={C#HLn=*+CB**W9$6RayOUMbqgmUf%H51_!=SA9)UFs>@1il%w`9nQ+HE zY3`GzgM|Z1Ca4t&fUC4&=NIpmqL4_X@uMjBn4^tVDTF_^9?|_3x4M@_dj$H756Zcm zY*DC%hYht2!I!5>Zq9xr8K;OlEOe$LuxRH!XR56&4f~b%qXJ{F97(MQG%iRZS2cim z>62(es!+*H49mChg(mwfgPfOUTAn5^*+kFEzK3KnNcIR4%vREx!aM^vNdLH6GVJ|t z)gQ=MDG!d;5c2M-81~K_BaLtE4SsI14Kwy~tLhx-6HK+LhnIY!+^;f5jy*)OM9*MV z@i98PRH^TyK_iJZ>D}pi35-+#Au?|_e-a*C-{mIj96-%2qRThkM)V(K>a(A0KV;9ZaXquG80n+ zE*H)i4gH4?Md-sSATfK4FFnaXBTy6QMX}qs;$*e7hHR zZ)!Fqvl|oq!P8t&o9RdLjv zX^r(f7^g`K4vYU>TxBZryQ#(362Ft;pb{lE(>?f#LlN)%$=U#bvwveJZX0;13===Kfs z#07J4aul@YPE%^b3;XLjh@0UzensR5yRM$RvWQWu8zlI(BL5O{o0h0k-hV=XMDZk{ z@u`<)nsz!qp7t4~jGCMXmFi>0vW7ZwN{MACoqxMc#Mfh^eRAG*K&NsbMr{YFE3if6 zRg~d*%axI{8dtB7VX z*TTfsvcz++{B=G-0A7SP=sRRa57LS$xhEBd^$QZ=;D$9fxgTeSEZ@q@EMv0h+K{IZ z4WHtpx|i(GT-AR!rp)~ii<|ZhuR{mw1_IB!q>=GqJdfX-{`kl#^pGk)>ZrXn&C(O? z?HMJJJ!t8|iXh*!41sU?w#BvvBTkT^xj0~lz#No;1^AWbvfmB-{O1xu)|Af_eNCRy zShrv~hoMXl?*?i<(I`9j7JS)b5}MEZ5yBuYfB*nOyp7jp7}An2<7KObC~}B{V2xo) zWrz&r`{1hqq|!JYol1ZJwn^@YODL#RDOds*?m=vS48>de1uN|{Dk&Yg(Y8IK_c|-f zZ_=fl0Efw<9Bs0Ifh}`IQbdzDSS(PV3j8LX<*4lWc2$T6YHLJ=OvE2n7Z;OHnseaB zd&+9<5krRFEu&Ua-p()JI<+RgN+wW6J2z}BYP!!FM3EHg_xNfzyLpfOr)`(4ITOg{ zL*287$n`GBroO|^`Ad?LIuq>YjFKO2(+U?R;M#PIe%nBNs?IfDNpaI_x0EWaAG}yW zxMcsKd9!Ee@n+}0+UDMUq+8B19tm=)JBXfJ;6bTX$qhAm&@^1~S@#pz841(Vdgy$W zZ3TaQtCFQG(o1m^Is|oz>Io5)<6^7jUJ3|ynKH}HP{_s#-@v`TLeI=X28`(r3mwM= zBgW+>&0E~n$Xns;S>Mya{SObH#03k--B`&Cle^3!CU9xDOlCoM>mgbr+H3v5ih7{+ zrKW5fjcbG<^nm25&BP3l*1)7O-th$$KgK0ijQ$@UXGub5b$X5w8prHky-2mX?pQ>? z-hPB25StsRTA}D^+mTf%Ubbd~Ly|b$zvD^3^dM2Lc&bn6Uq2&c;Q9+k$^AXMUIqws zVkN6wvMpRH^W(fDG7%Y4Uv_0m917QIRQs;>C%2-~=2bUDtrn_}K*apZ2${4Ib{Ie) z-51U|Y1KZDk(iGyl+yon(z6Ef6oq4B)=eo9qiYV`OSKo>MUL5M6SMbFx5#3p*1WOS7Oqz z1c_YkTi!P1!rvwpBIk61emNAY6n+?Gqh+W@fPxV3gFGR=Iz;)0T1)1XMpUED#ngvW zCQ`XIBO#98+F%oJNb<6JB1*A0kX%+pEVt3!}IuqiNbDq64qvr zr9l8OG67DQT@4p0gfN9HG3SfQ-2+nU4O?&38((#beKgi6a`OV?f-I~}LD^8wgJ9rH zf83tL7LD9s9y(lqI|X7i3lqj!<8oxcze*%0;3bnF(;R&Gn#8+JjmAL6Rsq$sC_L|c z*+xq5%uX8?oLr)T+d3%&;*iO2yJ@q3Pha4Sp9c%jW@?71`wxqSwOZKbi_Oww!$ic3 z;?<~MEDhz&55-uQ6CRO=I#_Vr?j117e7kxL){F+mK#uSq(1dF2*Yn5ptQYkGX2`3Xbj6}dJtZ-wcQeU4VMV-0=Awg2H4Y{9V#RP z0r@qiMt0NRT6|w^Uwy;cqqRNI9jSeVzrdA?HHU*N{Xc|_BH{4RBA&73DF1KtfvHVU z>n^J6zj0#1G}fkAw>^s^j6`h?HBIS7y`ZJXX7MG~Cn)fyd4(6PrIDm2Q}blCBvaFZ z`JA$F>U+CGremg*#dyR=At?k$yPd?|mbC`ecB@m;d|Wp!oV-Bd(Z&yKZUWvW)$K>` zOZc{CzkCXV-VRhkCKAcIR+i`ui2(}00IH$133WIUtlQQ0G-moaFkE;CgG5|pjg%NR zW7yS0Sbnx|szENC3^ImM=;1kjrBTgTo0W*xJ9F}Y zhh9bdO~j|QX}{COA-zz`YF4l_JXQi@~n< z)d<<;zwX)<{&Pk`6TJYW!v_XTdXdO zRb>sDrNgloqF5c5iUsN9m-r-6%i;*xx`a&CHb26xoP`H@tf2`q^3$o(>Mk z`iMT77?x4e=jt90b2|k_R0)(A75rhL6oD#+2{j)f(^TSnzK)cO)BUyF_aO@NoyV*T zA!I89OSUdju-AZ^f5XEMDR$Yg1&OGplDHIj_0=bI`kIbn2yJ^=7Pyw@E&7=z+nSLY zTM|BNEb!oFVHq8%0P>mxh2HFP#xVa)eb*gvB(%=lHMVh)6rX580_@?kfDu3_zuyE& zPsUH0o_EA?picXz0xlFmCJ>g8L+?8=$KxdV0w>_|)ipohjTMBq8s1@1&T!i_xPyEx zVl5F2Dkr&_>eez^hAl`LiXDB zsravPpH_EA1SVLHtkD1%I)Ms08C6WYg^MP3z;4cwp5{8bb zN<&W;xOyKslsE?w?49`y*gL*I>2;9$yS=DA0bnl;CMb&>orV?ict!V*UNd|J%@(x}gih{R8zMgQDf-8c!dL0s+`2}t+#LR0-+ToWu9 z`^i-}aBPQdR)!Y=6}d0z?V-M950%Rr9HbF=r-|ACmp!ja9x`MD!}~@d$qd6Xc6ZRU zXF%7rhhfJQ;u!C0-!BJYWGX)Rwu0Xu|IC9#nGS$>n4Qg<&@Jy6;wwdaEqGHyxFJ%OQhW2mJ}3!<)!V&FR3i-dIp?+&o8#LaQSzia?0YYp9sZu*O|2J1 zZ%Ab5RCf%Z`4tmy<_4qTGIYW-!d#)J4C8l@TdF&00pr(EwBwd%iut~nTw0nU$d`!P zFV$65o&IRMLQxkA7IXxp%gr?YJa3doiU+Wf@jZUtGL*Xl8 z;DY~s_}s6`lvii)rhjN`l_>!MqSB* z+^JPa37UfJEwdZlwn}17p-l*&yv!3Ug0yFk4h9uE+LZ$<3vYv^|8@e`kR>{41A@(e z=~m^m)A=>K0-uXib0_d8oW^@wJ^VO^-uJIDiz4lkuW;x6V1@JSOps$^j;a3_?n>TS zYy9A;$`65l(E&22=iBeR2oGYFWZP*14~(<>|4VoQb4*?b3^r-kkWR~B*y(O;$oiU_ zqPXUK_wE8(J{%+~&ilTy#^x~zlR25(Gi?V1EqMQ+RgdCQ?S%dMP#bCoRQ$VGZA0M< zzIdmK{H&FaP#Nkmh2ux`OR(qGAPf#50OsRPkfEusK`5ZLD4+i(^*HmKX-e8;EaXiD zPMD&JqbYMY)&*WZHJZ5I|&X;fH@uwws(>>jr-Cqe;Rb9pJtUB?YKoO+Z z^GGo4QEtbcvce}bm!i9{EMb3NDb%gWod$l9vTOB}zhpcjl8k_Bkt6!nK**+@sj(6~ zC#-ED$G+xrk#pwFTn4dQ#ZrWx`XWifw7j*7zVl$Ng??Ls-?S574FD?kaziUb^3 zh{p03!;G(*KDJQp_Q`##1Xg)bhX#A{A`~8~?|#p5-(tWK1EE9xJ%sf6vbUFQl+3ME zHfx8yZC=Qom zaleAbs=w5vg#X;Y3S@!!fZ+RI*XpClX&w!2!V!yv(@m zJv(Ga6Ixy-b)Bi6t|6oC0|^$mV%&>)+9qQC8eUrE`wLTCzGo|V1=`|LsR@=$u9Wd= zYKTZl;x-KMLb^c*+#+~2rbdyxoE|rq{FAeCl5(kEJQTwD`K%@d$08~<;uZAy=`Dci?5*~1U7@$HnDF2A$eh5n z>q(3?_#^OUjaC2SG#<#ln1O&HqM$Hix~)pKB39s=CV_HzPQ|<9UJ~f{DneNU`4@=DQuByg5zcxUj0oKPDeiFKSF%EPrclc7mzY)C~um*~C#-pN7qgtnF8 za)t8v!sXbr=?Xk*`iSG$uPnIMu_4=pOK5#DD7Eqbr|G5n@nNMD^S$XaOq*H;LX5o)DzzS^ zXu;7*-PrpML%V+98PP!az#`#o+AXx~K_~{NFN*+dC-Pw;Bp1m+TkYa#XnblV) zcL_gFm0RyOII4>nJF~y}2Z!RAVco1%*|QGJ&9{u-K}flBIe@1lZ~qPWgy{!E5x$oB zgw|pB@8jOCYKHWbu_sY6yMr@bZX(`D)bP`oO1c`8a@>9A8+*Dl$fsQ#oME+S0hhv3FCOD>lL6XNrqG8(tdMGK|;yGoqg4tP&943*Y++XNjSzszGh{iBj8s6u@din;2Px% zjP5D!1^Kv6eR9YjmM_|-4@|_^$!=8f4Y7$wpdPfutGtY=OB_S%9LTa4RijH7dY7le z!mK`mys2=1S`2e>BW;92n22eqiKc4)GDc!TBPU}9<-Ukgfroz%n!Qw5nC%eCL=mAAO8IT}j)`x)`9G)lW6wgFtY?Qc6k;qW2~J+kSNiXMa#Br+qSJ&wr$(CZQHhOy|Verc2!sZ zh>qxa_vA+8B!`(N?%8`S#L2&9YUj z;Mrg>sV;tLsrz-lmVI}yFnvhXAZjw=U4c^)ff?T2f7LHOqKhuA?=VE`g1Rk}9=b9}H?<8U*97Su|4kForLhAAOF4=eLN zqA}3+?s(lB=dJEu?3l)J`b(35-f?C(fY<&@HOEQHvM+WsU}5JtPfB;P&3)J!)k9fN zlchaPG74jhm_Xw6HpQ=}7iyuixu)1ubqknc$t)&U%DcUJxzi+YplL8+!b)-nIEv4p zU0lAtpG1DP#}QpiT;U^-!X7wkZ#p@FwA~eQ7q1J>y2ui@uu}9of#X%6W}k>jZbE>> zIGGMx$M*y%sY4uo-PzS^hWt7$Ek{^(oC&`K*R;D06G+ycrsfD0(@ai;5bn>-T*mgk81BOv+1tpPbntYwd+@(WwJj2kLL!<}i6@gU z5SAMqrJ{(pDI>gry;_}v=40O1D)l7fs)fCO^QZBoAz$4F0A`jf63F#z$EcXOt_dwWQ`Jd2ep>Wd ztZbi8dfHgK(oVh;skRoydYQo|E`@W_>1NEi-HIM{0j=$cTMx`naj#+6F2V$sueC(O z@TY!TDx5O$kaHKbUP^O*SuJc;aI4tGhp*(2{SNtDeRaZTQ1N9LLo2e`X(|nzNUl`g z)(1%7G`XdMPwai}f7`i3w!zS#Q}q)8S~8InF~lLzCm+Wqqvm+D2>9jE_a7LV#E#kWV^w_=(YeOjv z>nUtjLJ2X?;B!ETfAdhrRn=Fu+-HijqnIk6p#WuM=t27P+EOIzVS+E&1~IRbScATC zr*JWdSj7&16eWmcVy~EvOT-%62B?Vp*=i5xEt2smHBw4*aN-x{loIZU{oF732Z+K=-S+ch>{g>N1}BT0WABR zgP&J3uo69`_XI+-Fvb@OQ?4_-n$(6$HaRNxGf$fV1~Zx9CyY#U(!VNSWBIX&SJt19 z{s)E|kNz)mZl1)g*>b4;^h-pngbtM-h2eAdO=$sp3=HcNZtiY~LiiIn$kih!puPvr z!2e$VRbA<3j!y8=3S5#6CVy53cm^{9RUzuSq>O+_jOqJn7z54J;CFa!YxMdj1(NPT zi3;t=w5Zz&{Kn`o?NxfpKSm)Lszgb6;u5fnEQPZ9$b@nB@`56l@MX9xoiSoWT8qw) zAud_^6K)+*qe%Jz5Z(WUC^)uY(|hSM)2}y3<ebZ+Y1j``zLSrs|vCGW) z@gif7xvck%U()ET$?M=)VybcTkC{L99~b#bKqV$0b*`ueIv!(qT%#8FJtj3F%&}N) z9qNS*=uxGvp^6kOWL1WR;bbYDiVTAP4v+um@yT;3EIN?E<5E6Ra*^m{ClSQ1ddD zTZo}nr~3Ya@Cg|LwL!b{mU0r(VnCoFr9cDF3Nd|?%dI1rjt=rZ*8B{DKu1|0FwOLm zt#2x*eboWtAC#M)TG@Fj1B0X)sy}UV+(9~0m?*gKs21Nq(k{1JVxs}q&wMX~Qn&cr zTfI>1`MGxs?Yl*yjyC|mraUjq$N%vwH1~r!DbXuacD$cm>@2s zL2c;%_fBJ>;$1*_l6|*W=kVxnb$>%56e>(2{-^$0P+w+3@QEaa-S`X^LP23Mx{D*K3qeco>f z6RK%*X{RdW={kCw$<^U_AN6ry@Wz$pb2d#+`2b~l;FhA*_H%XL@CH`oTH^A)MYc|~ z;D^E-^gdz|O8cSuGAg3=AomgPuyAvaWji;7Te)G=g(tEE%o0cP|xzxsK{Y!feiL*Agt`CL#^ z9En;GrXGRXmaC5@I?O`ZIlVL!O9(xTvGH83U_~!HHL-;#4SUFWYF0LVUIqA5h!gk~ z$Ze=Syt=$d0Cx{fg7mDb7+qxm>vWTb7`e8ba4NRR(#*&*GqY+~hTE*n1KzoYT!Koe zG!0HBqT#7M%Wfv@Yv$;gi7Tg1S6t{C`$%~6V@D-g09#gl%XQyAXtK<&6p}Okny}|- zrO*ecQ4DaL!2_zZ_crQ0g(z7qN~XG~{q7@w$~a*vec#5SzAVuO=h`#`zaG$&dPq;k zaiF z5t>9E1@AYokrZ$mv2?G!*`#eU;G z6j>0Ri+RpQsF5an$(I)F&7ja?Qj?=S7nhUGox%@E-AED{p+_ahG~^9!cp%(ec;C~j zpefu}?@)?k`K|9HD#UA#YI7&cy$JZ{NL9VN90__44J5&+wubOH!CAC#Jqro21tExq zIr?725xMC{{qn*c=62e6@R)Des;Y(%@HYD?giwB4fG5Ux9S9=Q+Z?p?Y>aaGgc>w4 zPDTZ6jZg7QXuT!+i$@5Z(|^)SL>V9 zqr0E6Oe-AFU8?;vwPRAX$ITfq!WsG5P%pG7)@!!Wdi+@6VQ@Fx7pbJoTZ-u_)|c4@ zDbgJz*l7PX8O{ueE%wmk+vNkxFpogasl0C!CDXeeX8>cw*11o9z$vq_TZXBRtv&2I zJ^Wz42l!lHMQ2!S2x+xI`Qg}J`{oOiYxP410Q~K!X2|95IveMupy_|j9i4TST9SK?)uHoex1d=irFb9e$Hgc=j+UDNQGGB_C2j-@-mFoWP?SeAD-R&4UYV~2URo4z z0hMNvaf>qZxSgDKqHt}-FQhI+!?*#gNi9Rxug zi~8e07V6QEPf2QX5yOf;W339(HF5(s3uBdEU2i~PH-fSu%ZhTK`xh&=g!LWpy;_cl zBt`<3>}WD%W~Em{&6?tY+5DTy2Jtu2%}zJ;0)E~nxHoF@WVydo`s-rS8PWpn-m7_~ zCJy)mZ8)vG-8?bji*axDYBv!EOwdhQ)V1pPG~6)O4?^zX6K{xojnArI6bIpgyz?RH5BK&kal=i zS5*f<9Al%mBx{m;l>5)3-=PaPx)9!mg8J|^Cia)=3O81R*8zbp1J_mYsdEuhf)~W0 zX*)$~ekxm8^}?&ow+TnM2G)&d{GGw3#sIO`fqIbAcg!I2rL)ZwmKz zTW0RhhCTd8G_HV*)qBbebYmA50Bm2bPyr}t4nk?onAlJ^j*uBE#w?|aRL@Ggqj$o< z2L_zX_eJ+~2roy_;}3ynnzB->DVwhgjouN_G_D9a0GdDXmPUt-oK$H-L!L%y3&86$rP-d|I=KlPIsm4(Gzn&OaN< zcowm%BWYb3Hck(EZq1P~^#JuH!@bqL+g~Zpf@;|r>tC?v^%zhML6Z3`*u`-ou&w4T zh3kF{F8{!vW6>wwoY7LVKGNfCwx-#HDCr#(fdy(qlss|5n8#XchIPit`JcV~pUFUd zHP>l{MgPQHO;p#XIF^4;ESN0Dp&J*&dt9S&r2Dd6Z*5-9>q$jsM`(vmfstS_C$C^% zP|dvPV2hwC#YC2-BB_B1iqh70ovhL9t zpiujb0-npQ`FIfLdrw%B(+*RbIo0c+E(J)updXC!Z8Jya;3jbiDa0XT3=d0>OY_cI zK#X6zN&4 z8?IxM(Bsq&2DThVGXRfY`h2hlN?N zGN@&uACqt!c2DyVv-hjkn4c;I4ufj(l0)rePR$@jV#9FTG##)HgG)Xk6d?eWL0jry zrp&L*lS~TE2;u6>p`I{WN}`WrArK9SaI|g5MDP9!OU~@gi1~1G1(8Afxayg-XfeR2 z&{(h9IkT;)*ewByLjKJS#AKV5(^^``?CB#0_I3HU*}I;0;pR5CYDAF&HxD)CV>;zk z84UV!j*e?Bh)}S_84ot_63`JFQ$KwWQUWh%7Kj0uN)hBp~ zT=x3RyF0X!%=9|SUhibZgGJzgU}21%5rRuDgb?DzJ=X)(!fhawBddVdJgbuSy49$b-mjN{M3gu}Yie8`u0A zzg~rqjFaIp;yGKFZY<*GS<*zwqZBGh1pok;+NXqS*{E*_{T|B7Y;oQVGHP3ttQerg z-H2_=Po!cJ)qV=AAb4Y6)0j$ElV4B@VMxbjrsZI|>{L(92?5LoP2*$xPq)#?{n5#VK>8FiKWXPRtIOR>gkCXXIDzx}!zHT?+$n6OP#{6GX-} zTmMGSZoky=^VILE3zIaNe$Q9x_&H6gvVj_g>)U(x?BS!$jz4imqU^6^3{f z>(Jl|ovK$u*=<841urec&kX;KL?=9ZBPN+)KFl_q`~v_!qlRA(f_)l^yeab9ESfUD zRhgu8=J-?GA{A87#G=}f!J?=EWNJP@X)X(B2Z}9to#2NdpezMwlj`8~gCf)r~-1!p6~ zt?=@?EA$ASIeZnPC#5A&2>-0XAy3QRcbLgtx?|sY;?8rq-t8(-f40I5x(=>xr^-aq z-V%1}Jk9*Bx04}PHT?4QwSejzMrkwdDgS*(+>9W1Z(uWo{>o1d$U^@~ zcX6|*_0n}olGm^L*)7&lA2tbH#@U?BX33nhtw}#7W(EZJB)oU&hj^YmLCaI-5?YmCk0Br_0ht8dJa`MpAhl z=AlqwCn`S-%5;u`t5b!sw#8n@3FvkItvly|{rW)bFpE()PqH!(n(@_g{A)1z`TIj4 zLqn`u#FI_6o=}(TJ=t;yxTcqPzlSDxKpyv9Q>$U%%_`*|GCIjcP@UN8!39XMMUQ`U zGT6>#6&>Cnv$4Z6h#W*^D2x$sDx<}04{!QdakZldh<8q!F17P@?`C9&zYVNj3?E* zJN#QgO@3xgISjQD@pF~SC>eV)Y35YoMUDn*z^C<#ov>Wq2gGchPgg&ttN%+;t5)mpPM+QJwLTP0o@k4WZ3!RN=ilV2|eJZVn*nxGcAFxB^#zbj|IKNq;HCv*nN_scCQpQAul+(kI$sHiL1^O{|zGOxiBR}30*$yX!qVXT@iflAYxGeo zn~{*G0zs?AU8&it1Y1=?cm5Osx;bdxdTsZ26KrFdD~IY3e7N*OG|mflK_JD5hhK3R z-`|iezV7LJj}WghZxZA4=UeuVoB2Tltn~d$-2rJvGhk6%1p{6>tx|rG{_ZX@pVZs| z$6U&|IFt!yx(k^Leg*LAyJ~Xd<(>`h1QcZ5>wlBXx6(3>Yf!s01bQf6W>#XMXz&296TqgbWsgx_58Y+8spkOzg*qRs+^TC-z zdpPF%*h)&6$ehApXm3#sDI5#FfAt07ymec*M4+5S7P0d^@a&@`d3k+=y)zPCi*{*5vdO&_N!%Mcu z6=^fb*jlmjlEZh^t1K~r1+WgQg=#uSUGe%NLY0%b`GWp~9r6Y+BE3WtPS0K?u#ek) z#vUK|%$^Rs!T!7L-GBin1qb{^U2>Og{+L>DGOhlL{(fzI=sV-nus+`7OFCM7r`8k0 z^3X2(6JUNrw!D9vsfeI>tJi_28iW}OD}u0(eGD^rk1JT_~ zMKp`JpvD`)h*Yt#jf>QJWm#BJ!=g^jaw&5A;&+G25kEQw()SO={Vh^J54sijSqw1nnto4I`NaQdS+{_rvTLK|R5(+!1-)AmSc0qgx| z3g(ar$Aw1SsdRVwd>jlbWw64phYc&%=i)Zv%P4mw#9Uc2>yl&?(w}G==lA|X;yv>h zW+eKw5nt|DVg>EJTSy3kCHtfG5}cnxnD@qHZmUe0sWxW~ymBch8`exb=i0=@z)W_= z%F#u0h9JQW{!fp$ypAGhk`xs3;}~uh0Pv7DdVHT#YAqm^;S;g@X;d`bD7uAQ?5wuI zeZxa-S!9-_Q7C6G{&ea&5C!Qa6eW$Cb-6H~y9r*b= zps?!Qk*R@wsis0xtt-b#`Ahv`QZ#M8-&oSQ^*hG-IP_p!9nhpQHnZ>&Muedd$wFV` zT~fiyc8B0V5{?=kGwQ)ky8P&wqa|3Z=bx^cH<*NoH zSywi@1rqIa1?(5~Tr0>8_B)$(r3dFLgLQmwqUb^ML@JS)?NtDF8(viQmrS9Y(BMc~ zvk)IX8+Bdv$@`zPovJNUOs;0Y0GqMJlQybeSlErNkfvufJQGsapi(`@OXp)VMoAu~ zzlhc;FZ7TlYV+z}$Z;{ywu=Ztl_YGNBL4xG!#OTwLMXYH zj7|dV)Y}Er>%Yh2KIAYGR}3R6dEQZAupe^k9-wCVc;kR7&lP>Q~vs=XnQxV>hSrxoDWVT>(9f`cwnBc-s@ zP8}UJ%DFIuxCb&(b5jg$@6?OJs8n>hb+u7aB*ri9%a_Sr(61-(9%XHx1K#`*(xhcz z;>W>BpY7wFYJ@}CtMySh2(bT#7$a_i>kQt%ugwo}!0Bp5MbNWvK^V=n^=pjZpNvhs zELumtYr9SNH);XINLDtb$ZZzY-I!Y$q;~u!>!=X@^`kssHwv9jS(nlN zDeKe1#Wh%>vT*@uRKyulmgoB3DwQr+`)dk`8 zn!^3Nzf1#vxr4&aDPVFrE3__jX#e~yaigyCs6pWLgS|x?bF=(ix{@qvglr9QB=5o| z^%AU35A78$BCx}Qz7NaOUM~I>z!qn-#*Lv4Bfrf$ETZ&?i@7(FK)Rrq+<2i_h8y0F zpJSngMgAa+d_Fk;oY)k#4_{j+xko$!jhf>y!PGT13NR;iUI#}lU-{aoCzg|;7B4%< zCx7^})n{FB283~Kx--|EAgp{2ha@~+^#yB7>5Gv@_HKN|MjcxK8|0T-G2mA-GxT8s z41O^%Z{;?{(1au8!MBiR%*OGKM8D>o7%QUkm~ca$KHtsH(VFr~_JN5*h!k_W(ghQR z%6KTn)HQ57_BjyKFe~~#oXccaYQb9h$b3M29*cbL9V2xC{!@b) zn*42Z7X{P%=wZ@*;s)+wtj%!<@Bud%HKCdgd$k-;^ciSl>*0slkNZ}y!iQHM12HlV z-m)QysT;z=g1($lxciwg3s`1rKiKdKZ4u)AlV#f*GOSHB#rK6@kSKyO1_5cz9gk1@ zU^Babo&sJ5Q@r>{h?thDwg747{n5iz%ti<4$OvJ_&qT^`Y?3VEihrvLEHby;oVYlm zFnzK7eH>S3+#*#jnuaG&l07o2xsvPdP)<)Ah z;{unFP98WZ^==M55;;kt^dgc5z}BU&#kVFKBpijmO&WFqflNj9WjMsFd`>0@Pc&Yd zXY2jN6*niDnh)e|RED3yN8(Frc8qY~m^b1#HH2|FkeaReSG$$pF`tE>jSV`VV=zn- zFbQ+lz!y+D1{fIAIqjCuhpY<*Gx(F1k%H)Iv?%Acx}>V0EeSG7ZSmHe-_l_gh#jrx zRkf$lE`%BUuL_^{l?PghC11?m4yo3}wWEX@cnCXR{Te8{AzLj|^iM(!yfdG=8%mR@ zxdK6jPRgubfo9x%DnOFHo-}P2$=HXyC6KKG z^&r;@RB79R++ayA(LP9?-uWY(fM4|wWRo$gKW5-XY>ynMf33r(9uX)Xw(x?h{d<9r z$l3{U1I<4c(M?k3%H{TRC#-MD`fEKt+kBv@p<5S^L9Iu5V_$FXdTLc^!kpRH@8w93iU&8cC;@T z`FK|GJPqe_VP+!W5&2sH)qz$TDk*F5R(&~{(g<|P37#CwZX^H@2R3=WYx75{^BK1Q zu=x1^=+2{)^(a2@Sx~NFnXgfa0>4{eBrU&YtpNZC)+1G&^suimdfNKmHvsmRBX0}0Q-;b6crzH8 zso3Sl?>_=Sm`42x{zXpzF8M(zV9u_kl{pd1K6i)$dp#{b_Lp^aCM&15*B|=-FYEyT zK5R@`@;v`|rikCNh!I`Go=_(}1s6xzYJ|53Vr9nZjEZZkckhLJu?jPN&6C1@!xEy= zQ>(phu3!%N>`&85oVp!9jy`XYxvo%KJzNxP6Ea34EWG-Uk5I0jN@>*F4=}uRy zWdq7~^kwC_uic}_4mx@SrZMupk3&jPEDKIgZssK?WZ-Iu>_Bv|suh^D0$9LgH( z8uIq3e$4?mIkHd{S%OHCLP;CW+>ze373EOe%ipe_YOSU*4ECrv&f4S zAs^TOE%7SJj2j^seh)^CA70;=tIa*bW%QE$U>ZHCa_-UC))J=CokYai z1#XgMjk6S_=I1ngT*Tr_=(r-340RBzmTSGb2KR`9!F;UEUdfDaaA(Cvz=e@}7Y(hC zJh+|@7Jz#yKqSR zAwiykp8w~~rjvI5?f22?aY4Jeji^;#jmN#sTC%F!KcEgs!Af!;UzCC0z}BQ+{UYtR zpr(ZgztmYz9p(-S*uH``U7{O5R8sjOaN%~_nJMuVe}W?yMdd}UXp6M-R=^gvl>aF^ z8mj`u^Wd|?oRV|%)g9ws-6Q$FPP;m4dN2wuu+HZbGOZcV3_b=(q|VVxHHPxa&wsSS zg4T#V{Vo_Xm+AaYOCv5-ARkJbk(g#k4(KzAJ8B2ptXED03ni?*M%o``TeDgL>NXLM zt1Q@iTlm8u&aK0gG|2p`mIwNWvWo~`9NKQ1LZvzq=h3Q=RBvCGpjT;170MCHPW?3M zQ7rZQQ+uUMqL#kKNh63haR%Q>U&N;@F;uB4!sI-C@(eLHTHp18*I@S5iDb1+SD<3g z%QVie-NBpXxm8l1ZO>M*sX(yq{!5NKzyU}~vDNYtR`lck`H$Ij2Pev@)XM##8Z6^? zod4Tb40Af#F}{HnZFN+wQHcy&v=l~cv20$COzS!Cvd?P^Kw25eX0LhT+@m!Q|Gh`h?%kP1X#+&Ouy>@rN} zoQCRVvtW10;EkVQWr&SW2k}B08UwvEtoUmv9xm*5ac|$2Ny%b$u7&&NtORIt_fx=* zmgnO~FI#3pQ7mu_S_crK&`VLPcZJ(aV*LTiAQYFNS`FT|h)$;5B;dSzwnVDv%$UKq z(8B&a%y`zf{%yojIZV}MHrV-l;l*lUJi~)J-k>)BFEr`LwoZk7q06}bf`^(L+ShiE z;?+kH!tWOltS|-DD}XX}GW4H}UZcb|zZ9Ye9O})>xkz4UT$7XvQ(70L>d24`*b6-S z-8Rn^8vS_+vw9;u?ZQY<7P+B?+vp00yw0J*UqXbY?Mav1Ww1sg{vz<14>n*~M(#v+ z-ZzaPEKE>lSe{{DgJSz+Yi3uq;y*|Zw`wxwLNA|gK*s@;*K2I*LUkNK;l))}C7Q{M(8#dY?B(c+YhYWTq8;qCM zzad4LyQ>94Saz0DZWe2pf&f_)m?c1o`D74Kx=tI+ZcQW&JRk48apG=aI*Ssrk`-U0 z1e7y8=JZH>FRALAz>TF`0WNYcJQ-p+=jp4}4vCBs{JA z?iY<-R^E0}{o9c7MN-IO?<_A+g0MO6&D{GOK%m=t5;b>xw`nHIMwpAQ_nd-I^t5aa!0g#ke{Ot?`(HTC#i{j0DHAy7|n*{o9OQCNNfP38gCoI zxpg6{?$!bFecyBpch?Bl38aCY$gh6RYMhRqiVV&C(5vVa*HkfSWAHD(4X_RcCX~dk z!?4d1x6BZK2lUsGUH*O=l(NKrtHj4rq26}l7tYng2$#jT6$l52J8P=Bpdl`g{q@Hk z6cj4~cgPA`@|}(+BfS#ls(Z6Ap}%8bNKd#L=#zZ-py&gQmydzbiS!nN7Is^V>Dc6H zJ=hYQzxJ4z!|4;uPls9~;S%=+Ceo9z6q0iB&LzmMspN1RkJ~i|LBf zqhhwJI^7zRd?j38pX&pixlZWcUD-U3RN3e73APD0hQ-opIMxHX^i48_dGynS9fX*J zwv1IuZ+efGOqMRPZN&3nySzJE4?2qJ%%i{Ck(oxGXTPXJ(siumHK71{+r80D4ACh8 zgS#Q5zv!2q-Mh(uqS=#ES}>j6r(cG15ujfeMIkFmnEM6<9&sR;|87ab6Wj4+JVgt> z=nXB8b>#pW3aWfH(H$h=3d(}8DR^bH`-aA>@)}m1TE@0Ivllr zmLS!UdU~cZb^J>cVzs}C;j96A4xDWGAgDRtQ0n;OAbdu*ajx8!&v5SZQ0SNda7Lxf zg8Z#1xe38ak(a_!Q<$-jF6%yc5EhN8vVkvjLS>?fQsh*CmS2K1YIbHF315NkQc-s*Lap)V-wW&1DSo#2H-R#LE>!6D>AP z%sQG(u?Q!9ywsLS!^&Co+DA8=trp5!yNEqnhU(#js4~=B#b7J`d16_iryW~+yB)bd z2eJH=bO!n2xp=G2HALm(cfE10SMcDt_V7ZmRHn&c2n|s}mJKqa#k;f#5Adv!kdd{` zqGt&Aj?Ys(XYia#fgEAi>q%~yduoT5W}Xp^lRpn3tP1^{ z0C$#_%$gY_68#L$j#-Q7HsK9A9F0zhKrou4{9EhSwq1&%MCiQ9W8+l}52(Ivi_Mut zm7rGae8Ryd&ZnA%u>Y#oa&LNfEzq>LO(aNVXAxCoIqR?wKF%)_2qX=DH5q9SY(L-g zPV`T28g6R8(aZ!=o(XSwxtD3&d`yVYWozBjN3D_l@POFroB zIb(Ox$GEuoVG|=Elt#$}0=`?VoOf+5 ziW2ChVLZZrk`;m| z-zNPu$0t(6;O`c64hh5NH~9TekojcOJ@ELC+DIm@#UIPfRR5Mm!IF%%u(QFTPDcI+u=k6_RSdk5p(iO1?1>AKF+8>SQCv3&A3^sQD{sG zTL_oTADM{eh|}ax=oee;o|Z?-=%aPPhjG^l_#l_3E-Da=FcTDHIC=1Ds0~b&ZO_Nt zCJJTm*UK{s3}OR3-~gxR`@ctyP<~Wq$SXFbg_(O7s%0^D&qxpn zCNkYT&Q-5191(8X&Pvhwhb*1zkg9I{{qw!jX->I;a!cWlEU}Z-Gv&`QY%H>nY&=4q z{1u-go%*5~zqyiY886~#VlYjHvF7mciuLNIJe~4x&2scm9eKV%Y?=2O&?QZxPt+(O zIQsI_d8HK}tY7^qm0WZP#keSH*;DMiBL%nRng<0DgVu(rsYMDyeVZ*4-tzW}gV#w79ZJI-*P)O}LYqFhvotiPPw|*TGEL^Jv7#>y05GI0fJVIx zb!mrEq1lb4uGs&5FSr{!JEd~+$kArv&N>#o>*m3riM!SZU|ru z)#&@JBUN?#TzkaNqivww#$PlQtaY{EY7rXbs&HhA$MhFfSGFRiF2+`qL#m~%1V4v* zR#g3DjA{PeWZBKC7_3wGg+n0}T`&}1duZ(_6c&+HVFtOQ8z9;EIX)?yf>cglIC+Qq zyGt>$FIXs{5EdRCr5%h1f~@@s@dX?XjkcESnCc3ByDJ=jKJB83r>u^doz$ZXSE^by zAppg?#8Q2PFOURel|y*JB3yZ%Yw5-UII zJ^VEyWNkIBt)wLL{%oD zEqDTr+oA2B3;-^g)oB@1W&>2zDA9O4DhMIpKiSL+$OC*BbIurrM;xBNHdfUnN3u92 z|A{n`kxe|8H3Xg)nuAY`J@C?#BrK;PIwf^{V(PGyTJ*Mua@pqRZ#QV!M<+Dfd$)dQ zxvorBszBB%R9S`$kf37vfPq7|g0w7A%s*Y*KdNJ8RgwGd9%{zW@_NPmH;^P0yLA5q zK)hD<2Z)I#@}BLU4_S#EP9}t;p<^&eIK=5|y<@KQG7m&pafIVRw}|#q z3zJLa%dSbVAA2;Xgm~8T^EDi52+nGo_thjRnU%o8ngz3pWHpzUGgtdwcqIk!*6d3; z`D3yOci@ao5%@wbB18in$j&Dg5~)}kp?8>FJXJtla>A79reCrR`H&Aj=;_sz_tpXY z^c3Sxsc|>)D8P=2Vq0V2oU?$|2LJ(nXwI#50_^nYp2+FQb}|9rgL-A3k9xJ5!;SRR*6@fK(Zyw7-^EsYLt6 zA^-66#LTbrWrFA@TQxJVJ7-(46K!Divr1x$emuxQQFL5V=@x>2EQ9aH7u;2g<(T%R z0D<*hDGN*N;BiHKU01xeZ#ak*sglx1$#R4MdL(QU+$3TgR{DSC-pzOhFS5b%#w`cN z6UeQs*3x3pQA`;WkU1;=M5(SjU@8`P7WhDRW3>1<#K$KF#k$q#CrLEjaY@Wr3-`p3pUyOu!!Uk}6M27Pta%U@)w) zi*Ih+KkjtvRM6M_dfdhd*v(vd#QQOupLeeqA=2(8Uk%y<#~KO}>{zhAUxR+<;c?il zoXjlmPvo@F0>X6&om0A-d1#5VkI4sk|8y<>dH@=i8!|(VQ#Ox0a!AY@_~3WU9n1xA zW?yvin`2*Zzp8iRv$Y1M4h{B5@4m)&;koc61!9_D1`|5`5FJZzqOd!(6&c60EQX|c|SGnMeEilh{7J4id za3pVehSadkP|hoeuaFSB{{wcH33S0N*fT*De`)0qgdT!nH*EBKDff>Jp8<@zfOx;b zfveDj(OOo+ixqXTi_25t6EMBXLwDRFyF-c(I*6+>Y=t$qTp5z6qHY%x=>P_SfWwj& z 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 From 0b340ce7ff768e20416e4768c02c33aea14061fb Mon Sep 17 00:00:00 2001 From: Alex Hambley <33315205+alexhambley@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:17:36 +0100 Subject: [PATCH 15/16] chore(docker): consolidate Dockerfiles and tidy up compose --- .github/workflows/build-with-profiles.yml | 5 ++- Dockerfile | 23 +++++++++++-- Dockerfile.fivesafes-profile | 39 ----------------------- docker-compose-develop.yml | 5 ++- docker-compose.yml | 6 +++- 5 files changed, 33 insertions(+), 45 deletions(-) delete mode 100644 Dockerfile.fivesafes-profile diff --git a/.github/workflows/build-with-profiles.yml b/.github/workflows/build-with-profiles.yml index ba77faf..1a88d2f 100644 --- a/.github/workflows/build-with-profiles.yml +++ b/.github/workflows/build-with-profiles.yml @@ -39,8 +39,11 @@ jobs: uses: docker/build-push-action@v6 with: context: . - file: ./Dockerfile.fivesafes-profile push: true + # Bake the five-safes profile into the shared Dockerfile via build args. + build-args: | + FIVE_SAFES_PROFILE_VERSION=five-safes-0.7.4-beta + PROFILES_ARCHIVE_URL=https://github.com/eScienceLab/rocrate-validator/archive/refs/tags/five-safes-0.7.4-beta.tar.gz tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile index 19fca58..2a5543d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM python:3.11-slim -# Install required system packages, including git -RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* +# git is needed by some dependencies; wget is only used when baking a profile. +RUN apt-get update && apt-get install -y git wget && rm -rf /var/lib/apt/lists/* WORKDIR /app @@ -12,6 +12,22 @@ RUN pip install --no-cache-dir -r requirements.txt COPY cratey.py LICENSE /app/ COPY app /app/app +# Optionally bake an extra RO-Crate profile into the validator's bundled +# profiles directory. A plain build leaves PROFILES_ARCHIVE_URL empty and skips +# this entirely; the "with profiles" image build passes these as --build-arg. +# Baked profiles are then found automatically (no PROFILES_PATH needed). +ARG PROFILES_ARCHIVE_URL="" +ARG FIVE_SAFES_PROFILE_VERSION="" +ARG PY_VER=3.11 +RUN if [ -n "$PROFILES_ARCHIVE_URL" ]; then \ + wget -O /tmp/profiles.tar.gz "$PROFILES_ARCHIVE_URL" && \ + tar -xzf /tmp/profiles.tar.gz \ + -C "/usr/local/lib/python${PY_VER}/site-packages/rocrate_validator/profiles/" \ + --strip-components=3 \ + "rocrate-validator-${FIVE_SAFES_PROFILE_VERSION}/rocrate_validator/profiles/five-safes-crate" && \ + rm /tmp/profiles.tar.gz ; \ + fi + RUN useradd -ms /bin/bash flaskuser RUN chown -R flaskuser:flaskuser /app @@ -21,4 +37,5 @@ EXPOSE 5000 CMD ["flask", "run", "--host=0.0.0.0"] -LABEL org.opencontainers.image.source="https://github.com/eScienceLab/Cratey-Validator" \ No newline at end of file +LABEL org.opencontainers.image.source="https://github.com/eScienceLab/Cratey-Validator" +LABEL org.cratey.five-safes-profile-version="${FIVE_SAFES_PROFILE_VERSION}" diff --git a/Dockerfile.fivesafes-profile b/Dockerfile.fivesafes-profile deleted file mode 100644 index df234b9..0000000 --- a/Dockerfile.fivesafes-profile +++ /dev/null @@ -1,39 +0,0 @@ -FROM python:3.11-slim - -ARG FIVE_SAFES_PROFILE_VERSION=five-safes-0.7.4-beta -ARG PROFILES_ARCHIVE_URL=https://github.com/eScienceLab/rocrate-validator/archive/refs/tags/${FIVE_SAFES_PROFILE_VERSION}.tar.gz -ARG PY_VER=3.11 - -# Install required system packages, including git -RUN apt-get update && apt-get install -y git wget && rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -COPY requirements.txt . -RUN pip install --upgrade pip -RUN pip install --no-cache-dir -r requirements.txt - -COPY cratey.py LICENSE /app/ -COPY app /app/app -RUN < Date: Thu, 18 Jun 2026 10:21:58 +0100 Subject: [PATCH 16/16] feat(validation): bump rocrate-validator to 0.10.0; rename entrypoint and image A few final things to bundle together: - bump `roc-validator` 0.9.0 -> 0.10.0 to use offline/cache/extra-profiles - rename the WSGI entrypoint and align image/label naming. - Add `VALIDATION_OFFLINE`, `CACHE_PATH` and `EXTRA_PROFILES_PATH` settings - Offline validation is opt-in (default off). - `extra_profiles_path` ADDS to the bundled profiles (roc-validator 0.10.0 fix) - Warm validator HTTP cache at image build (rocrate-validator cache warm) - Replace the site-packages profile bake with extraction to /app/extra-profiles loaded via `EXTRA_PROFILES_PATH` - This is passed as a build arg from the profiles workflow. - Rename entrypoint `cratey.py` -> `wsgi.py` to fit convention - Rename the published image and the five-safes label to ro-crate-validation-service; point image.source at the renamed repo. Closes #162, #163, #164 --- .github/workflows/build-with-profiles.yml | 4 +- Dockerfile | 31 ++++++++++----- README.md | 48 +++++++++++++++-------- app/ro_crates/routes/post_routes.py | 17 ++++---- app/services/validation_service.py | 21 ++++++++-- app/tasks/validation_tasks.py | 3 ++ app/utils/config.py | 6 +++ app/validation/runner.py | 21 ++++++++++ docker-compose-develop.yml | 6 +-- docker-compose.yml | 8 ++-- pyproject.toml | 2 +- requirements-dev.txt | 2 +- requirements.txt | 2 +- tests/ro_crates/test_routes.py | 10 ++++- tests/services/test_validation_service.py | 7 +++- tests/utils/test_config.py | 16 ++++++++ tests/validation/test_runner.py | 31 +++++++++++++++ cratey.py => wsgi.py | 4 -- 18 files changed, 187 insertions(+), 52 deletions(-) rename cratey.py => wsgi.py (73%) diff --git a/.github/workflows/build-with-profiles.yml b/.github/workflows/build-with-profiles.yml index 1a88d2f..2ad137d 100644 --- a/.github/workflows/build-with-profiles.yml +++ b/.github/workflows/build-with-profiles.yml @@ -40,10 +40,12 @@ jobs: with: context: . push: true - # Bake the five-safes profile into the shared Dockerfile via build args. + # Add the five-safes profile (via EXTRA_PROFILES_PATH) and warm its + # cache, using the shared Dockerfile's build args. build-args: | FIVE_SAFES_PROFILE_VERSION=five-safes-0.7.4-beta PROFILES_ARCHIVE_URL=https://github.com/eScienceLab/rocrate-validator/archive/refs/tags/five-safes-0.7.4-beta.tar.gz + EXTRA_PROFILES_PATH=/app/extra-profiles tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile index 2a5543d..5ed6bb6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,25 +9,38 @@ COPY requirements.txt . RUN pip install --upgrade pip RUN pip install --no-cache-dir -r requirements.txt -COPY cratey.py LICENSE /app/ +COPY wsgi.py LICENSE /app/ COPY app /app/app -# Optionally bake an extra RO-Crate profile into the validator's bundled -# profiles directory. A plain build leaves PROFILES_ARCHIVE_URL empty and skips -# this entirely; the "with profiles" image build passes these as --build-arg. -# Baked profiles are then found automatically (no PROFILES_PATH needed). +# Optionally fetch an extra RO-Crate profile into a normal directory. It is +# *added* to the bundled profiles at runtime via EXTRA_PROFILES_PATH. +# A plain build leaves PROFILES_ARCHIVE_URL empty +# and skips this; the "with profiles" image build passes it as --build-arg. ARG PROFILES_ARCHIVE_URL="" ARG FIVE_SAFES_PROFILE_VERSION="" -ARG PY_VER=3.11 +# Set EXTRA_PROFILES_PATH only for the profiles build (passed as a build arg). +ARG EXTRA_PROFILES_PATH="" +ENV EXTRA_PROFILES_PATH=${EXTRA_PROFILES_PATH} +ENV CACHE_PATH=/app/.rocrate-cache RUN if [ -n "$PROFILES_ARCHIVE_URL" ]; then \ + mkdir -p /app/extra-profiles && \ wget -O /tmp/profiles.tar.gz "$PROFILES_ARCHIVE_URL" && \ tar -xzf /tmp/profiles.tar.gz \ - -C "/usr/local/lib/python${PY_VER}/site-packages/rocrate_validator/profiles/" \ + -C /app/extra-profiles \ --strip-components=3 \ "rocrate-validator-${FIVE_SAFES_PROFILE_VERSION}/rocrate_validator/profiles/five-safes-crate" && \ rm /tmp/profiles.tar.gz ; \ fi +# Pre-populate the HTTP cache so opt-in offline validation +# (VALIDATION_OFFLINE=true) works without network at runtime. +RUN if [ -n "$EXTRA_PROFILES_PATH" ]; then \ + rocrate-validator cache warm --all-profiles \ + --extra-profiles-path "$EXTRA_PROFILES_PATH" --cache-path "$CACHE_PATH" ; \ + else \ + rocrate-validator cache warm --all-profiles --cache-path "$CACHE_PATH" ; \ + fi + RUN useradd -ms /bin/bash flaskuser RUN chown -R flaskuser:flaskuser /app @@ -37,5 +50,5 @@ EXPOSE 5000 CMD ["flask", "run", "--host=0.0.0.0"] -LABEL org.opencontainers.image.source="https://github.com/eScienceLab/Cratey-Validator" -LABEL org.cratey.five-safes-profile-version="${FIVE_SAFES_PROFILE_VERSION}" +LABEL org.opencontainers.image.source="https://github.com/eScienceLab/RO-Crate-Validation-Service" +LABEL org.ro-crate-validation-service.five-safes-profile-version="${FIVE_SAFES_PROFILE_VERSION}" diff --git a/README.md b/README.md index a47a505..9d9b35d 100644 --- a/README.md +++ b/README.md @@ -256,12 +256,28 @@ fails quickly with a clear error rather than at the first request. | `S3_RESULTS_PREFIX` | `validation-results` | key prefix for results | | `CELERY_BROKER_URL` | — | Redis broker URL | | `CELERY_RESULT_BACKEND` | — | Celery result backend URL | -| `PROFILES_PATH` | — | directory of 'custom' RO-Crate profiles (optional) | +| `PROFILES_PATH` | — | directory of profiles that **replaces** the bundled set (optional) | +| `EXTRA_PROFILES_PATH` | — | directory of profiles **added** to the bundled set (optional) | +| `CACHE_PATH` | per-user dir | HTTP cache location for the validator | +| `VALIDATION_OFFLINE` | `false` | validate using only the cache (no network) | | `FLASK_ENV` | `development` | `production` disables debug | When `STORAGE_ENABLED=true`, the `S3_*` and `CELERY_*` variables above are **required** — startup fails if any are missing. +### Profiles, cache, and offline validation + +Custom profiles (e.g. `five-safes-crate`) are best added with +`EXTRA_PROFILES_PATH`, which **adds** them to the validator's bundled profiles — +unlike `PROFILES_PATH`, which replaces the bundled set entirely. The published +"with profiles" image bakes the five-safes profile in this way. + +The validator caches the profile/context HTTP resources it fetches. The Docker +image pre-populates this cache at build time (`rocrate-validator cache warm`), +so setting `VALIDATION_OFFLINE=true` runs validation entirely from the cache +with no network access. Online validation (the default) also uses and refreshes +the cache. (Requires rocrate-validator ≥ 0.10.0.) + ## Running the service ### Prerequisites @@ -339,25 +355,25 @@ app/ ├── __init__.py # app factory: config, blueprints, error handlers, request IDs ├── health.py # /healthz and /readyz ├── storage/ # object-storage abstraction -│ ├── base.py # StorageBackend protocol + ObjectStat -│ ├── s3.py # boto3 implementation (any S3-compatible store) -│ ├── memory.py # in-memory backend (tests / local) -│ └── errors.py # StorageError, ObjectNotFound +│ ├── base.py # StorageBackend protocol + ObjectStat +│ ├── s3.py # boto3 implementation (any S3-compatible store) +│ ├── memory.py # in-memory backend (tests / local) +│ └── errors.py # StorageError, ObjectNotFound ├── crates/ # crate identity, layout, resolution -│ ├── ids.py # 'Crate ID' validation -│ ├── layout.py # object keys -│ └── resolver.py # deterministic zip/dir resolution +│ ├── ids.py # 'Crate ID' validation +│ ├── layout.py # object keys +│ └── resolver.py # deterministic zip/dir resolution ├── validation/ # validation boundary -│ ├── results.py # ValidationOutcome (valid/invalid/error) -│ └── runner.py # wraps rocrate-validator -├── ro_crates/routes/ # HTTP endpoints (metadata + ID-based) +│ ├── results.py # ValidationOutcome (valid/invalid/error) +│ └── runner.py # wraps rocrate-validator +├── ro_crates/routes/ # HTTP endpoints (metadata + ID-based) ├── services/ -│ ├── validation_service.py # request handling: resolve, queue, read results -│ └── logging_service.py # JSON logging, request IDs, redaction -├── tasks/validation_tasks.py # Celery task: fetch → validate → persist → webhook +│ ├── validation_service.py # request handling: resolve, queue, read results +│ └── logging_service.py # JSON logging, request IDs, redaction +├── tasks/validation_tasks.py # Celery task: fetch → validate → persist → webhook └── utils/ - ├── config.py # validated Settings - └── webhook_utils.py # webhook delivery with retry/backoff + ├── config.py # validated Settings + └── webhook_utils.py # webhook delivery with retry/backoff ``` ## License diff --git a/app/ro_crates/routes/post_routes.py b/app/ro_crates/routes/post_routes.py index 90ebdc4..e0eb3e3 100644 --- a/app/ro_crates/routes/post_routes.py +++ b/app/ro_crates/routes/post_routes.py @@ -71,12 +71,15 @@ def validate_ro_crate_metadata(json_data) -> tuple[Response, int]: """ crate_json = json_data["crate_json"] + profile_name = json_data.get("profile_name") - if "profile_name" in json_data: - profile_name = json_data["profile_name"] - else: - profile_name = None - - profiles_path = current_app.config["PROFILES_PATH"] + settings = current_app.config["SETTINGS"] - return run_metadata_validation(crate_json, profile_name, profiles_path=profiles_path) + return run_metadata_validation( + crate_json, + profile_name, + profiles_path=settings.profiles_path, + extra_profiles_path=settings.extra_profiles_path, + cache_path=settings.cache_path, + offline=settings.validation_offline, + ) diff --git a/app/services/validation_service.py b/app/services/validation_service.py index 532d2ee..a9f407c 100644 --- a/app/services/validation_service.py +++ b/app/services/validation_service.py @@ -51,7 +51,12 @@ def queue_ro_crate_validation_task( def run_metadata_validation( - crate_json: str, profile_name=None, profiles_path=None + crate_json: str, + profile_name=None, + profiles_path=None, + extra_profiles_path=None, + cache_path=None, + offline=False, ) -> tuple[Response, int]: """ Validate RO-Crate metadata synchronously and return the result inline. @@ -62,7 +67,10 @@ def run_metadata_validation( :param crate_json: The RO-Crate JSON-LD metadata, as a string. :param profile_name: The profile to validate against. - :param profiles_path: A path to the profile definition directory. + :param profiles_path: A profiles directory that replaces the bundled set. + :param extra_profiles_path: A profiles directory added to the bundled set. + :param cache_path: HTTP cache location for the validator. + :param offline: Validate using only the cache (no network). :return: A JSON response and HTTP status code. """ if not crate_json: @@ -76,7 +84,14 @@ def run_metadata_validation( if not metadata: return jsonify({"error": "Required parameter crate_json is empty"}), 422 - outcome = validate_metadata(metadata, profile_name=profile_name, profiles_path=profiles_path) + outcome = validate_metadata( + metadata, + profile_name=profile_name, + profiles_path=profiles_path, + extra_profiles_path=extra_profiles_path, + cache_path=cache_path, + offline=offline, + ) status_code = 422 if outcome.status is ValidationStatus.ERROR else 200 return jsonify(outcome.to_dict()), status_code diff --git a/app/tasks/validation_tasks.py b/app/tasks/validation_tasks.py index 19ea3d5..48a8608 100644 --- a/app/tasks/validation_tasks.py +++ b/app/tasks/validation_tasks.py @@ -85,6 +85,9 @@ def run_validation_job( local_path, profile_name=profile_name, profiles_path=settings.profiles_path, + extra_profiles_path=settings.extra_profiles_path, + cache_path=settings.cache_path, + offline=settings.validation_offline, created_at=created_at, ) finally: diff --git a/app/utils/config.py b/app/utils/config.py index b52946b..70e9d02 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -47,6 +47,9 @@ class Settings: s3_use_ssl: bool s3_crate_prefix: str s3_results_prefix: str + extra_profiles_path: Optional[str] + cache_path: Optional[str] + validation_offline: bool @classmethod def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "Settings": @@ -91,6 +94,9 @@ def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "Settings": s3_use_ssl=_parse_bool(env.get("S3_USE_SSL")), s3_crate_prefix=_clean(env.get("S3_CRATE_PREFIX")) or "crates", s3_results_prefix=_clean(env.get("S3_RESULTS_PREFIX")) or "validation-results", + extra_profiles_path=_clean(env.get("EXTRA_PROFILES_PATH")), + cache_path=_clean(env.get("CACHE_PATH")), + validation_offline=_parse_bool(env.get("VALIDATION_OFFLINE")), ) diff --git a/app/validation/runner.py b/app/validation/runner.py index d7efac6..62c4ba0 100644 --- a/app/validation/runner.py +++ b/app/validation/runner.py @@ -19,7 +19,10 @@ def validate_crate_path( rocrate_uri: str, profile_name: Optional[str] = None, profiles_path: Optional[str] = None, + extra_profiles_path: Optional[str] = None, skip_checks: Optional[list] = None, + cache_path: Optional[str] = None, + offline: bool = False, created_at: Optional[str] = None, ) -> ValidationOutcome: """Validate a crate on disk (a directory or zip) at ``rocrate_uri``.""" @@ -27,7 +30,10 @@ def validate_crate_path( {"rocrate_uri": rocrate_uri}, profile_name=profile_name, profiles_path=profiles_path, + extra_profiles_path=extra_profiles_path, skip_checks=skip_checks, + cache_path=cache_path, + offline=offline, created_at=created_at, ) @@ -36,7 +42,10 @@ def validate_metadata( metadata: dict, profile_name: Optional[str] = None, profiles_path: Optional[str] = None, + extra_profiles_path: Optional[str] = None, skip_checks: Optional[list] = None, + cache_path: Optional[str] = None, + offline: bool = False, created_at: Optional[str] = None, ) -> ValidationOutcome: """Validate an in-memory RO-Crate metadata graph.""" @@ -44,7 +53,10 @@ def validate_metadata( {"metadata_only": True, "metadata_dict": metadata}, profile_name=profile_name, profiles_path=profiles_path, + extra_profiles_path=extra_profiles_path, skip_checks=skip_checks, + cache_path=cache_path, + offline=offline, created_at=created_at, ) @@ -53,7 +65,10 @@ def _run( base_settings: dict, profile_name: Optional[str], profiles_path: Optional[str], + extra_profiles_path: Optional[str], skip_checks: Optional[list], + cache_path: Optional[str], + offline: bool, created_at: Optional[str], ) -> ValidationOutcome: options = dict(base_settings) @@ -61,8 +76,14 @@ def _run( options["profile_identifier"] = profile_name if profiles_path: options["profiles_path"] = profiles_path + if extra_profiles_path: + options["extra_profiles_path"] = extra_profiles_path if skip_checks: options["skip_checks"] = skip_checks + if cache_path: + options["cache_path"] = cache_path + if offline: + options["offline"] = offline try: settings = services.ValidationSettings(**options) diff --git a/docker-compose-develop.yml b/docker-compose-develop.yml index 28f0b3e..f7c08e4 100644 --- a/docker-compose-develop.yml +++ b/docker-compose-develop.yml @@ -11,7 +11,7 @@ services: ports: - "5001:5000" environment: - - FLASK_APP=cratey.py + - FLASK_APP=wsgi.py - FLASK_ENV=development - CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/0 @@ -23,7 +23,7 @@ services: - S3_SECRET_KEY=${S3_SECRET_KEY} - S3_BUCKET=${S3_BUCKET} - S3_USE_SSL=${S3_USE_SSL:-false} - - PROFILES_PATH=/app/profiles + - EXTRA_PROFILES_PATH=/app/profiles depends_on: - redis # Metadata validation runs synchronously in this process, so the flask @@ -47,7 +47,7 @@ services: - S3_SECRET_KEY=${S3_SECRET_KEY} - S3_BUCKET=${S3_BUCKET} - S3_USE_SSL=${S3_USE_SSL:-false} - - PROFILES_PATH=/app/profiles + - EXTRA_PROFILES_PATH=/app/profiles depends_on: - redis volumes: diff --git a/docker-compose.yml b/docker-compose.yml index d25868a..73c9e2d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -# Default stack, using the published image (ghcr.io/.../cratey-validator). +# Default stack, using the published image (ghcr.io/.../ro-crate-validation-service). # For running the service. Object storage is opt-in: set STORAGE_ENABLED=true # and start the "objectstore" profile (docker compose --profile objectstore up). # For local development against a freshly built image, use @@ -7,11 +7,11 @@ services: flask: platform: linux/x86_64 - image: "ghcr.io/esciencelab/cratey-validator:0.1" + image: "ghcr.io/esciencelab/ro-crate-validation-service:0.1" ports: - "5001:5000" environment: - - FLASK_APP=cratey.py + - FLASK_APP=wsgi.py - FLASK_ENV=development - CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/0 @@ -28,7 +28,7 @@ services: celery_worker: platform: linux/x86_64 - image: "ghcr.io/esciencelab/cratey-validator:0.1" + image: "ghcr.io/esciencelab/ro-crate-validation-service:0.1" command: celery -A app.celery_worker.celery worker --loglevel=info -E environment: - CELERY_BROKER_URL=redis://redis:6379/0 diff --git a/pyproject.toml b/pyproject.toml index 3efe90e..b791232 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "redis==7.4.0", "python-dotenv==1.2.2", "apiflask==3.1.0", - "roc-validator==0.9.0", + "roc-validator==0.10.0", ] [project.optional-dependencies] diff --git a/requirements-dev.txt b/requirements-dev.txt index 48a638d..c38068a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -204,7 +204,7 @@ rich==13.9.4 # roc-validator rich-click==1.9.8 # via roc-validator -roc-validator==0.9.0 +roc-validator==0.10.0 # via ro-crate-validation-service (pyproject.toml) ruff==0.15.17 # via ro-crate-validation-service (pyproject.toml) diff --git a/requirements.txt b/requirements.txt index b392f3d..5a94f45 100644 --- a/requirements.txt +++ b/requirements.txt @@ -160,7 +160,7 @@ rich==13.9.4 # roc-validator rich-click==1.8.9 # via roc-validator -roc-validator==0.9.0 +roc-validator==0.10.0 # via ro-crate-validation-service (pyproject.toml) s3transfer==0.18.0 # via boto3 diff --git a/tests/ro_crates/test_routes.py b/tests/ro_crates/test_routes.py index 5fa391a..3743016 100644 --- a/tests/ro_crates/test_routes.py +++ b/tests/ro_crates/test_routes.py @@ -110,7 +110,15 @@ def test_validate_metadata_success( crate_json = payload.get("crate_json") profile_name = payload.get("profile_name") - mock_run.assert_called_once_with(crate_json, profile_name, profiles_path=profiles_path) + # Storage-disabled client => all validation-tuning settings are unset. + mock_run.assert_called_once_with( + crate_json, + profile_name, + profiles_path=profiles_path, + extra_profiles_path=None, + cache_path=None, + offline=False, + ) assert response.status_code == status_code assert response.json == response_json diff --git a/tests/services/test_validation_service.py b/tests/services/test_validation_service.py index 02216bd..1c393e9 100644 --- a/tests/services/test_validation_service.py +++ b/tests/services/test_validation_service.py @@ -98,7 +98,12 @@ def test_run_metadata_validation_valid_is_200(mock_validate, flask_app): assert status == 200 assert response.json["status"] == "valid" mock_validate.assert_called_once_with( - {"@graph": []}, profile_name="ro-crate", profiles_path="/app/profiles" + {"@graph": []}, + profile_name="ro-crate", + profiles_path="/app/profiles", + extra_profiles_path=None, + cache_path=None, + offline=False, ) diff --git a/tests/utils/test_config.py b/tests/utils/test_config.py index b1ea143..74275a2 100644 --- a/tests/utils/test_config.py +++ b/tests/utils/test_config.py @@ -13,6 +13,22 @@ def test_defaults_when_storage_disabled(): assert settings.flask_env == "development" assert settings.debug is True assert settings.profiles_path is None + assert settings.extra_profiles_path is None + assert settings.cache_path is None + assert settings.validation_offline is False + + +def test_validation_tuning_vars_are_read(): + settings = Settings.from_env( + { + "EXTRA_PROFILES_PATH": "/app/extra-profiles", + "CACHE_PATH": "/app/.rocrate-cache", + "VALIDATION_OFFLINE": "true", + } + ) + assert settings.extra_profiles_path == "/app/extra-profiles" + assert settings.cache_path == "/app/.rocrate-cache" + assert settings.validation_offline is True def test_storage_enabled_requires_s3_and_broker_config(): diff --git a/tests/validation/test_runner.py b/tests/validation/test_runner.py index 8bf2dde..35079fb 100644 --- a/tests/validation/test_runner.py +++ b/tests/validation/test_runner.py @@ -76,3 +76,34 @@ def test_validate_crate_path_exception_becomes_error_outcome(monkeypatch): outcome = runner.validate_crate_path("/tmp/crate") assert outcome.status is ValidationStatus.ERROR assert "bad crate" in outcome.error + + +def test_offline_cache_and_extra_profiles_are_passed_through(monkeypatch): + fake = FakeServices(result=FakeResult(has_issues=False)) + monkeypatch.setattr(runner, "services", fake) + + runner.validate_crate_path( + "/tmp/crate", + profile_name="five-safes-crate", + extra_profiles_path="/app/extra-profiles", + cache_path="/app/.rocrate-cache", + offline=True, + ) + + s = fake.last_settings + assert s["extra_profiles_path"] == "/app/extra-profiles" + assert s["cache_path"] == "/app/.rocrate-cache" + assert s["offline"] is True + + +def test_offline_and_cache_omitted_when_not_set(monkeypatch): + fake = FakeServices(result=FakeResult(has_issues=False)) + monkeypatch.setattr(runner, "services", fake) + + runner.validate_metadata({"@graph": []}) + + s = fake.last_settings + assert "extra_profiles_path" not in s + assert "cache_path" not in s + # offline defaults to False and is only forwarded when enabled + assert s.get("offline", False) is False diff --git a/cratey.py b/wsgi.py similarity index 73% rename from cratey.py rename to wsgi.py index f514f14..c0f7d15 100644 --- a/cratey.py +++ b/wsgi.py @@ -1,9 +1,5 @@ """Entry point for the Flask application.""" -# Author: Alexander Hambley -# License: MIT -# Copyright (c) 2025 eScience Lab, The University of Manchester - from app import create_app from app.services.logging_service import setup_logging