This is an automated email from the ASF dual-hosted git repository. yasithdev pushed a commit to branch feat/generic-experiment-launcher in repository https://gitbox.apache.org/repos/asf/airavata-portals.git
commit d0c3e210cb90400736a432da72ca086720945d86 Author: yasithdev <[email protected]> AuthorDate: Fri Apr 24 21:21:56 2026 -0400 feat(launcher): DRF serializers + JSON contract schemas --- .../apps/api/launcher_serializers.py | 48 +++++++++++++++++ .../apps/api/tests/test_launcher_serializers.py | 62 ++++++++++++++++++++++ .../tests/contracts/experiment-draft.schema.json | 51 ++++++++++++++++++ .../tests/contracts/preview-response.schema.json | 11 ++++ 4 files changed, 172 insertions(+) diff --git a/airavata-django-portal/django_airavata/apps/api/launcher_serializers.py b/airavata-django-portal/django_airavata/apps/api/launcher_serializers.py new file mode 100644 index 000000000..af186fc4d --- /dev/null +++ b/airavata-django-portal/django_airavata/apps/api/launcher_serializers.py @@ -0,0 +1,48 @@ +import re + +from rest_framework import serializers + + +WALLTIME_RE = re.compile(r"^\d{1,3}:[0-5]\d:[0-5]\d$") + + +class RuntimeSerializer(serializers.Serializer): + compute_resource_id = serializers.CharField() + partition = serializers.CharField() + walltime = serializers.CharField() + nodes = serializers.IntegerField(min_value=1) + cpus_per_node = serializers.IntegerField(min_value=1) + + def validate_walltime(self, value: str) -> str: + if not WALLTIME_RE.match(value): + raise serializers.ValidationError("walltime must match HH:MM:SS or HHH:MM:SS") + return value + + +class StorageRefSerializer(serializers.Serializer): + storage_id = serializers.CharField() + path = serializers.CharField() + + +class ExperimentDraftSerializer(serializers.Serializer): + name = serializers.CharField(min_length=1, max_length=256) + project_id = serializers.CharField() + description = serializers.CharField(required=False, allow_blank=True, default="") + app_id = serializers.CharField() + interface_name = serializers.CharField() + inputs = serializers.DictField(child=serializers.JSONField()) + outputs = serializers.DictField(child=StorageRefSerializer()) + runtime = RuntimeSerializer() + + def validate_inputs(self, value): + # Each input is either a scalar (str/int/float/bool) or a {storage_id, path} object. + for name, v in value.items(): + if isinstance(v, dict): + StorageRefSerializer(data=v).is_valid(raise_exception=True) + return value + + +class PreviewResponseSerializer(serializers.Serializer): + invocation_command = serializers.CharField() + script_contents = serializers.CharField() + warnings = serializers.ListField(child=serializers.CharField(), default=list) diff --git a/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_serializers.py b/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_serializers.py new file mode 100644 index 000000000..b1101d277 --- /dev/null +++ b/airavata-django-portal/django_airavata/apps/api/tests/test_launcher_serializers.py @@ -0,0 +1,62 @@ +import json +from pathlib import Path +from unittest import TestCase + +import jsonschema + +from django_airavata.apps.api import launcher_serializers + + +CONTRACTS = Path(__file__).resolve().parents[4] / "tests" / "contracts" + + +class ExperimentDraftSerializerTest(TestCase): + def _valid_draft(self) -> dict: + return { + "name": "test-run", + "project_id": "proj-1", + "app_id": "namd", + "interface_name": "run", + "inputs": { + "sim_dir": {"storage_id": "my-home", "path": "/home/x/sim"}, + "steps": 1000, + }, + "outputs": {"trajectory": {"storage_id": "my-home", "path": "/home/x/out.dcd"}}, + "runtime": { + "compute_resource_id": "bridges-2", + "partition": "RM", + "walltime": "01:00:00", + "nodes": 1, + "cpus_per_node": 64, + }, + } + + def test_valid_draft_passes_schema(self): + schema = json.loads((CONTRACTS / "experiment-draft.schema.json").read_text()) + jsonschema.validate(self._valid_draft(), schema) + + def test_serializer_accepts_valid_draft(self): + serializer = launcher_serializers.ExperimentDraftSerializer(data=self._valid_draft()) + self.assertTrue(serializer.is_valid(), serializer.errors) + + def test_serializer_rejects_missing_runtime(self): + draft = self._valid_draft() + del draft["runtime"] + serializer = launcher_serializers.ExperimentDraftSerializer(data=draft) + self.assertFalse(serializer.is_valid()) + self.assertIn("runtime", serializer.errors) + + def test_serializer_rejects_bad_walltime(self): + draft = self._valid_draft() + draft["runtime"]["walltime"] = "not-a-time" + serializer = launcher_serializers.ExperimentDraftSerializer(data=draft) + self.assertFalse(serializer.is_valid()) + + +class PreviewResponseSchemaTest(TestCase): + def test_valid_response(self): + schema = json.loads((CONTRACTS / "preview-response.schema.json").read_text()) + jsonschema.validate( + {"invocation_command": "bash run.sh", "script_contents": "#!/bin/bash\n", "warnings": []}, + schema, + ) diff --git a/airavata-django-portal/tests/contracts/experiment-draft.schema.json b/airavata-django-portal/tests/contracts/experiment-draft.schema.json new file mode 100644 index 000000000..0007626c3 --- /dev/null +++ b/airavata-django-portal/tests/contracts/experiment-draft.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ExperimentDraft", + "type": "object", + "required": ["name", "project_id", "app_id", "interface_name", "inputs", "outputs", "runtime"], + "properties": { + "name": {"type": "string", "minLength": 1, "maxLength": 256}, + "project_id": {"type": "string"}, + "description": {"type": "string"}, + "app_id": {"type": "string"}, + "interface_name": {"type": "string"}, + "inputs": { + "type": "object", + "additionalProperties": { + "oneOf": [ + {"type": ["string", "number", "boolean"]}, + { + "type": "object", + "required": ["storage_id", "path"], + "properties": { + "storage_id": {"type": "string"}, + "path": {"type": "string"} + } + } + ] + } + }, + "outputs": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["storage_id", "path"], + "properties": { + "storage_id": {"type": "string"}, + "path": {"type": "string"} + } + } + }, + "runtime": { + "type": "object", + "required": ["compute_resource_id", "partition", "walltime", "nodes", "cpus_per_node"], + "properties": { + "compute_resource_id": {"type": "string"}, + "partition": {"type": "string"}, + "walltime": {"type": "string", "pattern": "^\\d{1,3}:[0-5]\\d:[0-5]\\d$"}, + "nodes": {"type": "integer", "minimum": 1}, + "cpus_per_node": {"type": "integer", "minimum": 1} + } + } + } +} diff --git a/airavata-django-portal/tests/contracts/preview-response.schema.json b/airavata-django-portal/tests/contracts/preview-response.schema.json new file mode 100644 index 000000000..cb734f59c --- /dev/null +++ b/airavata-django-portal/tests/contracts/preview-response.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "PreviewResponse", + "type": "object", + "required": ["invocation_command", "script_contents", "warnings"], + "properties": { + "invocation_command": {"type": "string", "minLength": 1}, + "script_contents": {"type": "string", "minLength": 1}, + "warnings": {"type": "array", "items": {"type": "string"}} + } +}
