add:output api call details and curl

This commit is contained in:
gongwenxin
2025-05-28 17:58:20 +08:00
parent 936714242f
commit 0585dd0b29
14 changed files with 11177 additions and 3006 deletions
+193 -43
View File
@@ -1,6 +1,15 @@
"""API Caller Module"""
import requests
from typing import Any, Dict, Optional, Union, List
import json # Added for cURL body pretty printing
from typing import Any, Dict, Optional, Union, List, Tuple # Added Tuple
import shlex # Added for shell quoting
import urllib.parse # Moved import to top level for reuse
# Attempt to import curlify, if not found, the function will raise an error or fallback
try:
import curlify
except ImportError:
curlify = None # So we can check its availability
from pydantic import BaseModel, Field, HttpUrl
@@ -29,6 +38,19 @@ class APIResponse(BaseModel):
json_content: Optional[Any] = None # Parsed JSON content if applicable
elapsed_time: float # in seconds
class APICallDetail(BaseModel):
"""Model to store detailed information about a single API call for logging."""
request_method: str
request_url: str
request_headers: Dict[str, str]
request_params: Optional[Dict[str, Any]] = None
request_body: Optional[Any] = None
curl_command: str
response_status_code: int
response_headers: Dict[str, str]
response_body: Optional[Any] = None # Could be str for non-JSON, or parsed JSON
response_elapsed_time: float
class APICaller:
"""
Responsible for executing HTTP/S API calls to the DDMS services.
@@ -37,8 +59,52 @@ class APICaller:
def __init__(self, default_timeout: int = 30, default_headers: Optional[Dict[str, str]] = None):
self.default_timeout = default_timeout
self.default_headers = default_headers or {}
# Session object can be initialized here if we want to reuse it across calls,
# but for curlify, a temporary one in _generate_curl_command is fine.
self._session_for_curlify = requests.Session()
def call_api(self, request_data: APIRequest) -> APIResponse:
def _generate_curl_command(self, request_data: APIRequest, actual_headers: Dict[str, str]) -> str:
"""Generates an equivalent cURL command using the curlify library."""
if curlify is None:
# Fallback or error message if curlify is not installed
# For now, returning a simple message. Ideally, log this.
print("ERROR: curlify library is not installed. Cannot generate cURL command.")
return "curlify_not_installed"
# Construct the full URL with parameters for the Request object
url_with_params = str(request_data.url)
if request_data.params:
query_string = urllib.parse.urlencode(request_data.params)
url_with_params = f"{url_with_params}?{query_string}"
# Create a requests.Request object
# Note: requests.Request takes 'data' for form data and 'json' for json body.
# Our APIRequest has json_data (preferred) and data.
req = requests.Request(
method=request_data.method.upper(),
url=url_with_params,
headers=actual_headers, # actual_headers already includes defaults + request-specific
data=request_data.data, # Pass form data if present
json=request_data.json_data # Pass json data if present (requests handles one or the other)
)
# Prepare the request using a session (needed by curlify)
# Using the session from the APICaller instance
prepared_request = self._session_for_curlify.prepare_request(req)
try:
# Generate cURL command using curlify
# Adding verify=False to match current requests.request(verify=False) behavior
# compressed=True by default in curlify, which is usually fine (adds --compressed)
curl_command_str = curlify.to_curl(prepared_request, verify=False)
print(f"DEBUG: curlify generated command (raw): {curl_command_str}") # Debug print
print(f"DEBUG: curlify generated command (repr): {repr(curl_command_str)}") # Added repr print
return curl_command_str
except Exception as e:
print(f"ERROR: Failed to generate cURL command with curlify: {e}")
return f"curlify_generation_failed: {e}"
def call_api(self, request_data: APIRequest) -> Tuple[APIResponse, APICallDetail]: # Modified return type
"""
Makes an API call based on the provided request data.
@@ -46,15 +112,24 @@ class APICaller:
request_data: An APIRequest Pydantic model instance.
Returns:
An APIResponse Pydantic model instance.
A tuple containing:
- An APIResponse Pydantic model instance.
- An APICallDetail Pydantic model instance.
"""
merged_headers = {**self.default_headers, **(request_data.headers or {})}
timeout = request_data.timeout or self.default_timeout
json_payload = request_data.json_data
# Generate cURL command before making the request
curl_command = self._generate_curl_command(request_data, merged_headers)
request_body_for_log = None
if json_payload is not None:
request_body_for_log = json_payload
elif request_data.data is not None:
request_body_for_log = request_data.data # Store as is, might be dict or str
try:
# 如果提供了 body,使用它作为 json 参数
json_payload = request_data.json_data
response = requests.request(
method=request_data.method.upper(),
url=str(request_data.url),
@@ -63,47 +138,112 @@ class APICaller:
json=json_payload,
data=request_data.data,
timeout=timeout,
verify=False
verify=False # As per original code
)
# 不立即引发异常,而是捕获状态码
status_code = response.status_code
json_content = None
try:
if response.headers.get('Content-Type', '').startswith('application/json'):
json_content = response.json()
except requests.exceptions.JSONDecodeError:
# Not a JSON response or invalid JSON, that's fine for some cases.
pass
return APIResponse(
status_code = response.status_code
response_headers_dict = dict(response.headers)
response_content_bytes = response.content
response_elapsed_time = response.elapsed.total_seconds()
parsed_json_content = None
response_body_for_log: Any = response_content_bytes.decode('utf-8', errors='replace') # Default to decoded string
try:
if response_headers_dict.get('Content-Type', '').startswith('application/json'):
parsed_json_content = response.json()
response_body_for_log = parsed_json_content # If JSON, log the parsed JSON
except requests.exceptions.JSONDecodeError:
pass # Keep response_body_for_log as decoded string
api_response = APIResponse(
status_code=status_code,
headers=dict(response.headers),
content=response.content,
json_content=json_content,
elapsed_time=response.elapsed.total_seconds()
headers=response_headers_dict,
content=response_content_bytes,
json_content=parsed_json_content,
elapsed_time=response_elapsed_time
)
api_call_detail = APICallDetail(
request_method=request_data.method.upper(),
request_url=str(request_data.url),
request_headers=merged_headers,
request_params=request_data.params,
request_body=request_body_for_log,
curl_command=curl_command,
response_status_code=status_code,
response_headers=response_headers_dict,
response_body=response_body_for_log,
response_elapsed_time=response_elapsed_time
)
return api_response, api_call_detail
except requests.exceptions.HTTPError as e:
# 处理 HTTP 错误
print(f"API call to {request_data.url} failed: {e}")
return APIResponse(
status_code=e.response.status_code,
headers=dict(e.response.headers),
content=e.response.content,
json_content=None,
elapsed_time=e.response.elapsed.total_seconds()
# For HTTPError, response object should exist
status_code = e.response.status_code if e.response else 500
response_headers_dict = dict(e.response.headers) if e.response else {}
response_content_bytes = e.response.content if e.response else str(e).encode()
response_elapsed_time = e.response.elapsed.total_seconds() if e.response and hasattr(e.response, 'elapsed') else 0
response_body_for_log = response_content_bytes.decode('utf-8', errors='replace')
# Try to parse JSON from error response if possible, for logging
parsed_json_content_error = None
if response_headers_dict.get('Content-Type', '').startswith('application/json'):
try:
parsed_json_content_error = json.loads(response_body_for_log) # use json.loads for string
response_body_for_log = parsed_json_content_error
except json.JSONDecodeError:
pass
api_response_err = APIResponse(
status_code=status_code,
headers=response_headers_dict,
content=response_content_bytes,
json_content=parsed_json_content_error, # Log parsed JSON if available
elapsed_time=response_elapsed_time
)
api_call_detail_err = APICallDetail(
request_method=request_data.method.upper(),
request_url=str(request_data.url),
request_headers=merged_headers,
request_params=request_data.params,
request_body=request_body_for_log, # request body for log
curl_command=curl_command,
response_status_code=status_code,
response_headers=response_headers_dict,
response_body=response_body_for_log, # Log decoded string or parsed JSON
response_elapsed_time=response_elapsed_time
)
return api_response_err, api_call_detail_err
except requests.exceptions.RequestException as e:
# 处理其他请求异常
print(f"API call to {request_data.url} failed: {e}")
return APIResponse(
status_code=getattr(e.response, 'status_code', 500),
headers=dict(getattr(e.response, 'headers', {})),
content=str(e).encode(),
# For other RequestExceptions, e.response might not exist or be partial
status_code = getattr(e.response, 'status_code', 503) # 503 Service Unavailable seems fitting
response_headers_dict = dict(getattr(e.response, 'headers', {}))
response_content_str = str(e)
response_content_bytes = response_content_str.encode()
api_response_exc = APIResponse(
status_code=status_code,
headers=response_headers_dict,
content=response_content_bytes,
json_content=None,
elapsed_time=0
)
api_call_detail_exc = APICallDetail(
request_method=request_data.method.upper(),
request_url=str(request_data.url),
request_headers=merged_headers,
request_params=request_data.params,
request_body=request_body_for_log, # request body for log
curl_command=curl_command,
response_status_code=status_code,
response_headers=response_headers_dict,
response_body=response_content_str, # Log the error string
response_elapsed_time=0
)
return api_response_exc, api_call_detail_exc
# Example Usage (can be moved to tests or main application logic)
if __name__ == '__main__':
@@ -115,13 +255,15 @@ if __name__ == '__main__':
url=HttpUrl("https://jsonplaceholder.typicode.com/todos/1"),
headers={"X-Request-ID": "12345"}
)
response = caller.call_api(get_req_data)
response, detail = caller.call_api(get_req_data) # Unpack two values now
print("GET Response:")
if response.json_content:
print(f"Status: {response.status_code}, Data: {response.json_content}")
else:
print(f"Status: {response.status_code}, Content: {response.content.decode()}")
print(f"Time taken: {response.elapsed_time:.4f}s")
print("GET Call Detail:")
print(detail.model_dump_json(indent=2)) # Use model_dump_json for pretty print
print("\n")
@@ -132,13 +274,15 @@ if __name__ == '__main__':
json_data={"title": "foo", "body": "bar", "userId": 1},
headers={"Content-Type": "application/json; charset=UTF-8"}
)
response = caller.call_api(post_req_data)
response, detail = caller.call_api(post_req_data)
print("POST Response with json_data:")
if response.json_content:
print(f"Status: {response.status_code}, Data: {response.json_content}")
else:
print(f"Status: {response.status_code}, Content: {response.content.decode()}")
print(f"Time taken: {response.elapsed_time:.4f}s")
print("POST Call Detail (json_data):")
print(detail.model_dump_json(indent=2))
# Example POST request with body (alias for json_data)
post_req_data_with_body = APIRequest(
@@ -147,13 +291,15 @@ if __name__ == '__main__':
body={"title": "using body", "body": "testing body alias", "userId": 2},
headers={"Content-Type": "application/json; charset=UTF-8"}
)
response = caller.call_api(post_req_data_with_body)
response, detail = caller.call_api(post_req_data_with_body)
print("\nPOST Response with body:")
if response.json_content:
print(f"Status: {response.status_code}, Data: {response.json_content}")
else:
print(f"Status: {response.status_code}, Content: {response.content.decode()}")
print(f"Time taken: {response.elapsed_time:.4f}s")
print("POST Call Detail (body):")
print(detail.model_dump_json(indent=2))
# Example list body request (demonstrating array body support)
array_req_data = APIRequest(
@@ -162,20 +308,24 @@ if __name__ == '__main__':
body=["test_string", "another_value"],
headers={"Content-Type": "application/json; charset=UTF-8"}
)
response = caller.call_api(array_req_data)
response, detail = caller.call_api(array_req_data)
print("\nPOST Response with array body:")
if response.json_content:
print(f"Status: {response.status_code}, Data: {response.json_content}")
else:
print(f"Status: {response.status_code}, Content: {response.content.decode()}")
print(f"Time taken: {response.elapsed_time:.4f}s")
print("POST Call Detail (array body):")
print(detail.model_dump_json(indent=2))
# Example Error request (non-existent domain)
error_req_data = APIRequest(
method="GET",
url=HttpUrl("https://nonexistentdomain.invalid"),
)
response = caller.call_api(error_req_data)
response, detail = caller.call_api(error_req_data)
print("\nError GET Response:")
print(f"Status: {response.status_code}, Content: {response.content.decode()}")
print(f"Time taken: {response.elapsed_time:.4f}s")
print(f"Time taken: {response.elapsed_time:.4f}s")
print("Error GET Call Detail:")
print(detail.model_dump_json(indent=2))
+71 -83
View File
@@ -7,35 +7,34 @@
import logging
import json
import time
import re # 添加 re 模块导入
import os # Added os for path operations
import re
from typing import Dict, List, Any, Optional, Union, Tuple, Type, ForwardRef
from enum import Enum
import datetime
import datetime as dt
from uuid import UUID
from dataclasses import asdict as dataclass_asdict, is_dataclass # New import
from dataclasses import asdict as dataclass_asdict, is_dataclass
import copy
from pydantic import BaseModel, Field, create_model
from pydantic import BaseModel, Field, create_model, HttpUrl # Added HttpUrl for Literal type hint if needed
from pydantic.networks import EmailStr
from pydantic.types import Literal # Explicitly import Literal
from .input_parser.parser import InputParser, YAPIEndpoint, SwaggerEndpoint, ParsedYAPISpec, ParsedSwaggerSpec
from .api_caller.caller import APICaller, APIRequest, APIResponse
from .api_caller.caller import APICaller, APIRequest, APIResponse, APICallDetail # Ensure APICallDetail is imported
from .json_schema_validator.validator import JSONSchemaValidator
from .test_framework_core import ValidationResult, TestSeverity, APIRequestContext, APIResponseContext, BaseAPITestCase
from .test_case_registry import TestCaseRegistry
# 尝试导入 utils.schema_utils
from .utils import schema_utils
from .utils.common_utils import format_url_with_path_params # 新增导入
from .utils.common_utils import format_url_with_path_params
# 尝试导入 LLMService,如果失败则允许,因为 LLM 功能是可选的
try:
from .llm_utils.llm_service import LLMService
except ImportError:
LLMService = None
logging.getLogger(__name__).info("LLMService 未找到,LLM 相关功能将不可用。")
# Cache for dynamically created Pydantic models to avoid redefinition issues
_dynamic_model_cache: Dict[str, Type[BaseModel]] = {}
class ExecutedTestCaseResult:
@@ -321,8 +320,9 @@ class TestSummary:
print(f" - 验证点: {vp.message}")
class APITestOrchestrator:
"""API测试编排器"""
"""
测试编排器,负责加载API定义、发现和执行测试用例、生成报告等。
"""
def __init__(self, base_url: str,
custom_test_cases_dir: Optional[str] = None,
llm_api_key: Optional[str] = None,
@@ -331,77 +331,53 @@ class APITestOrchestrator:
use_llm_for_request_body: bool = False,
use_llm_for_path_params: bool = False,
use_llm_for_query_params: bool = False,
use_llm_for_headers: bool = False
use_llm_for_headers: bool = False,
output_dir: Optional[str] = None # output_dir is now optional and not used for saving API call details internally
):
"""
初始化API测试编排器
Args:
base_url: API基础URL
custom_test_cases_dir: 存放自定义 APITestCase 的目录路径。如果为 None,则不加载自定义测试用例。
llm_api_key: 大模型服务的API Key。
llm_base_url: 大模型服务的兼容OpenAI的基础URL。
llm_model_name: 要使用的具体模型名称。
use_llm_for_request_body: 是否全局启用LLM生成请求体。
use_llm_for_path_params: 是否全局启用LLM生成路径参数。
use_llm_for_query_params: 是否全局启用LLM生成查询参数。
use_llm_for_headers: 是否全局启用LLM生成头部参数。
"""
self.base_url = base_url.rstrip('/')
self.logger = logging.getLogger(__name__)
# 初始化组件
self.parser = InputParser()
self.api_caller = APICaller()
self.validator = JSONSchemaValidator() # JSON Schema 验证器,可能会被测试用例内部使用
self.test_case_registry: Optional[TestCaseRegistry] = None
if custom_test_cases_dir:
self.logger.info(f"初始化 TestCaseRegistry,扫描目录: {custom_test_cases_dir}")
self.schema_validator = JSONSchemaValidator()
self.test_case_registry = TestCaseRegistry(custom_test_cases_dir)
self.logger = logging.getLogger(__name__)
# self.output_dir is kept if other parts of the orchestrator might use it,
# but it's no longer used by the removed _save_api_call_details
self.output_dir_param = output_dir
self.api_call_details_log: List[APICallDetail] = []
# LLM Service Initialization
self.llm_service: Optional[LLMService] = None
if LLMService and llm_api_key:
try:
self.test_case_registry = TestCaseRegistry(test_cases_dir=custom_test_cases_dir)
self.logger.info(f"TestCaseRegistry 初始化完成,发现 {len(self.test_case_registry.get_all_test_case_classes())} 个测试用例类。")
self.llm_service = LLMService(api_key=llm_api_key, base_url=llm_base_url, model_name=llm_model_name)
self.logger.info(f"LLMService initialized successfully with model: {llm_model_name or 'default'}.")
except Exception as e:
self.logger.error(f"初始化 TestCaseRegistry 失败: {e}", exc_info=True)
else:
self.logger.info("未提供 custom_test_cases_dir,不加载自定义 APITestCase。")
self.logger.error(f"LLMService initialization failed: {e}. LLM features will be disabled.", exc_info=True)
self.llm_service = None
elif LLMService and not llm_api_key:
self.logger.info("LLMService is available, but LLM API key was not provided. LLM features will be disabled.")
# LLM 全局配置开关
self.use_llm_for_request_body = use_llm_for_request_body
self.use_llm_for_path_params = use_llm_for_path_params
self.use_llm_for_query_params = use_llm_for_query_params
self.use_llm_for_headers = use_llm_for_headers
self.llm_service: Optional[LLMService] = None
if LLMService is None:
self.logger.warning("LLMService 类未能导入,LLM 相关功能将完全禁用。")
# 强制所有LLM使用为False,并确保服务实例为None
self.llm_endpoint_params_cache: Dict[str, Dict[str, Any]] = {}
if (self.use_llm_for_request_body or \
self.use_llm_for_path_params or \
self.use_llm_for_query_params or \
self.use_llm_for_headers) and not self.llm_service:
self.logger.warning("LLM-based data generation was enabled, but LLMService is not available or failed to initialize. Disabling all LLM features.")
self.use_llm_for_request_body = False
self.use_llm_for_path_params = False
self.use_llm_for_query_params = False
self.use_llm_for_headers = False
elif llm_api_key and llm_base_url and llm_model_name: # 直接检查配置是否完整
try:
self.llm_service = LLMService(
api_key=llm_api_key,
base_url=llm_base_url,
model_name=llm_model_name
)
self.logger.info(f"LLMService 已成功初始化,模型: {llm_model_name}")
except ValueError as ve:
self.logger.error(f"LLMService 初始化失败 (参数错误): {ve}。LLM相关功能将不可用。")
self.llm_service = None # 确保初始化失败时服务为None
except Exception as e:
self.logger.error(f"LLMService 初始化时发生未知错误: {e}。LLM相关功能将不可用。", exc_info=True)
self.llm_service = None # 确保初始化失败时服务为None
else:
# 如果LLMService类存在,但配置不完整
if LLMService:
self.logger.warning("LLMService 类已找到,但未提供完整的LLM配置 (api_key, base_url, model_name)。LLM相关功能将不可用。")
# self.llm_service 默认就是 None,无需额外操作
# 新增:端点级别的LLM生成参数缓存
self.llm_endpoint_params_cache: Dict[str, Dict[str, Any]] = {}
def get_api_call_details(self) -> List[APICallDetail]:
"""Returns the collected list of API call details."""
return self.api_call_details_log
def _should_use_llm_for_param_type(
self,
@@ -868,7 +844,7 @@ class APITestOrchestrator:
test_case_instance = test_case_class(
endpoint_spec=endpoint_spec_dict,
global_api_spec=global_spec_dict,
json_schema_validator=self.validator,
json_schema_validator=self.schema_validator,
llm_service=self.llm_service # Pass the orchestrator's LLM service instance
)
self.logger.info(f"开始执行测试用例 '{test_case_instance.id}' ({test_case_instance.name}) for endpoint '{endpoint_spec_dict.get('method', 'N/A')} {endpoint_spec_dict.get('path', 'N/A')}'")
@@ -966,32 +942,34 @@ class APITestOrchestrator:
)
response_call_start_time = time.time()
api_response_obj = self.api_caller.call_api(api_request_obj)
# api_response_obj = self.api_caller.call_api(api_request_obj)
api_response, api_call_detail = self.api_caller.call_api(api_request_obj)
self.api_call_details_log.append(api_call_detail) # 记录日志
response_call_elapsed_time = time.time() - response_call_start_time
actual_text_content: Optional[str] = None
if hasattr(api_response_obj, 'text_content') and api_response_obj.text_content is not None:
actual_text_content = api_response_obj.text_content
elif api_response_obj.json_content is not None:
if isinstance(api_response_obj.json_content, str): # Should not happen if json_content is parsed
actual_text_content = api_response_obj.json_content
# 使用解包后的 api_response:
if hasattr(api_response, 'text_content') and api_response.text_content is not None:
actual_text_content = api_response.text_content
elif api_response.json_content is not None: # <--- 使用 api_response
if isinstance(api_response.json_content, str):
actual_text_content = api_response.json_content
else:
try:
actual_text_content = json.dumps(api_response_obj.json_content, ensure_ascii=False)
except TypeError: # If json_content is not serializable (e.g. bytes)
actual_text_content = str(api_response_obj.json_content)
actual_text_content = json.dumps(api_response.json_content, ensure_ascii=False)
except TypeError:
actual_text_content = str(api_response.json_content)
api_response_context = APIResponseContext(
status_code=api_response_obj.status_code,
headers=api_response_obj.headers,
json_content=api_response_obj.json_content,
status_code=api_response.status_code, # <--- 使用 api_response
headers=api_response.headers, # <--- 使用 api_response
json_content=api_response.json_content, # <--- 使用 api_response
text_content=actual_text_content,
elapsed_time=response_call_elapsed_time,
original_response= getattr(api_response_obj, 'raw_response', None), # Pass raw if available
original_response= getattr(api_response, 'raw_response', None), # <--- 使用 api_response
request_context=api_request_context
)
validation_results.extend(test_case_instance.validate_response(api_response_context, api_request_context))
validation_results.extend(test_case_instance.check_performance(api_response_context, api_request_context))
@@ -1424,12 +1402,15 @@ class APITestOrchestrator:
self.logger.error(f"从 run_tests_from_yapi 重新初始化 TestCaseRegistry 失败: {e}", exc_info=True)
self.logger.info(f"从YAPI文件加载API定义: {yapi_file_path}")
parsed_yapi = self.parser.parse_yapi_spec(yapi_file_path)
self.api_call_details_log = [] # Reset for new run
parsed_yapi = self.parser.parse_yapi_spec(yapi_file_path) # Corrected: self.parser
summary = TestSummary()
if not parsed_yapi:
self.logger.error(f"解析YAPI文件失败: {yapi_file_path}")
summary.finalize_summary()
# No longer calls _save_api_call_details here
return summary
endpoints_to_test = parsed_yapi.endpoints
@@ -1453,6 +1434,8 @@ class APITestOrchestrator:
summary.add_endpoint_result(result)
summary.finalize_summary()
# No longer calls _save_api_call_details here
summary.print_summary_to_console() # Keep console print
return summary
def run_tests_from_swagger(self, swagger_file_path: str,
@@ -1468,12 +1451,15 @@ class APITestOrchestrator:
self.logger.error(f"从 run_tests_from_swagger 重新初始化 TestCaseRegistry 失败: {e}", exc_info=True)
self.logger.info(f"从Swagger文件加载API定义: {swagger_file_path}")
parsed_swagger = self.parser.parse_swagger_spec(swagger_file_path)
self.api_call_details_log = [] # Reset for new run
parsed_swagger = self.parser.parse_swagger_spec(swagger_file_path) # Corrected: self.parser
summary = TestSummary()
if not parsed_swagger:
self.logger.error(f"解析Swagger文件失败: {swagger_file_path}")
summary.finalize_summary()
# No longer calls _save_api_call_details here
return summary
endpoints_to_test = parsed_swagger.endpoints
@@ -1481,7 +1467,7 @@ class APITestOrchestrator:
endpoints_to_test = [ep for ep in endpoints_to_test if any(tag in ep.tags for tag in tags)]
summary.set_total_endpoints_defined(len(endpoints_to_test))
total_applicable_tcs = 0
if self.test_case_registry:
for endpoint_spec in endpoints_to_test:
@@ -1497,6 +1483,8 @@ class APITestOrchestrator:
summary.add_endpoint_result(result)
summary.finalize_summary()
# No longer calls _save_api_call_details here
summary.print_summary_to_console() # Keep console print
return summary
def _generate_data_from_schema(self, schema: Dict[str, Any],