blob: 62c2a594f016d5bb0cc807b517528a7cba2770c2 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Regenerate REST resources and compare the contract with a server OpenAPI export.
Usage: python scripts/client_api.py --check
python scripts/client_api.py --write
python scripts/client_api.py --openapi server-openapi.json
"""
import argparse
import ast
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PACKAGE = ROOT / "hugegraph-python-client/src/pyhugegraph"
def render_resources():
root = PACKAGE
contract = json.loads((root / "api/server_contract.json").read_text())
groups = {}
for operation, spec in contract["operations"].items():
group, method = operation.split(".")
groups.setdefault(group, []).append((operation, method, spec))
license = (root / "api/olap.py").read_text().split('"""')[0]
out = (
license + '"""Explicit low-level REST resources for the pinned server contract.\n\n'
"Common workflows are available on the client managers; these resources expose\n"
'every REST operation, including deployment-specific extensions.\n"""\n\n'
"from typing import Any\n\nfrom pyhugegraph.api.common import _UNSET, RestResource\n\n"
)
for group, methods in groups.items():
cls = "".join(x.title() for x in group.split("_")) + "API"
out += "\nclass " + cls + "(RestResource):\n"
for operation, name, op in methods:
pp = [x for x in re.findall(r"{(\w+)}", op["path"]) if x not in {"graphspace", "graph"}]
# Preserve omitted bodies separately from explicit JSON null.
args = ["self"] + [f"{x}: Any" for x in pp]
args += ["*"] if op["body"] or op["query"] or op["headers"] else []
args += ["body: Any = _UNSET"] if op["body"] else []
args += ["authorization: str | None = None"] if op["headers"] else []
args += [f"{q}: Any = None" for q in op["query"] if q not in pp]
args += ["query: dict[str, Any] | None = None"] if op.get("query_passthrough") else []
path_arg = ", path_params={" + ", ".join(repr(x) + ": " + x for x in pp) + "}" if pp else ""
query_arg = ", params={" + ", ".join(repr(q) + ": " + q for q in op["query"]) + "}" if op["query"] else ""
if op.get("query_passthrough"):
query_arg += " | (query or {})"
body_arg = ", body=body" if op["body"] else ""
body_arg += ', headers={"Authorization": authorization}' if op["headers"] else ""
out += (
f"\n def {name}({', '.join(args)}) -> Any:\n"
f' """{op["method"]} /{op["path"]}."""\n'
f" return self.request({operation!r}{path_arg}{query_arg}{body_arg})\n"
)
out += (
"\n\nclass RestManager(RestResource):\n"
' """Complete REST surface; all child resources share the client session."""\n\n'
" def __init__(self, session):\n super().__init__(session)\n"
)
for group in groups:
cls = "".join(x.title() for x in group.split("_")) + "API"
out += f" self.{group} = {cls}(session)\n"
return out
def render_algorithms(contract):
source = (PACKAGE / "api/olap.py").read_text()
marker = " # BEGIN GENERATED ALGORITHM METHODS\n"
prefix, _ = source.split(marker, 1)
methods = []
for name in contract["algorithms"]:
methods.append(
f"\n def {name}(self, parameters: Mapping[str, Any] | None = None) -> dict:\n"
f' """Submit the {name} full-graph algorithm and return its task receipt."""\n'
f' return self.submit("{name}", parameters)\n'
)
return prefix + marker + "".join(methods)
def check_operation_bindings(contract):
errors = []
known = contract["operations"]
for path in (PACKAGE / "api").glob("*.py"):
for node in ast.walk(ast.parse(path.read_text())):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "request"
and node.args
and isinstance(node.args[0], ast.Constant)
):
value = node.args[0].value
if isinstance(value, str) and re.fullmatch(r"[a-z_]+\.[a-z_]+", value) and value not in known:
errors.append(f"{path.name}:{node.lineno}: unknown operation {value}")
return errors
def normalized(path):
return re.sub(r"{[^}]+}", "{}", path.strip("/"))
def normalized_media_types(content):
if not isinstance(content, dict):
return set()
return {media_type.split(";", 1)[0].strip().lower() for media_type in content}
def compare_openapi(document, contract):
if not isinstance(document.get("paths"), dict):
raise ValueError("OpenAPI document must contain a paths object")
expected = {}
for spec in contract["operations"].values():
key = (spec["method"].lower(), normalized(spec["path"]))
body = bool(spec.get("body", False))
# Publishing overrides describe OpenAPI; serialization still uses content_type.
request_media = (
{
media_type.split(";", 1)[0].strip().lower()
for media_type in spec.get("openapi_request_media", [spec["content_type"]])
}
if body
else set()
)
shape = expected.setdefault(key, {"query": set(), "body": False, "request_media": set()})
shape["body"] = shape["body"] or body
shape["request_media"].update(request_media)
shape["query"].update(spec.get("openapi_query", spec["query"]))
actual = {}
for path, item in document["paths"].items():
for method, operation in item.items():
if method.lower() not in {"get", "post", "put", "delete", "patch", "head", "options"}:
continue
params = item.get("parameters", []) + operation.get("parameters", [])
request_body = operation.get("requestBody")
actual[(method.lower(), normalized(path))] = {
"query": {p["name"] for p in params if p.get("in") == "query"},
"body": request_body is not None,
"request_media": normalized_media_types(request_body.get("content", {}))
if isinstance(request_body, dict)
else set(),
}
for spec in contract.get("platform_operations", []):
actual.pop((spec["method"].lower(), normalized(spec["path"])), None)
result = []
for key in sorted(actual.keys() - expected.keys()):
result.append(f"ADDED {key[0].upper()} /{key[1]}")
for key in sorted(expected.keys() - actual.keys()):
result.append(f"MISSING {key[0].upper()} /{key[1]}")
for key in sorted(expected.keys() & actual.keys()):
expected_shape = expected[key]
actual_shape = actual[key]
if actual_shape["query"] != expected_shape["query"]:
result.append(
f"PARAMETERS {key[0].upper()} /{key[1]}: "
f"added={sorted(actual_shape['query'] - expected_shape['query'])}, "
f"missing={sorted(expected_shape['query'] - actual_shape['query'])}"
)
if actual_shape["body"] != expected_shape["body"]:
result.append(
f"BODY {key[0].upper()} /{key[1]}: expected={expected_shape['body']}, actual={actual_shape['body']}"
)
if actual_shape["request_media"] != expected_shape["request_media"]:
result.append(
f"REQUEST MEDIA {key[0].upper()} /{key[1]}: "
f"added={sorted(actual_shape['request_media'] - expected_shape['request_media'])}, "
f"missing={sorted(expected_shape['request_media'] - actual_shape['request_media'])}"
)
return result
def coverage_table(contract):
lines = ["| Python REST entry | Method | Path | Query parameters |", "| --- | --- | --- | --- |"]
for operation, spec in sorted(contract["operations"].items()):
query = ", ".join(spec["query"]) or "—"
if spec.get("query_passthrough"):
query += "; additional query map"
lines.append(f"| `rest.{operation}()` | {spec['method']} | `/{spec['path']}` | {query} |")
return "\n".join(lines) + "\n"
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="check static resources and coverage table")
parser.add_argument("--write", action="store_true", help="regenerate static resources and coverage table")
parser.add_argument("--openapi", type=Path, help="compare an offline server OpenAPI JSON document")
parser.add_argument("--website-root", type=Path, help="also synchronize the bilingual website reference tables")
args = parser.parse_args()
contract = json.loads((PACKAGE / "api/server_contract.json").read_text())
generated = render_resources()
algorithms = render_algorithms(contract)
algorithm_target = PACKAGE / "api/olap.py"
target = PACKAGE / "api/rest.py"
reference = ROOT / "hugegraph-python-client/API_COVERAGE.md"
table = "# REST coverage\n\nServer: `" + contract["server_ref"] + "`\n\n" + coverage_table(contract)
if args.write:
target.write_text(generated)
algorithm_target.write_text(algorithms)
reference.write_text(table)
elif (
ast.dump(ast.parse(target.read_text())) != ast.dump(ast.parse(generated))
or ast.dump(ast.parse(algorithm_target.read_text())) != ast.dump(ast.parse(algorithms))
or not reference.exists()
or reference.read_text() != table
):
parser.exit(1, "Generated API/coverage is stale. Run python scripts/client_api.py --write and format.\n")
errors = check_operation_bindings(contract)
if errors:
parser.exit(1, "\n".join(errors) + "\n")
if args.website_root:
for lang in ("en", "cn"):
page = args.website_root / f"content/{lang}/docs/clients/hugegraph-python-client.md"
source = page.read_text()
start = "<!-- BEGIN REST CONTRACT -->\n"
end = "<!-- END REST CONTRACT -->"
before, tail = source.split(start, 1)
_, after = tail.split(end, 1)
updated = before + start + coverage_table(contract) + "{.wide}\n\n" + end + after
if args.write:
page.write_text(updated)
elif updated != source:
parser.exit(1, f"{page}: stale REST table\n")
if args.openapi:
changes = compare_openapi(json.loads(args.openapi.read_text()), contract)
if changes:
parser.exit(1, "\n".join(changes) + "\n")
sys.stdout.write(
f"{len(contract['operations'])} operations; {len(contract['algorithms'])} OLAP algorithms; "
f"server {contract['server_ref']}\n"
)
if __name__ == "__main__":
main()