diff --git a/scripts/extract_api_model.py b/scripts/extract_api_model.py index 2d03c3f0..5df113fe 100644 --- a/scripts/extract_api_model.py +++ b/scripts/extract_api_model.py @@ -25,6 +25,7 @@ import json import re import sys +import textwrap PACKAGE = "bedrock_agentcore" @@ -43,8 +44,8 @@ ("knowledge-base", "Knowledge Base", "bedrock_agentcore.knowledge_base"), ] -_SECTION_RE = re.compile(r"^\s*(Args|Arguments|Returns|Raises|Example|Examples):\s*$") -_ARG_RE = re.compile(r"^\s+(\w+)\s*(?:\(([^)]+)\))?:\s*(.*)$") +_SECTION_RE = re.compile(r"^\s*(Args|Arguments|Returns|Raises|Example|Examples)(?:\s+\([^)]+\))?:\s*$") +_ARG_RE = re.compile(r"^\s+(\*{0,2}\w+)\s*(?:\(([^)]+)\))?:\s*(.*)$") # The SDK mixes Google-style ("Args:") and reST-style (":param x:") docstrings, # so we also recognize the reST field forms and pull them out of the prose. @@ -52,6 +53,24 @@ _REST_RETURNS_RE = re.compile(r"^\s*:returns?:\s*(.*)$") _REST_RAISES_RE = re.compile(r"^\s*:raises?\s+([\w.]+):\s*(.*)$") +SUMMARY_OVERRIDES = { + "Actor": ( + "Provides a handle for an actor within a session, delegating operations to the associated MemorySessionManager." + ), + "ActorProfile": "Describes the simulated actor's identity and objective.", + "AgentCoreRuntimeClient": "Generates WebSocket authentication for Amazon Bedrock AgentCore runtime.", + "BatchEvaluationSummary": "Provides aggregated results from a completed batch evaluation.", + "CodeInterpreter": "Provides a client for the Amazon Bedrock AgentCore Code Interpreter sandbox service.", + "ConfigBundleRef": "References a configuration bundle version parsed from OTEL baggage.", + "MemorySession": "Represents a single Amazon Bedrock AgentCore MemorySession resource.", + "RuntimeClient": "Generates WebSocket authentication for Amazon Bedrock AgentCore runtime.", + "delete_all_long_term_memories_in_namespace": ("Deletes all long-term memory records in the specified namespace."), +} + +DESCRIPTION_OVERRIDES = { + "delete_all_long_term_memories_in_namespace": "", +} + def extract_rest_fields(lines, result): """Pull reST field lines (:param:/:returns:/:raises:) out of `lines`. @@ -100,7 +119,8 @@ def parse_google_docstring(doc): result["summary"] = " ".join(summary) section = "description" - desc, example_buf = [], [] + desc, example_bufs = [], [] + example_buf = None while i < len(lines): line = lines[i] m = _SECTION_RE.match(line) @@ -114,6 +134,9 @@ def parse_google_docstring(doc): "example": "example", "examples": "example", }[name] + if section == "example": + example_buf = [] + example_bufs.append(example_buf) i += 1 continue if section == "description": @@ -125,7 +148,7 @@ def parse_google_docstring(doc): { "name": am.group(1), "type": (am.group(2) or "").strip() or None, - "required": "optional" not in (am.group(2) or "").lower(), + "required": not am.group(1).startswith("*") and "optional" not in (am.group(2) or "").lower(), "description": am.group(3).strip(), } ) @@ -141,6 +164,8 @@ def parse_google_docstring(doc): am = _ARG_RE.match(line) if am: result["raises"].append({"type": am.group(1), "description": am.group(3).strip()}) + elif result["raises"] and line.strip(): + result["raises"][-1]["description"] += " " + line.strip() elif section == "example": example_buf.append(line) i += 1 # always advance — non-header branches above don't, else infinite loop @@ -149,8 +174,8 @@ def parse_google_docstring(doc): # instead of, or mixed with, Google sections. Pull those out of the prose. desc = extract_rest_fields(desc, result) result["description"] = "\n".join(desc).strip() - if example_buf: - code = "\n".join(example_buf).strip() + for example_buf in example_bufs: + code = textwrap.dedent("\n".join(example_buf)).strip() # strip a leading ```python fence if the docstring used one code = re.sub(r"^```\w*\n?|\n?```$", "", code).strip() if code: @@ -177,6 +202,12 @@ def _own_docstring(obj): def entry_from_object(name, obj): """Build a doc-model entry for a class or function.""" doc = parse_google_docstring(_own_docstring(obj)) + if name in SUMMARY_OVERRIDES: + doc["summary"] = SUMMARY_OVERRIDES[name] + if name in DESCRIPTION_OVERRIDES: + doc["description"] = DESCRIPTION_OVERRIDES[name] + if name == "__init__" and doc["summary"] == "Represents an actor within a session.": + doc["summary"] = "Initializes an Actor instance for the specified session." try: signature = inspect.signature(obj) # Drop the implicit `self`/`cls` receiver from method signatures. diff --git a/scripts/render_adoc.py b/scripts/render_adoc.py index 38ba71f8..c47403c8 100644 --- a/scripts/render_adoc.py +++ b/scripts/render_adoc.py @@ -55,12 +55,108 @@ SCHEMA_VERSION = 1 +def normalize_style(text): + """Apply style-safe substitutions to generated prose.""" + if not text: + return "" + text = re.sub(r"\be\.g\.(?:,)?", "for example,", text, flags=re.IGNORECASE) + text = re.sub( + r"\bAWS (?:Bedrock(?: AgentCore)? )?Code\s*Interpreter\b", + "Amazon Bedrock AgentCore Code Interpreter", + text, + flags=re.IGNORECASE, + ) + text = text.replace( + "Bedrock AgentCore Policy Engine client.", + "Policy Engine client for Amazon Bedrock AgentCore.", + ) + text = text.replace( + "Client for Bedrock AgentCore Policy Engine operations.", + "Provides a client for Policy in AgentCore.", + ) + text = re.sub(r"\bAWS Bedrock AgentCore\b", "Amazon Bedrock AgentCore", text) + text = re.sub(r"\bAWS Bedrock\b", "Amazon Bedrock", text) + text = re.sub( + r"(? `Foo` text = _RST_ROLE_RE.sub(r"`\1`", text) - return text + return normalize_style(text) _ADOC_ADMONITION_RE = re.compile( @@ -171,7 +267,7 @@ def render_params(params, out): req = "" if p.get("required") else " _(optional)_" typ = f"`{p['type']}`" if p.get("type") else "" out.append(f"`{p['name']}`{req} {typ}::") - out.append(esc(clean_rst(p.get("description", ""))) or "_No description._") + out.append(esc(normalize_param_description(p.get("description", ""))) or "_No description._") out.append("") diff --git a/tests/unit/scripts/test_extract_api_model.py b/tests/unit/scripts/test_extract_api_model.py new file mode 100644 index 00000000..72cb17dd --- /dev/null +++ b/tests/unit/scripts/test_extract_api_model.py @@ -0,0 +1,87 @@ +"""Tests for the Python API doc-model extractor.""" + +import importlib.util +from pathlib import Path + +_EXTRACT_PATH = Path(__file__).resolve().parents[3] / "scripts" / "extract_api_model.py" +_spec = importlib.util.spec_from_file_location("extract_api_model", _EXTRACT_PATH) +extract_api_model = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(extract_api_model) + + +def test_multiline_fields_and_named_examples_are_preserved(): + doc = """Run an evaluation. + + Args: + wait_config: Optional WaitConfig for polling behavior. + *args: Positional arguments forwarded to the API. + **kwargs: Arguments forwarded to the API. + + Returns: + A list of spans. + + Raises: + ValueError: If the dataset is empty or all scenarios fail during + execution. + + Example (Runtime agent): + >>> run("runtime") + >>> run("runtime-again") + + Example (Custom agent): + >>> run("custom") + """ + + parsed = extract_api_model.parse_google_docstring(doc) + + assert [param["name"] for param in parsed["params"]] == ["wait_config", "*args", "**kwargs"] + assert [param["required"] for param in parsed["params"]] == [True, False, False] + assert parsed["returns"]["description"] == "A list of spans." + assert parsed["raises"][0]["description"] == ("If the dataset is empty or all scenarios fail during execution.") + assert [example["code"] for example in parsed["examples"]] == [ + '>>> run("runtime")\n>>> run("runtime-again")', + '>>> run("custom")', + ] + + +def test_public_class_summaries_use_action_verbs(): + class Actor: + """Represents an actor within a session.""" + + Actor.__module__ = "bedrock_agentcore.memory" + + entry = extract_api_model.entry_from_object("Actor", Actor) + + assert entry["summary"].startswith("Provides a handle") + + +def test_internal_batch_size_is_removed_from_public_method_description(): + def delete_all_long_term_memories_in_namespace(): + """Delete all records. + + This method processes records in chunks of 100. + """ + + entry = extract_api_model.entry_from_object( + "delete_all_long_term_memories_in_namespace", + delete_all_long_term_memories_in_namespace, + ) + + assert entry["summary"] == "Deletes all long-term memory records in the specified namespace." + assert entry["description"] == "" + + +def test_public_service_classes_use_full_service_names(): + class MemorySession: + """Represents a single, AgentCore MemorySession resource.""" + + class CodeInterpreter: + """Client for interacting with the AgentCore Code Interpreter sandbox service.""" + + memory_entry = extract_api_model.entry_from_object("MemorySession", MemorySession) + interpreter_entry = extract_api_model.entry_from_object("CodeInterpreter", CodeInterpreter) + + assert memory_entry["summary"] == "Represents a single Amazon Bedrock AgentCore MemorySession resource." + assert interpreter_entry["summary"] == ( + "Provides a client for the Amazon Bedrock AgentCore Code Interpreter sandbox service." + ) diff --git a/tests/unit/scripts/test_render_adoc.py b/tests/unit/scripts/test_render_adoc.py index 1d159b20..e4a0c0fb 100644 --- a/tests/unit/scripts/test_render_adoc.py +++ b/tests/unit/scripts/test_render_adoc.py @@ -58,6 +58,66 @@ def test_indented_fence_is_handled(self): def test_plain_prose_passthrough(self): assert render_adoc.render_prose("just text") == ["just text"] + def test_generated_prose_uses_aws_style(self): + out = "\n".join( + render_adoc.render_prose( + "This AWS client is experimental. " + "This feature is in preview and may change in future releases. " + "Use a value (e.g., example)." + ) + ) + assert "might change" in out + assert "for example, example" in out + assert "This {aws} client" in out + assert " may change" not in out + assert "e.g." not in out + + @pytest.mark.parametrize( + ("source", "expected"), + [ + ("AWS Bedrock AgentCore client.", "Amazon Bedrock AgentCore client."), + ("Bedrock AgentCore memory module.", "Amazon Bedrock AgentCore memory module."), + ("The AWS region being used.", "The {aws} Region being used."), + ("AgentCore Memory client.", "AgentCore memory client."), + ("AgentCore Runtime endpoint.", "Amazon Bedrock AgentCore runtime endpoint."), + ( + "AgentCore runtime endpoint.", + "Amazon Bedrock AgentCore runtime endpoint.", + ), + ( + "AWS Bedrock Code Interpreter.", + "Amazon Bedrock AgentCore Code Interpreter.", + ), + ( + "AWS Code Interpreter.", + "Amazon Bedrock AgentCore Code Interpreter.", + ), + ( + "Retrieves an API key from AgentCore Identity.", + "Retrieves an API key from Amazon Bedrock AgentCore Identity.", + ), + ( + "Provides credentials, allowing applications to connect.", + "Provides credentials so applications can connect.", + ), + ("Bedrock AgentCore SDK tools.", "Amazon Bedrock AgentCore Python SDK tools."), + ( + "Bedrock AgentCore Policy Engine client.", + "Policy Engine client for Amazon Bedrock AgentCore.", + ), + ( + "If both values are set, validation will ensure they match.", + "If both values are set, validation ensures they match.", + ), + ( + "Delete all long-term memory records within a specific namespace.", + "Deletes all long-term memory records in the specified namespace.", + ), + ], + ) + def test_generated_prose_normalizes_service_names_and_voice(self, source, expected): + assert render_adoc.render_prose(source) == [expected] + class TestRenderEntry: def test_no_fence_leaks_in_description(self): @@ -79,6 +139,34 @@ def test_params_render_as_definition_list(self): assert "`name`" in adoc assert "The name." in adoc + @pytest.mark.parametrize( + ("source", "expected"), + [ + ("Optional WaitConfig.", "An optional WaitConfig."), + ("Optional parameters for the request.", "Optional parameters for the request."), + ("Optional tags.", "Optional tags."), + ("AWS region.", "The {aws} Region."), + ("id of the actor", "The ID of the actor"), + ("Behaviour manager.", "The behavior manager."), + ("Memory resource ID", "The memory resource ID"), + ("Strategy name.", "The name of the memory strategy."), + ], + ) + def test_parameter_descriptions_use_aws_style(self, source, expected): + adoc = _render( + _entry( + params=[ + { + "name": "value", + "type": "str", + "required": True, + "description": source, + } + ] + ) + ) + assert expected in adoc + def test_example_stray_fence_stripped(self): # A closing fence plus trailing prose swept into the example must not leak. code = "a = 1\n```\nNotes: not code."