blob: 2c5a623578143386db5f81907672f386dfa74a4f [file]
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "grpcio-tools==1.80.0",
# "googleapis-common-protos>=1.62.0",
# ]
# ///
"""Regenerate airavata/model + airavata/services from the airavata-api protos.
Run: `uv run --script codegen.py` (needs an airavata-api sibling checkout).
"""
from __future__ import annotations
import os
import shutil
import sys
import tempfile
from pathlib import Path
SDK_ROOT = Path(__file__).resolve().parent
PROTO_API_ROOT = SDK_ROOT.parent / "airavata-api"
MODEL_PROTO_ROOT = PROTO_API_ROOT / "src" / "main" / "proto"
AIRAVATA_PKG = SDK_ROOT / "airavata"
MODEL_OUT = AIRAVATA_PKG / "model"
SERVICES_OUT = AIRAVATA_PKG / "services"
SMOKE_CHECKS = [
(SERVICES_OUT / "experiment_service_pb2_grpc.py", "GetFullExperiment"),
(SERVICES_OUT / "experiment_set_service_pb2_grpc.py", "ExperimentSetServiceStub"),
(MODEL_OUT / "commons" / "commons_pb2.pyi", "AccessFlags"),
]
def _fail(msg: str) -> None:
sys.stderr.write(f"codegen: {msg}\n")
sys.exit(1)
def _underscore(name: str) -> str:
return name.replace("-", "_")
def _service_protos() -> list[Path]:
protos = [
p
for p in PROTO_API_ROOT.glob("*/src/main/proto/*.proto")
if "/org/" not in p.as_posix()
]
if not protos:
_fail(f"no service protos found under {PROTO_API_ROOT}/*/src/main/proto/")
return sorted(protos)
def _rewrite_proto_imports(text: str) -> str:
# flat sibling-service import -> services/<underscored>.proto
out = []
for line in text.splitlines(keepends=True):
stripped = line.strip()
if stripped.startswith("import \"") and "/" not in stripped[len("import \""):]:
inner = stripped[len("import \"") : stripped.index("\";", len("import \""))]
out.append(f'import "services/{_underscore(inner)}";\n')
else:
out.append(line)
return "".join(out)
def _rewrite_py(text: str) -> str:
# rewrite only the import lines + the BuildTop module-name; never the
# serialized descriptors (they carry the org.apache.airavata.* wire identity).
out = []
for line in text.splitlines(keepends=True):
if line.startswith("from org.apache.airavata."):
line = "from airavata." + line[len("from org.apache.airavata."):]
elif line.startswith("from services import") or line.startswith("from services."):
line = "from airavata." + line[len("from "):]
elif "BuildTopDescriptorsAndMessages(DESCRIPTOR, 'org.apache.airavata." in line:
line = line.replace(
"BuildTopDescriptorsAndMessages(DESCRIPTOR, 'org.apache.airavata.",
"BuildTopDescriptorsAndMessages(DESCRIPTOR, 'airavata.",
)
out.append(line)
return "".join(out)
def _relocate_and_rewrite(gen_dir: Path) -> None:
for d in (MODEL_OUT, SERVICES_OUT):
if d.exists():
shutil.rmtree(d)
model_src = gen_dir / "org" / "apache" / "airavata" / "model"
if not model_src.is_dir():
_fail(f"protoc produced no model tree at {model_src}")
services_src = gen_dir / "services"
if not services_src.is_dir():
_fail(f"protoc produced no services tree at {services_src}")
def _copy_tree(src: Path, dst: Path) -> None:
for f in sorted(src.rglob("*")):
if f.is_dir():
continue
dest = dst / f.relative_to(src)
dest.parent.mkdir(parents=True, exist_ok=True)
if f.suffix in (".py", ".pyi"):
dest.write_text(_rewrite_py(f.read_text()))
else:
shutil.copy2(f, dest)
_copy_tree(model_src, MODEL_OUT)
_copy_tree(services_src, SERVICES_OUT)
for root in (MODEL_OUT, SERVICES_OUT):
for d in [root, *[p for p in root.rglob("*") if p.is_dir()]]:
init = d / "__init__.py"
if not init.exists():
init.write_text("")
def _proto_includes() -> list[str]:
import grpc_tools
includes = []
builtin = Path(grpc_tools.__file__).resolve().parent / "_proto"
if builtin.is_dir():
includes.append(str(builtin))
for entry in sys.path:
cand = Path(entry) / "google" / "api" / "annotations.proto"
if cand.is_file():
includes.append(entry)
break
else:
_fail("google/api/annotations.proto not found; install googleapis-common-protos")
return includes
def _stage(stage: Path) -> None:
src_org = MODEL_PROTO_ROOT / "org"
if not src_org.is_dir():
_fail(f"model proto root not found: {src_org}")
shutil.copytree(src_org, stage / "org")
services_dir = stage / "services"
services_dir.mkdir(parents=True)
for proto in _service_protos():
dest = services_dir / _underscore(proto.name)
dest.write_text(_rewrite_proto_imports(proto.read_text()))
def _run_protoc(stage: Path, out_dir: Path, rel_protos: list[str], includes: list[str]) -> None:
from grpc_tools import protoc
args = [
"grpc_tools.protoc",
f"--proto_path={stage}",
*[f"--proto_path={inc}" for inc in includes],
f"--python_out={out_dir}",
f"--grpc_python_out={out_dir}",
f"--pyi_out={out_dir}",
*rel_protos,
]
prev = os.getcwd()
os.chdir(stage)
try:
rc = protoc.main(args)
finally:
os.chdir(prev)
if rc != 0:
_fail(f"protoc exited {rc} for: {' '.join(rel_protos[:3])}...")
def main() -> None:
if not PROTO_API_ROOT.is_dir():
_fail(f"airavata-api not found at {PROTO_API_ROOT} (sibling checkout required)")
AIRAVATA_PKG.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="airavata-codegen-") as tmp:
stage = Path(tmp) / "stage"
gen = Path(tmp) / "gen"
gen.mkdir(parents=True)
_stage(stage)
includes = _proto_includes()
org_protos = [
str(p.relative_to(stage)) for p in sorted((stage / "org").rglob("*.proto"))
]
svc_protos = [
str(p.relative_to(stage))
for p in sorted((stage / "services").glob("*.proto"))
]
print(f"codegen: {len(org_protos)} model protos, {len(svc_protos)} service protos")
# two passes (models, then services); a combined run fails protoc path relativization
_run_protoc(stage, gen, org_protos, includes)
_run_protoc(stage, gen, svc_protos, includes)
_relocate_and_rewrite(gen)
failures = [
f"{path.relative_to(SDK_ROOT)} missing '{token}'"
for path, token in SMOKE_CHECKS
if not (path.is_file() and token in path.read_text())
]
if failures:
_fail("smoke check failed: " + "; ".join(failures))
print(f"codegen: regenerated {MODEL_OUT.relative_to(SDK_ROOT)} + {SERVICES_OUT.relative_to(SDK_ROOT)}")
if __name__ == "__main__":
main()