compose-runner 0.6.1__py2.py3-none-any.whl → 0.6.2rc1__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.6.1'
32
- __version_tuple__ = version_tuple = (0, 6, 1)
31
+ __version__ = version = '0.6.2rc1'
32
+ __version_tuple__ = version_tuple = (0, 6, 2, 'rc1')
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ from dataclasses import dataclass
6
+ from typing import Any, Dict, Optional
7
+
8
+
9
+ def is_http_event(event: Any) -> bool:
10
+ return isinstance(event, dict) and "requestContext" in event
11
+
12
+
13
+ def _decode_body(event: Dict[str, Any]) -> Optional[str]:
14
+ body = event.get("body")
15
+ if not body:
16
+ return None
17
+ if event.get("isBase64Encoded"):
18
+ return base64.b64decode(body).decode("utf-8")
19
+ return body
20
+
21
+
22
+ def extract_payload(event: Dict[str, Any]) -> Dict[str, Any]:
23
+ if not is_http_event(event):
24
+ return event
25
+ body = _decode_body(event)
26
+ if not body:
27
+ return {}
28
+ return json.loads(body)
29
+
30
+
31
+ def http_response(body: Dict[str, Any], status_code: int = 200) -> Dict[str, Any]:
32
+ return {
33
+ "statusCode": status_code,
34
+ "headers": {"Content-Type": "application/json"},
35
+ "body": json.dumps(body),
36
+ }
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class LambdaRequest:
41
+ raw_event: Any
42
+ payload: Dict[str, Any]
43
+ is_http: bool
44
+
45
+ @classmethod
46
+ def parse(cls, event: Any) -> "LambdaRequest":
47
+ payload = extract_payload(event)
48
+ return cls(raw_event=event, payload=payload, is_http=is_http_event(event))
49
+
50
+ def respond(self, body: Dict[str, Any], status_code: int = 200) -> Dict[str, Any]:
51
+ if self.is_http:
52
+ return http_response(body, status_code)
53
+ return body
54
+
55
+ def bad_request(self, message: str, status_code: int = 400) -> Dict[str, Any]:
56
+ return self.respond({"status": "FAILED", "error": message}, status_code=status_code)
57
+
58
+ def get(self, key: str, default: Any = None) -> Any:
59
+ return self.payload.get(key, default)
60
+
@@ -2,52 +2,30 @@ from __future__ import annotations
2
2
 
3
3
  import os
4
4
  import time
5
- import base64
6
- import json
7
5
  from typing import Any, Dict, List
8
6
 
9
7
  import boto3
10
8
 
9
+ from compose_runner.aws_lambda.common import LambdaRequest
10
+
11
11
  _LOGS_CLIENT = boto3.client("logs", region_name=os.environ.get("AWS_REGION", "us-east-1"))
12
12
 
13
13
  LOG_GROUP_ENV = "RUNNER_LOG_GROUP"
14
14
  DEFAULT_LOOKBACK_MS_ENV = "DEFAULT_LOOKBACK_MS"
15
15
 
16
- def _is_http_event(event: Any) -> bool:
17
- return isinstance(event, dict) and "requestContext" in event
18
-
19
-
20
- def _extract_payload(event: Dict[str, Any]) -> Dict[str, Any]:
21
- if not _is_http_event(event):
22
- return event
23
- body = event.get("body")
24
- if not body:
25
- return {}
26
- if event.get("isBase64Encoded"):
27
- body = base64.b64decode(body).decode("utf-8")
28
- return json.loads(body)
29
-
30
-
31
- def _http_response(body: Dict[str, Any], status_code: int = 200) -> Dict[str, Any]:
32
- return {
33
- "statusCode": status_code,
34
- "headers": {"Content-Type": "application/json"},
35
- "body": json.dumps(body),
36
- }
37
-
38
16
 
39
17
  def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
40
- raw_event = event
41
- event = _extract_payload(event)
42
- job_id = event.get("job_id")
43
- if not job_id:
44
- message = "Request payload must include 'job_id'."
45
- if _is_http_event(raw_event):
46
- return _http_response({"status": "FAILED", "error": message}, status_code=400)
18
+ request = LambdaRequest.parse(event)
19
+ payload = request.payload
20
+ artifact_prefix = payload.get("artifact_prefix")
21
+ if not artifact_prefix:
22
+ message = "Request payload must include 'artifact_prefix'."
23
+ if request.is_http:
24
+ return request.bad_request(message, status_code=400)
47
25
  raise KeyError(message)
48
- next_token = event.get("next_token")
49
- start_time = event.get("start_time")
50
- end_time = event.get("end_time")
26
+ next_token = payload.get("next_token")
27
+ start_time = payload.get("start_time")
28
+ end_time = payload.get("end_time")
51
29
 
52
30
  log_group = os.environ[LOG_GROUP_ENV]
53
31
  lookback_ms = int(os.environ.get(DEFAULT_LOOKBACK_MS_ENV, "3600000"))
@@ -60,7 +38,7 @@ def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
60
38
 
61
39
  params: Dict[str, Any] = {
62
40
  "logGroupName": log_group,
63
- "filterPattern": f'"{job_id}"',
41
+ "filterPattern": f'"{artifact_prefix}"',
64
42
  "startTime": int(start_time),
65
43
  }
66
44
  if end_time is not None:
@@ -75,10 +53,8 @@ def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
75
53
  ]
76
54
 
77
55
  body = {
78
- "job_id": job_id,
56
+ "artifact_prefix": artifact_prefix,
79
57
  "events": events,
80
58
  "next_token": response.get("nextToken"),
81
59
  }
82
- if _is_http_event(raw_event):
83
- return _http_response(body)
84
- return body
60
+ return request.respond(body)
@@ -1,13 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
- import base64
5
- import json
6
4
  from datetime import datetime, timezone
7
5
  from typing import Any, Dict, List
8
6
 
9
7
  import boto3
10
8
 
9
+ from compose_runner.aws_lambda.common import LambdaRequest
10
+
11
11
  _S3 = boto3.client("s3", region_name=os.environ.get("AWS_REGION", "us-east-1"))
12
12
 
13
13
  RESULTS_BUCKET_ENV = "RESULTS_BUCKET"
@@ -21,44 +21,21 @@ def _serialize_dt(value: datetime) -> str:
21
21
  return value.astimezone(timezone.utc).isoformat()
22
22
 
23
23
 
24
- def _is_http_event(event: Any) -> bool:
25
- return isinstance(event, dict) and "requestContext" in event
26
-
27
-
28
- def _extract_payload(event: Dict[str, Any]) -> Dict[str, Any]:
29
- if not _is_http_event(event):
30
- return event
31
- body = event.get("body")
32
- if not body:
33
- return {}
34
- if event.get("isBase64Encoded"):
35
- body = base64.b64decode(body).decode("utf-8")
36
- return json.loads(body)
37
-
38
-
39
- def _http_response(body: Dict[str, Any], status_code: int = 200) -> Dict[str, Any]:
40
- return {
41
- "statusCode": status_code,
42
- "headers": {"Content-Type": "application/json"},
43
- "body": json.dumps(body),
44
- }
45
-
46
-
47
24
  def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
48
- raw_event = event
49
- event = _extract_payload(event)
25
+ request = LambdaRequest.parse(event)
26
+ payload = request.payload
50
27
  bucket = os.environ[RESULTS_BUCKET_ENV]
51
28
  prefix = os.environ.get(RESULTS_PREFIX_ENV)
52
29
 
53
- job_id = event.get("job_id")
54
- if not job_id:
55
- message = "Request payload must include 'job_id'."
56
- if _is_http_event(raw_event):
57
- return _http_response({"status": "FAILED", "error": message}, status_code=400)
30
+ artifact_prefix = payload.get("artifact_prefix")
31
+ if not artifact_prefix:
32
+ message = "Request payload must include 'artifact_prefix'."
33
+ if request.is_http:
34
+ return request.bad_request(message, status_code=400)
58
35
  raise KeyError(message)
59
- expires_in = int(event.get("expires_in", DEFAULT_EXPIRES_IN))
36
+ expires_in = int(payload.get("expires_in", DEFAULT_EXPIRES_IN))
60
37
 
61
- key_prefix = f"{prefix.rstrip('/')}/{job_id}" if prefix else job_id
38
+ key_prefix = f"{prefix.rstrip('/')}/{artifact_prefix}" if prefix else artifact_prefix
62
39
 
63
40
  response = _S3.list_objects_v2(Bucket=bucket, Prefix=key_prefix)
64
41
  contents = response.get("Contents", [])
@@ -84,11 +61,9 @@ def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
84
61
  )
85
62
 
86
63
  body = {
87
- "job_id": job_id,
64
+ "artifact_prefix": artifact_prefix,
88
65
  "artifacts": artifacts,
89
66
  "bucket": bucket,
90
67
  "prefix": key_prefix,
91
68
  }
92
- if _is_http_event(raw_event):
93
- return _http_response(body)
94
- return body
69
+ return request.respond(body)
@@ -3,45 +3,24 @@ from __future__ import annotations
3
3
  import json
4
4
  import logging
5
5
  import os
6
- import base64
7
- from pathlib import Path
8
- from typing import Any, Dict, Iterable, Optional
6
+ import uuid
7
+ from typing import Any, Dict, Optional
9
8
 
10
9
  import boto3
10
+ from botocore.exceptions import ClientError
11
11
 
12
- NUMBA_CACHE_DIR = Path(os.environ.get("NUMBA_CACHE_DIR", "/tmp/numba_cache"))
13
- NUMBA_CACHE_DIR.mkdir(parents=True, exist_ok=True)
14
- os.environ["NUMBA_CACHE_DIR"] = str(NUMBA_CACHE_DIR)
15
-
16
- from compose_runner.run import run as run_compose
12
+ from compose_runner.aws_lambda.common import LambdaRequest
17
13
 
18
14
  logger = logging.getLogger(__name__)
19
15
  logger.setLevel(logging.INFO)
20
16
 
21
- _S3_CLIENT = boto3.client("s3", region_name=os.environ.get("AWS_REGION", "us-east-1"))
22
-
23
-
24
- def _is_http_event(event: Any) -> bool:
25
- return isinstance(event, dict) and "requestContext" in event
17
+ _SFN_CLIENT = boto3.client("stepfunctions", region_name=os.environ.get("AWS_REGION", "us-east-1"))
26
18
 
27
-
28
- def _extract_payload(event: Dict[str, Any]) -> Dict[str, Any]:
29
- if not _is_http_event(event):
30
- return event
31
- body = event.get("body")
32
- if not body:
33
- return {}
34
- if event.get("isBase64Encoded"):
35
- body = base64.b64decode(body).decode("utf-8")
36
- return json.loads(body)
37
-
38
-
39
- def _http_response(body: Dict[str, Any], status_code: int = 200) -> Dict[str, Any]:
40
- return {
41
- "statusCode": status_code,
42
- "headers": {"Content-Type": "application/json"},
43
- "body": json.dumps(body),
44
- }
19
+ STATE_MACHINE_ARN_ENV = "STATE_MACHINE_ARN"
20
+ RESULTS_BUCKET_ENV = "RESULTS_BUCKET"
21
+ RESULTS_PREFIX_ENV = "RESULTS_PREFIX"
22
+ NSC_KEY_ENV = "NSC_KEY"
23
+ NV_KEY_ENV = "NV_KEY"
45
24
 
46
25
 
47
26
  def _log(job_id: str, message: str, **details: Any) -> None:
@@ -50,88 +29,87 @@ def _log(job_id: str, message: str, **details: Any) -> None:
50
29
  logger.info(json.dumps(payload))
51
30
 
52
31
 
53
- def _iter_result_files(result_dir: Path) -> Iterable[Path]:
54
- for path in result_dir.iterdir():
55
- if path.is_file():
56
- yield path
57
-
58
-
59
- def _upload_results(job_id: str, result_dir: Path, bucket: str, prefix: Optional[str]) -> None:
60
- base_prefix = f"{prefix.rstrip('/')}/{job_id}" if prefix else job_id
61
- for file_path in _iter_result_files(result_dir):
62
- key = f"{base_prefix}/{file_path.name}"
63
- _S3_CLIENT.upload_file(str(file_path), bucket, key)
32
+ def _job_input(
33
+ payload: Dict[str, Any],
34
+ artifact_prefix: str,
35
+ bucket: Optional[str],
36
+ prefix: Optional[str],
37
+ nsc_key: Optional[str],
38
+ nv_key: Optional[str],
39
+ ) -> Dict[str, Any]:
40
+ no_upload_flag = bool(payload.get("no_upload", False))
41
+ doc: Dict[str, Any] = {
42
+ "artifact_prefix": artifact_prefix,
43
+ "meta_analysis_id": payload["meta_analysis_id"],
44
+ "environment": payload.get("environment", "production"),
45
+ "no_upload": "true" if no_upload_flag else "false",
46
+ "results": {"bucket": bucket or "", "prefix": prefix or ""},
47
+ }
48
+ n_cores = payload.get("n_cores")
49
+ doc["n_cores"] = str(n_cores) if n_cores is not None else ""
50
+ if nsc_key is not None:
51
+ doc["nsc_key"] = nsc_key
52
+ else:
53
+ doc["nsc_key"] = ""
54
+ if nv_key is not None:
55
+ doc["nv_key"] = nv_key
56
+ else:
57
+ doc["nv_key"] = ""
58
+ return doc
64
59
 
65
60
 
66
61
  def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
67
- raw_event = event
68
- payload = _extract_payload(event)
69
- job_id = context.aws_request_id
62
+ request = LambdaRequest.parse(event)
63
+ payload = request.payload
64
+ if STATE_MACHINE_ARN_ENV not in os.environ:
65
+ raise RuntimeError(f"{STATE_MACHINE_ARN_ENV} environment variable must be set.")
66
+
70
67
  if "meta_analysis_id" not in payload:
71
68
  message = "Request payload must include 'meta_analysis_id'."
72
- _log(job_id, "workflow.failed", error=message)
73
- if _is_http_event(raw_event):
74
- return _http_response(
75
- {"job_id": job_id, "status": "FAILED", "error": message}, status_code=400
76
- )
69
+ if request.is_http:
70
+ return request.bad_request(message, status_code=400)
77
71
  raise KeyError(message)
78
- meta_analysis_id = payload["meta_analysis_id"]
79
- environment = payload.get("environment", "production")
80
- nsc_key = payload.get("nsc_key") or os.environ.get("NSC_KEY")
81
- nv_key = payload.get("nv_key") or os.environ.get("NV_KEY")
82
- no_upload = bool(payload.get("no_upload", False))
83
- n_cores = payload.get("n_cores")
84
72
 
85
- result_dir = Path("/tmp") / job_id
86
- result_dir.mkdir(parents=True, exist_ok=True)
87
-
88
- bucket = os.environ.get("RESULTS_BUCKET")
89
- prefix = os.environ.get("RESULTS_PREFIX")
73
+ artifact_prefix = payload.get("artifact_prefix") or str(uuid.uuid4())
74
+ bucket = os.environ.get(RESULTS_BUCKET_ENV)
75
+ prefix = os.environ.get(RESULTS_PREFIX_ENV)
76
+ nsc_key = payload.get("nsc_key") or os.environ.get(NSC_KEY_ENV)
77
+ nv_key = payload.get("nv_key") or os.environ.get(NV_KEY_ENV)
78
+
79
+ job_input = _job_input(payload, artifact_prefix, bucket, prefix, nsc_key, nv_key)
80
+ params = {
81
+ "stateMachineArn": os.environ[STATE_MACHINE_ARN_ENV],
82
+ "name": artifact_prefix,
83
+ "input": json.dumps(job_input),
84
+ }
90
85
 
91
- _log(
92
- job_id,
93
- "workflow.start",
94
- meta_analysis_id=meta_analysis_id,
95
- environment=environment,
96
- no_upload=no_upload,
97
- )
98
86
  try:
99
- url, _ = run_compose(
100
- meta_analysis_id=meta_analysis_id,
101
- environment=environment,
102
- result_dir=str(result_dir),
103
- nsc_key=nsc_key,
104
- nv_key=nv_key,
105
- no_upload=no_upload,
106
- n_cores=n_cores,
107
- )
108
- _log(job_id, "workflow.completed", result_url=url)
109
-
110
- if bucket:
111
- _upload_results(job_id, result_dir, bucket, prefix)
112
- _log(job_id, "artifacts.uploaded", bucket=bucket, prefix=prefix)
113
-
87
+ response = _SFN_CLIENT.start_execution(**params)
88
+ except _SFN_CLIENT.exceptions.ExecutionAlreadyExists as exc:
89
+ _log(artifact_prefix, "workflow.duplicate", error=str(exc))
114
90
  body = {
115
- "job_id": job_id,
116
- "status": "SUCCEEDED",
117
- "result_url": url,
118
- "artifacts_bucket": bucket,
119
- "artifacts_prefix": prefix,
91
+ "status": "FAILED",
92
+ "error": "A job with the provided artifact_prefix already exists.",
93
+ "artifact_prefix": artifact_prefix,
120
94
  }
121
- if _is_http_event(raw_event):
122
- return _http_response(body)
123
- return body
124
- except Exception as exc: # noqa: broad-except - bubble up but log context
125
- _log(job_id, "workflow.failed", error=str(exc))
126
- if _is_http_event(raw_event):
127
- return _http_response(
128
- {"job_id": job_id, "status": "FAILED", "error": str(exc)}, status_code=500
129
- )
130
- raise
131
- finally:
132
- if os.environ.get("DELETE_TMP", "true").lower() == "true":
133
- for path in _iter_result_files(result_dir):
134
- try:
135
- path.unlink()
136
- except OSError:
137
- _log(job_id, "cleanup.warning", file=str(path))
95
+ if request.is_http:
96
+ return request.respond(body, status_code=409)
97
+ raise ValueError(body["error"]) from exc
98
+ except ClientError as exc:
99
+ _log(artifact_prefix, "workflow.failed_to_queue", error=str(exc))
100
+ message = "Failed to start compose-runner job."
101
+ body = {"status": "FAILED", "error": message}
102
+ if request.is_http:
103
+ return request.respond(body, status_code=500)
104
+ raise RuntimeError(message) from exc
105
+
106
+ execution_arn = response["executionArn"]
107
+ _log(artifact_prefix, "workflow.queued", execution_arn=execution_arn)
108
+
109
+ body = {
110
+ "job_id": execution_arn,
111
+ "artifact_prefix": artifact_prefix,
112
+ "status": "SUBMITTED",
113
+ "status_url": f"/jobs/{execution_arn}",
114
+ }
115
+ return request.respond(body, status_code=202)
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from datetime import datetime
6
+ from typing import Any, Dict, Optional
7
+
8
+ import boto3
9
+ from botocore.exceptions import ClientError
10
+
11
+ from compose_runner.aws_lambda.common import LambdaRequest
12
+
13
+ _SFN = boto3.client("stepfunctions", region_name=os.environ.get("AWS_REGION", "us-east-1"))
14
+ _S3 = boto3.client("s3", region_name=os.environ.get("AWS_REGION", "us-east-1"))
15
+
16
+ RESULTS_BUCKET_ENV = "RESULTS_BUCKET"
17
+ RESULTS_PREFIX_ENV = "RESULTS_PREFIX"
18
+ METADATA_FILENAME = "metadata.json"
19
+
20
+
21
+ def _serialize_dt(value: datetime) -> str:
22
+ return value.astimezone().isoformat()
23
+
24
+
25
+ def _metadata_key(prefix: Optional[str], artifact_prefix: str) -> str:
26
+ if prefix:
27
+ return f"{prefix.rstrip('/')}/{artifact_prefix}/{METADATA_FILENAME}"
28
+ return f"{artifact_prefix}/{METADATA_FILENAME}"
29
+
30
+
31
+ def _load_metadata(bucket: str, prefix: Optional[str], artifact_prefix: str) -> Optional[Dict[str, Any]]:
32
+ key = _metadata_key(prefix, artifact_prefix)
33
+ try:
34
+ response = _S3.get_object(Bucket=bucket, Key=key)
35
+ except ClientError as error:
36
+ if error.response["Error"]["Code"] in {"NoSuchKey", "404"}:
37
+ return None
38
+ raise
39
+ data = response["Body"].read()
40
+ return json.loads(data.decode("utf-8"))
41
+
42
+
43
+ def _parse_output(output: Optional[str]) -> Dict[str, Any]:
44
+ if not output:
45
+ return {}
46
+ try:
47
+ return json.loads(output)
48
+ except json.JSONDecodeError:
49
+ return {"raw_output": output}
50
+
51
+
52
+ def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
53
+ request = LambdaRequest.parse(event)
54
+ payload = request.payload
55
+
56
+ job_id = payload.get("job_id")
57
+ if not job_id:
58
+ message = "Request payload must include 'job_id'."
59
+ if request.is_http:
60
+ return request.bad_request(message, status_code=400)
61
+ raise KeyError(message)
62
+
63
+ try:
64
+ description = _SFN.describe_execution(executionArn=job_id)
65
+ except ClientError as error:
66
+ body = {"status": "FAILED", "error": error.response["Error"]["Message"]}
67
+ if request.is_http:
68
+ status_code = 404 if error.response["Error"]["Code"] == "ExecutionDoesNotExist" else 500
69
+ return request.respond(body, status_code=status_code)
70
+ raise
71
+
72
+ status = description["status"]
73
+ body: Dict[str, Any] = {
74
+ "job_id": job_id,
75
+ "status": status,
76
+ "start_time": _serialize_dt(description["startDate"]),
77
+ }
78
+ if "stopDate" in description:
79
+ body["stop_time"] = _serialize_dt(description["stopDate"])
80
+
81
+ output_doc = _parse_output(description.get("output"))
82
+ body["output"] = output_doc
83
+
84
+ artifact_prefix = description.get("name")
85
+ if not artifact_prefix:
86
+ raise ValueError("Execution does not expose a name; cannot determine artifact prefix.")
87
+ body["artifact_prefix"] = artifact_prefix
88
+
89
+ if status in {"SUCCEEDED", "FAILED"}:
90
+ results_info = output_doc.get("results") or {}
91
+ bucket = results_info.get("bucket") or os.environ.get(RESULTS_BUCKET_ENV)
92
+ prefix = results_info.get("prefix") or os.environ.get(RESULTS_PREFIX_ENV)
93
+
94
+ if bucket and artifact_prefix:
95
+ metadata = _load_metadata(bucket, prefix, artifact_prefix)
96
+ if metadata:
97
+ body["result"] = metadata
98
+
99
+ if status == "FAILED":
100
+ body["error"] = output_doc.get("error")
101
+
102
+ return request.respond(body)
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Iterable, Optional
9
+
10
+ import boto3
11
+
12
+ from compose_runner.run import run as run_compose
13
+
14
+ NUMBA_CACHE_DIR = Path(os.environ.get("NUMBA_CACHE_DIR", "/tmp/numba_cache"))
15
+ NUMBA_CACHE_DIR.mkdir(parents=True, exist_ok=True)
16
+ os.environ["NUMBA_CACHE_DIR"] = str(NUMBA_CACHE_DIR)
17
+
18
+ logger = logging.getLogger("compose_runner.ecs_task")
19
+ handler = logging.StreamHandler(sys.stdout)
20
+ formatter = logging.Formatter("%(message)s")
21
+ handler.setFormatter(formatter)
22
+ logger.addHandler(handler)
23
+ logger.setLevel(logging.INFO)
24
+
25
+ _S3_CLIENT = boto3.client("s3", region_name=os.environ.get("AWS_REGION", "us-east-1"))
26
+
27
+ RESULTS_BUCKET_ENV = "RESULTS_BUCKET"
28
+ RESULTS_PREFIX_ENV = "RESULTS_PREFIX"
29
+ ARTIFACT_PREFIX_ENV = "ARTIFACT_PREFIX"
30
+ META_ANALYSIS_ENV = "META_ANALYSIS_ID"
31
+ ENVIRONMENT_ENV = "ENVIRONMENT"
32
+ NSC_KEY_ENV = "NSC_KEY"
33
+ NV_KEY_ENV = "NV_KEY"
34
+ NO_UPLOAD_ENV = "NO_UPLOAD"
35
+ N_CORES_ENV = "N_CORES"
36
+ DELETE_TMP_ENV = "DELETE_TMP"
37
+ METADATA_FILENAME = "metadata.json"
38
+
39
+
40
+ def _log(artifact_prefix: str, message: str, **details: Any) -> None:
41
+ payload = {"artifact_prefix": artifact_prefix, "message": message, **details}
42
+ logger.info(json.dumps(payload))
43
+
44
+
45
+ def _iter_result_files(result_dir: Path) -> Iterable[Path]:
46
+ for path in result_dir.iterdir():
47
+ if path.is_file():
48
+ yield path
49
+
50
+
51
+ def _upload_results(artifact_prefix: str, result_dir: Path, bucket: str, prefix: Optional[str]) -> None:
52
+ base_prefix = f"{prefix.rstrip('/')}/{artifact_prefix}" if prefix else artifact_prefix
53
+ for file_path in _iter_result_files(result_dir):
54
+ key = f"{base_prefix}/{file_path.name}"
55
+ _S3_CLIENT.upload_file(str(file_path), bucket, key)
56
+
57
+
58
+ def _write_metadata(bucket: str, prefix: Optional[str], artifact_prefix: str, metadata: Dict[str, Any]) -> None:
59
+ base_prefix = f"{prefix.rstrip('/')}/{artifact_prefix}" if prefix else artifact_prefix
60
+ key = f"{base_prefix}/{METADATA_FILENAME}"
61
+ metadata["metadata_key"] = key
62
+ _S3_CLIENT.put_object(
63
+ Bucket=bucket,
64
+ Key=key,
65
+ Body=json.dumps(metadata).encode("utf-8"),
66
+ ContentType="application/json",
67
+ )
68
+
69
+
70
+ def _bool_from_env(value: Optional[str]) -> bool:
71
+ if value is None:
72
+ return False
73
+ return value.lower() in {"1", "true", "t", "yes", "y"}
74
+
75
+
76
+ def main() -> None:
77
+ if ARTIFACT_PREFIX_ENV not in os.environ:
78
+ raise RuntimeError(f"{ARTIFACT_PREFIX_ENV} environment variable must be set.")
79
+ if META_ANALYSIS_ENV not in os.environ:
80
+ raise RuntimeError(f"{META_ANALYSIS_ENV} environment variable must be set.")
81
+
82
+ artifact_prefix = os.environ[ARTIFACT_PREFIX_ENV]
83
+ meta_analysis_id = os.environ[META_ANALYSIS_ENV]
84
+ environment = os.environ.get(ENVIRONMENT_ENV, "production")
85
+ nsc_key = os.environ.get(NSC_KEY_ENV) or None
86
+ nv_key = os.environ.get(NV_KEY_ENV) or None
87
+ no_upload = _bool_from_env(os.environ.get(NO_UPLOAD_ENV))
88
+ n_cores_value = os.environ.get(N_CORES_ENV)
89
+ n_cores = int(n_cores_value) if n_cores_value else None
90
+
91
+ bucket = os.environ.get(RESULTS_BUCKET_ENV)
92
+ prefix = os.environ.get(RESULTS_PREFIX_ENV)
93
+
94
+ result_dir = Path("/tmp") / artifact_prefix
95
+ result_dir.mkdir(parents=True, exist_ok=True)
96
+
97
+ _log(
98
+ artifact_prefix,
99
+ "workflow.start",
100
+ meta_analysis_id=meta_analysis_id,
101
+ environment=environment,
102
+ no_upload=no_upload,
103
+ )
104
+ try:
105
+ url, _ = run_compose(
106
+ meta_analysis_id=meta_analysis_id,
107
+ environment=environment,
108
+ result_dir=str(result_dir),
109
+ nsc_key=nsc_key,
110
+ nv_key=nv_key,
111
+ no_upload=no_upload,
112
+ n_cores=n_cores,
113
+ )
114
+ _log(artifact_prefix, "workflow.completed", result_url=url)
115
+
116
+ metadata: Dict[str, Any] = {
117
+ "artifact_prefix": artifact_prefix,
118
+ "meta_analysis_id": meta_analysis_id,
119
+ "result_url": url,
120
+ "artifacts_bucket": bucket,
121
+ "artifacts_prefix": prefix,
122
+ }
123
+
124
+ if bucket:
125
+ _upload_results(artifact_prefix, result_dir, bucket, prefix)
126
+ _log(artifact_prefix, "artifacts.uploaded", bucket=bucket, prefix=prefix)
127
+ _write_metadata(bucket, prefix, artifact_prefix, metadata)
128
+ _log(artifact_prefix, "metadata.written", bucket=bucket, prefix=prefix)
129
+
130
+ _log(artifact_prefix, "workflow.success", result_url=url)
131
+ except Exception as exc: # noqa: broad-except
132
+ _log(artifact_prefix, "workflow.failed", error=str(exc))
133
+ raise
134
+ finally:
135
+ delete_tmp = _bool_from_env(os.environ.get(DELETE_TMP_ENV, "true"))
136
+ if delete_tmp:
137
+ for path in _iter_result_files(result_dir):
138
+ try:
139
+ path.unlink()
140
+ except OSError:
141
+ _log(artifact_prefix, "cleanup.warning", file=str(path))
142
+
143
+
144
+ if __name__ == "__main__":
145
+ main()
@@ -1,11 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ from datetime import datetime, timezone
4
5
  from typing import Any, Dict
5
6
 
6
- import pytest
7
-
8
- from compose_runner.aws_lambda import log_poll_handler, results_handler, run_handler
7
+ from compose_runner.aws_lambda import log_poll_handler, results_handler, run_handler, status_handler
9
8
 
10
9
 
11
10
  class DummyContext:
@@ -25,42 +24,49 @@ def _make_http_event(payload: Dict[str, Any]) -> Dict[str, Any]:
25
24
 
26
25
 
27
26
  def test_run_handler_http_success(monkeypatch, tmp_path):
28
- called = {}
29
-
30
- def fake_run(**kwargs):
31
- called.update(kwargs)
32
- return "https://result/url", None
27
+ captured = {}
33
28
 
34
- uploads = []
29
+ class FakeSFN:
30
+ def start_execution(self, **kwargs):
31
+ captured.update(kwargs)
32
+ return {"executionArn": "arn:aws:states:us-east-1:123:execution:state-machine:run-123"}
35
33
 
36
- class FakeS3:
37
- def upload_file(self, filename, bucket, key):
38
- uploads.append((filename, bucket, key))
34
+ class exceptions:
35
+ class ExecutionAlreadyExists(Exception):
36
+ ...
39
37
 
40
- monkeypatch.setattr(run_handler, "run_compose", fake_run)
41
- monkeypatch.setattr(run_handler, "_S3_CLIENT", FakeS3())
38
+ monkeypatch.setattr(run_handler, "_SFN_CLIENT", FakeSFN())
39
+ monkeypatch.setenv("STATE_MACHINE_ARN", "arn:aws:states:state-machine")
42
40
  monkeypatch.setenv("RESULTS_BUCKET", "bucket")
43
41
  monkeypatch.setenv("RESULTS_PREFIX", "prefix")
44
42
  monkeypatch.setenv("NSC_KEY", "nsc")
45
43
  monkeypatch.setenv("NV_KEY", "nv")
46
44
 
47
- event = _make_http_event({"meta_analysis_id": "abc123", "environment": "production"})
48
- context = DummyContext("job-456")
45
+ event = _make_http_event(
46
+ {"meta_analysis_id": "abc123", "environment": "production", "artifact_prefix": "artifact-123"}
47
+ )
48
+ context = DummyContext("unused")
49
49
 
50
50
  response = run_handler.handler(event, context)
51
51
  body = json.loads(response["body"])
52
52
 
53
- assert response["statusCode"] == 200
54
- assert body["job_id"] == "job-456"
55
- assert body["status"] == "SUCCEEDED"
56
- assert called["meta_analysis_id"] == "abc123"
57
- assert called["environment"] == "production"
58
- assert called["nsc_key"] == "nsc"
59
- assert called["nv_key"] == "nv"
60
- assert uploads == [] # no files written during test
53
+ assert response["statusCode"] == 202
54
+ assert body["job_id"].startswith("arn:aws:states")
55
+ assert body["artifact_prefix"] == "artifact-123"
56
+ assert body["status"] == "SUBMITTED"
57
+ assert captured["name"] == "artifact-123"
58
+ input_doc = json.loads(captured["input"])
59
+ assert input_doc["artifact_prefix"] == "artifact-123"
60
+ assert input_doc["meta_analysis_id"] == "abc123"
61
+ assert input_doc["environment"] == "production"
62
+ assert input_doc["results"]["bucket"] == "bucket"
63
+ assert input_doc["results"]["prefix"] == "prefix"
64
+ assert input_doc["nsc_key"] == "nsc"
65
+ assert input_doc["nv_key"] == "nv"
61
66
 
62
67
 
63
68
  def test_run_handler_missing_meta_analysis(monkeypatch):
69
+ monkeypatch.setenv("STATE_MACHINE_ARN", "arn:aws:states:state-machine")
64
70
  event = _make_http_event({"environment": "production"})
65
71
  response = run_handler.handler(event, DummyContext())
66
72
  body = json.loads(response["body"])
@@ -80,9 +86,9 @@ def test_log_poll_handler(monkeypatch):
80
86
  monkeypatch.setenv("DEFAULT_LOOKBACK_MS", "1000")
81
87
  monkeypatch.setattr(log_poll_handler, "_LOGS_CLIENT", FakeLogs())
82
88
 
83
- event = {"job_id": "id"}
89
+ event = {"artifact_prefix": "id"}
84
90
  result = log_poll_handler.handler(event, DummyContext())
85
- assert result["job_id"] == "id"
91
+ assert result["artifact_prefix"] == "id"
86
92
  assert result["next_token"] == "token-1"
87
93
  assert result["events"][0]["message"] == events_payload[0]["message"]
88
94
 
@@ -94,7 +100,7 @@ def test_log_poll_handler_http_missing_job_id(monkeypatch):
94
100
  body = json.loads(response["body"])
95
101
  assert response["statusCode"] == 400
96
102
  assert body["status"] == "FAILED"
97
- assert "job_id" in body["error"]
103
+ assert "artifact_prefix" in body["error"]
98
104
 
99
105
 
100
106
  def test_results_handler(monkeypatch):
@@ -119,15 +125,66 @@ def test_results_handler(monkeypatch):
119
125
  monkeypatch.setenv("RESULTS_PREFIX", "prefix")
120
126
  monkeypatch.setattr(results_handler, "_S3", FakeS3())
121
127
 
122
- event = _make_http_event({"job_id": "id"})
128
+ event = _make_http_event({"artifact_prefix": "id"})
123
129
  response = results_handler.handler(event, DummyContext())
124
130
  body = json.loads(response["body"])
125
131
  assert response["statusCode"] == 200
126
- assert body["job_id"] == "id"
132
+ assert body["artifact_prefix"] == "id"
127
133
  assert body["artifacts"][0]["url"] == "https://signed/url"
128
134
  assert body["artifacts"][0]["filename"] == "file1.nii.gz"
129
135
 
130
136
 
137
+ def test_status_handler_succeeded(monkeypatch):
138
+ start = datetime(2024, 1, 1, tzinfo=timezone.utc)
139
+ stop = datetime(2024, 1, 1, 1, tzinfo=timezone.utc)
140
+ output_payload = {"results": {"bucket": "bucket", "prefix": "prefix"}}
141
+
142
+ class FakeBody:
143
+ def __init__(self, data):
144
+ self._data = data
145
+
146
+ def read(self):
147
+ return self._data
148
+
149
+ class FakeSFN:
150
+ def describe_execution(self, **kwargs):
151
+ return {
152
+ "status": "SUCCEEDED",
153
+ "name": "artifact-1",
154
+ "startDate": start,
155
+ "stopDate": stop,
156
+ "output": json.dumps(output_payload),
157
+ }
158
+
159
+ class FakeS3:
160
+ def get_object(self, Bucket, Key):
161
+ assert Bucket == "bucket"
162
+ assert Key == "prefix/artifact-1/metadata.json"
163
+ metadata = {"artifact_prefix": "artifact-1", "result_url": "https://results"}
164
+ return {"Body": FakeBody(json.dumps(metadata).encode("utf-8"))}
165
+
166
+ monkeypatch.setattr(status_handler, "_SFN", FakeSFN())
167
+ monkeypatch.setattr(status_handler, "_S3", FakeS3())
168
+
169
+ event = _make_http_event({"job_id": "arn:execution"})
170
+ response = status_handler.handler(event, DummyContext())
171
+ body = json.loads(response["body"])
172
+
173
+ assert response["statusCode"] == 200
174
+ assert body["status"] == "SUCCEEDED"
175
+ assert body["artifact_prefix"] == "artifact-1"
176
+ assert body["result"]["result_url"] == "https://results"
177
+
178
+
179
+ def test_status_handler_missing_job_id(monkeypatch):
180
+ event = _make_http_event({})
181
+ response = status_handler.handler(event, DummyContext())
182
+ body = json.loads(response["body"])
183
+ assert response["statusCode"] == 400
184
+ assert body["status"] == "FAILED"
185
+ assert "job_id" in body["error"]
186
+
187
+
131
188
  def test_results_handler_missing_job_id(monkeypatch):
132
189
  monkeypatch.setenv("RESULTS_BUCKET", "bucket")
133
190
  event = _make_http_event({})
@@ -135,4 +192,4 @@ def test_results_handler_missing_job_id(monkeypatch):
135
192
  body = json.loads(response["body"])
136
193
  assert response["statusCode"] == 400
137
194
  assert body["status"] == "FAILED"
138
- assert "job_id" in body["error"]
195
+ assert "artifact_prefix" in body["error"]
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: compose-runner
3
+ Version: 0.6.2rc1
4
+ Summary: A package for running neurosynth-compose analyses
5
+ Project-URL: Repository, https://github.com/neurostuff/compose-runner
6
+ Author-email: James Kent <jamesdkent21@gmail.com>
7
+ License: BSD 3-Clause License
8
+ License-File: LICENSE
9
+ Keywords: meta-analysis,neuroimaging,neurosynth,neurosynth-compose
10
+ Classifier: License :: OSI Approved :: BSD License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Dist: click
13
+ Requires-Dist: nimare
14
+ Requires-Dist: numpy
15
+ Requires-Dist: sentry-sdk
16
+ Provides-Extra: aws
17
+ Requires-Dist: boto3; extra == 'aws'
18
+ Provides-Extra: tests
19
+ Requires-Dist: pytest; extra == 'tests'
20
+ Requires-Dist: pytest-recording; extra == 'tests'
21
+ Requires-Dist: vcrpy; extra == 'tests'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # compose-runner
25
+
26
+ Python package to execute meta-analyses created using neurosynth compose and NiMARE
27
+ as the meta-analysis execution engine.
28
+
29
+ ## AWS Deployment
30
+
31
+ This repository includes an AWS CDK application that turns compose-runner into a
32
+ serverless batch pipeline using Step Functions, AWS Lambda, and ECS Fargate.
33
+ The deployed architecture works like this:
34
+
35
+ - `ComposeRunnerSubmit` (Lambda Function URL) accepts HTTP requests, validates
36
+ the meta-analysis payload, and starts a Step Functions execution. The response
37
+ is immediate and returns both a durable `job_id` (the execution ARN) and the
38
+ `artifact_prefix` used for S3 and log correlation.
39
+ - A Standard state machine runs a single Fargate task (`compose_runner.ecs_task`)
40
+ and waits for completion. The container downloads inputs, executes the
41
+ meta-analysis on up to 4 vCPU / 30 GiB of memory, uploads artifacts to S3, and
42
+ writes `metadata.json` into the same prefix.
43
+ - `ComposeRunnerStatus` (Lambda Function URL) wraps `DescribeExecution`, merges
44
+ metadata from S3, and exposes a simple status endpoint suitable for polling.
45
+ - `ComposeRunnerLogPoller` streams the ECS CloudWatch Logs for a given `artifact_prefix`,
46
+ while `ComposeRunnerResultsFetcher` returns presigned URLs for stored artifacts.
47
+
48
+ 1. Create a virtual environment and install the CDK dependencies:
49
+ ```bash
50
+ cd infra/cdk
51
+ python -m venv .venv
52
+ source .venv/bin/activate
53
+ pip install -r requirements.txt
54
+ ```
55
+ 2. (One-time per account/region) bootstrap the CDK environment:
56
+ ```bash
57
+ cdk bootstrap
58
+ ```
59
+ 3. Deploy the stack (supplying the compose-runner version you want baked into the images):
60
+ ```bash
61
+ cdk deploy \
62
+ -c composeRunnerVersion=$(hatch version) \
63
+ -c resultsPrefix=compose-runner/results \
64
+ -c taskCpu=4096 \
65
+ -c taskMemoryMiB=30720
66
+ ```
67
+ Pass `-c resultsBucketName=<bucket>` to use an existing S3 bucket, or omit it
68
+ to let the stack create and retain a dedicated bucket. Additional knobs:
69
+
70
+ - `-c stateMachineTimeoutSeconds=7200` to control the max wall clock per run
71
+ - `-c submitTimeoutSeconds` / `-c statusTimeoutSeconds` / `-c pollTimeoutSeconds`
72
+ to tune Lambda timeouts
73
+ - `-c taskEphemeralStorageGiB` if the default 21 GiB scratch volume is insufficient
74
+
75
+ The deployment builds both the Lambda image (`aws_lambda/Dockerfile`) and the
76
+ Fargate task image (`Dockerfile`), provisions the Step Functions state machine,
77
+ and configures a public VPC so each task has outbound internet access.
78
+ The CloudFormation outputs list the HTTPS endpoints for submission, status,
79
+ logs, and artifact retrieval, alongside the Step Functions ARN.
@@ -1,23 +1,26 @@
1
1
  compose_runner/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- compose_runner/_version.py,sha256=7vNQiXfKffK0nbqts6Xy6-E1b1YOm4EGigvgaHr83o4,704
2
+ compose_runner/_version.py,sha256=PQv64kDBSWybHkMaDVivQRRmALSrws0RGcr_Z0k2xNY,714
3
3
  compose_runner/cli.py,sha256=1tkxFgEe8Yk7VkzE8qxGmCGqLU7UbGin2VaP0AiZkVg,1101
4
+ compose_runner/ecs_task.py,sha256=5-DbbcwfAqqkYRUuPADIlYatp5NK70uJYCo06O3IcdM,4997
4
5
  compose_runner/run.py,sha256=yIh8Fj8dfVKvahRl483qGOsDUoAS1FdsYrKZp_HknGo,18525
5
6
  compose_runner/sentry.py,sha256=pjqwsZrXrKB0cCy-TL-_2eYJIqUU0aV-8e0SWUk-9Xw,320
6
7
  compose_runner/aws_lambda/__init__.py,sha256=yZNXXv7gCPSrtLCEX5Qf4cnzSTS3fHPV6k-SyZwiZIA,48
7
- compose_runner/aws_lambda/log_poll_handler.py,sha256=GXdGmHahH-pizAR7AIGSFotgnmPR8fG6pHaa9SLca78,2516
8
- compose_runner/aws_lambda/results_handler.py,sha256=XsykZ91p_PgcawocPozftDJhGGVbq2KD4nLjRoSqYGU,2714
9
- compose_runner/aws_lambda/run_handler.py,sha256=bJGrtasNd9C7kgAH-k-vO-4m-UKxJ9SMmDApTJ0Mk_c,4563
8
+ compose_runner/aws_lambda/common.py,sha256=cA2G5lO4P8uVBqJaYcU6Y3P3t3syoTmk4SpLKZhAFo8,1688
9
+ compose_runner/aws_lambda/log_poll_handler.py,sha256=eEU-Ra_-17me3e4eqSTd2Nv_qoaOl7zi3kIxD58Tbek,1905
10
+ compose_runner/aws_lambda/results_handler.py,sha256=vSxs4nbWyBmkFFKRGIp5-T4W2hPh9zgj7uNH-e18aW8,2107
11
+ compose_runner/aws_lambda/run_handler.py,sha256=iZW35xqa9FBZQTxRBH0JOpYhQ3Si1eTkxFHB5VJ5drA,3981
12
+ compose_runner/aws_lambda/status_handler.py,sha256=K_VDyPYY3ExiyalDyf35nXi3UZzqj4AenWmlxkzWNXo,3423
10
13
  compose_runner/tests/conftest.py,sha256=ijb1iw724izKMxrvclt5x7LljTGoBfHwSS-jIEUe-sQ,191
11
14
  compose_runner/tests/test_cli.py,sha256=G3Kz7Nbl2voJ_luXPL7E6slkRNF9lmcpZ-nHBAqeL-M,290
12
- compose_runner/tests/test_lambda_handlers.py,sha256=zadS-7HwBX-JZyyatWMSNoDOaSafb11_p1YpvxFgl2E,4757
15
+ compose_runner/tests/test_lambda_handlers.py,sha256=A13q8KEMzxFPdqQYOIEqhYvEwKN1-3SPtGN9nRvLvtU,7115
13
16
  compose_runner/tests/test_run.py,sha256=Nhx7wz8XxQuxy3kT5yoE_S1Hw0Mgmfn8TWYOZXm1_Gg,1795
14
17
  compose_runner/tests/cassettes/test_run/test_download_bundle.yaml,sha256=vgdGDqirjBHosQsspkaN5Ty6XqJbkYUAbGtdImym5xI,79304
15
18
  compose_runner/tests/cassettes/test_run/test_run_database_workflow.yaml,sha256=ay0aHtU-nmVWvbmN_EIgO9MMkC4ZeQljKU8nkTXOoDw,8724312
16
19
  compose_runner/tests/cassettes/test_run/test_run_group_comparison_workflow.yaml,sha256=FaZpMdcaM7TMgyueyZBGftm6ywUh1HhtGmCegXUmRFA,4029712
17
20
  compose_runner/tests/cassettes/test_run/test_run_string_group_comparison_workflow.yaml,sha256=pcn6tQwrimhDtP8yJ3jFlsfEOnk8FWybYQr9IQ5A_KA,3233839
18
21
  compose_runner/tests/cassettes/test_run/test_run_workflow.yaml,sha256=0Nk7eJWAmgYALG2ODrezbRhpYsc00JiuYVjXt3TUm5c,3857234
19
- compose_runner-0.6.1.dist-info/METADATA,sha256=tOKPEgafEThNEAYz7B-vKNP1sS1YIp51PXXcwNOja6s,2443
20
- compose_runner-0.6.1.dist-info/WHEEL,sha256=tkmg4JIqwd9H8mL30xA7crRmoStyCtGp0VWshokd1Jc,105
21
- compose_runner-0.6.1.dist-info/entry_points.txt,sha256=TyPmB9o2tSWw8L3mcach9r2EL7inRVXE9ew3_XReMIY,55
22
- compose_runner-0.6.1.dist-info/licenses/LICENSE,sha256=PeiWxrrRme2rIpPMV9vjgGe7UHEKCIcTb0KagYhnyqo,1313
23
- compose_runner-0.6.1.dist-info/RECORD,,
22
+ compose_runner-0.6.2rc1.dist-info/METADATA,sha256=8_a_K_vWLHqO6Kz-KyARz2NOt0WZ2MfTBf-pHQ2MppE,3435
23
+ compose_runner-0.6.2rc1.dist-info/WHEEL,sha256=tkmg4JIqwd9H8mL30xA7crRmoStyCtGp0VWshokd1Jc,105
24
+ compose_runner-0.6.2rc1.dist-info/entry_points.txt,sha256=TyPmB9o2tSWw8L3mcach9r2EL7inRVXE9ew3_XReMIY,55
25
+ compose_runner-0.6.2rc1.dist-info/licenses/LICENSE,sha256=PeiWxrrRme2rIpPMV9vjgGe7UHEKCIcTb0KagYhnyqo,1313
26
+ compose_runner-0.6.2rc1.dist-info/RECORD,,
@@ -1,62 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: compose-runner
3
- Version: 0.6.1
4
- Summary: A package for running neurosynth-compose analyses
5
- Project-URL: Repository, https://github.com/neurostuff/compose-runner
6
- Author-email: James Kent <jamesdkent21@gmail.com>
7
- License: BSD 3-Clause License
8
- License-File: LICENSE
9
- Keywords: meta-analysis,neuroimaging,neurosynth,neurosynth-compose
10
- Classifier: License :: OSI Approved :: BSD License
11
- Classifier: Programming Language :: Python :: 3
12
- Requires-Dist: click
13
- Requires-Dist: nimare
14
- Requires-Dist: numpy
15
- Requires-Dist: sentry-sdk
16
- Provides-Extra: aws
17
- Requires-Dist: boto3; extra == 'aws'
18
- Provides-Extra: tests
19
- Requires-Dist: pytest; extra == 'tests'
20
- Requires-Dist: pytest-recording; extra == 'tests'
21
- Requires-Dist: vcrpy; extra == 'tests'
22
- Description-Content-Type: text/markdown
23
-
24
- # compose-runner
25
-
26
- Python package to execute meta-analyses created using neurosynth compose and NiMARE
27
- as the meta-analysis execution engine.
28
-
29
- ## AWS Lambda Deployment
30
-
31
- This repository includes an AWS CDK application for provisioning the Lambda-based
32
- execution environment and log polling function.
33
-
34
- 1. Create a virtual environment and install the CDK dependencies:
35
- ```bash
36
- cd infra/cdk
37
- python -m venv .venv
38
- source .venv/bin/activate
39
- pip install -r requirements.txt
40
- ```
41
- 2. (One-time per account/region) bootstrap the CDK environment:
42
- ```bash
43
- cdk bootstrap
44
- ```
45
- 3. Deploy the stack (supplying the compose-runner version you want baked into the Lambda image):
46
- ```bash
47
- cdk deploy \
48
- -c composeRunnerVersion=$(hatch version) \
49
- -c resultsPrefix=compose-runner/results \
50
- -c runMemorySize=3008 \
51
- -c runTimeoutSeconds=900
52
- ```
53
- The deployment output includes HTTPS endpoints for submitting runs (`ComposeRunnerFunctionUrl`), polling logs (`ComposeRunnerLogPollerFunctionUrl`), and fetching presigned S3 URLs (`ComposeRunnerResultsFunctionUrl`).
54
- Omit `resultsBucketName` to let the stack create a managed bucket, or pass an
55
- existing bucket name via `-c resultsBucketName=<bucket>`.
56
-
57
- The deployment builds the Lambda container image from `aws_lambda/Dockerfile`,
58
- creates two functions (`ComposeRunnerFunction` and `ComposeRunnerLogPoller`),
59
- and provisions the S3 bucket used to store generated artifacts (including
60
- `meta_results.pkl`). The log poller function expects clients to call it with a
61
- job ID (the run Lambda invocation request ID) and returns filtered CloudWatch Logs
62
- entries for that job.