diff --git a/README.md b/README.md index 56e3ba1..9c4551f 100644 --- a/README.md +++ b/README.md @@ -165,4 +165,5 @@ Please ignore \[name-defined\] errors for now. This is a known bug we are workin ## Support -If you run into any issues or want help getting started with this project, please contact support@polyapi.io \ No newline at end of file +If you run into any issues or want help getting started with this project, please contact support@polyapi.io +. \ No newline at end of file diff --git a/dev_requirements.txt b/dev_requirements.txt new file mode 100644 index 0000000..8f81da5 --- /dev/null +++ b/dev_requirements.txt @@ -0,0 +1,3 @@ +-r requirements.txt +mock==5.2.0 +pytest \ No newline at end of file diff --git a/polyapi/cli.py b/polyapi/cli.py index 7299841..e99cded 100644 --- a/polyapi/cli.py +++ b/polyapi/cli.py @@ -14,6 +14,16 @@ CLI_COMMANDS = ["setup", "generate", "function", "clear", "help", "update_rendered_spec"] +def _get_version_string(): + """Get the version string for the package.""" + try: + import importlib.metadata + version = importlib.metadata.version('polyapi-python') + return version + except Exception: + return "Unknown" + + def execute_from_cli(): # First we setup all our argument parsing logic # Then we parse the arguments (waaay at the bottom) @@ -22,6 +32,11 @@ def execute_from_cli(): description="Manage your Poly API configurations and functions", formatter_class=argparse.RawTextHelpFormatter ) + + # Add global --version/-v flag + parser.add_argument('-v', '--version', action='version', + version=_get_version_string(), + help="Show version information") subparsers = parser.add_subparsers(help="Available commands") @@ -36,6 +51,9 @@ def setup(args): set_api_key_and_url(args.url, args.api_key) else: initialize_config(force=True) + # setup command should have default cache values + from .config import cache_generate_args + cache_generate_args(contexts=None, names=None, function_ids=None, no_types=False) generate() setup_parser.set_defaults(command=setup) @@ -46,11 +64,34 @@ def setup(args): generate_parser = subparsers.add_parser("generate", help="Generates Poly library") generate_parser.add_argument("--no-types", action="store_true", help="Generate SDK without type definitions") generate_parser.add_argument("--contexts", type=str, required=False, help="Contexts to generate") + generate_parser.add_argument("--names", type=str, required=False, help="Resource names to generate (comma-separated)") + generate_parser.add_argument("--function-ids", type=str, required=False, help="Function IDs to generate (comma-separated)") def generate_command(args): + from .config import cache_generate_args + initialize_config() + contexts = args.contexts.split(",") if args.contexts else None - generate(contexts=contexts, no_types=args.no_types) + names = args.names.split(",") if args.names else None + function_ids = args.function_ids.split(",") if args.function_ids else None + no_types = args.no_types + + # overwrite all cached values with the values passed in from the command line + final_contexts = contexts + final_names = names + final_function_ids = function_ids + final_no_types = no_types + + # cache the values used for this explicit generate command + cache_generate_args( + contexts=final_contexts, + names=final_names, + function_ids=final_function_ids, + no_types=final_no_types + ) + + generate(contexts=final_contexts, names=final_names, function_ids=final_function_ids, no_types=final_no_types) generate_parser.set_defaults(command=generate_command) diff --git a/polyapi/client.py b/polyapi/client.py index 92084ed..2c3068e 100644 --- a/polyapi/client.py +++ b/polyapi/client.py @@ -10,7 +10,7 @@ """ -def _wrap_code_in_try_except(code: str) -> str: +def _wrap_code_in_try_except(function_name: str, code: str) -> str: """ this is necessary because client functions with imports will blow up ALL server functions, even if they don't use them. because the server function will try to load all client functions when loading the library @@ -18,8 +18,8 @@ def _wrap_code_in_try_except(code: str) -> str: prefix = """logger = logging.getLogger("poly") try: """ - suffix = """except ImportError as e: - logger.debug(e)""" + suffix = f"""except ImportError as e: + logger.warning("Failed to import client function '{function_name}', function unavailable: " + str(e))""" lines = code.split("\n") code = "\n ".join(lines) @@ -39,6 +39,6 @@ def render_client_function( return_type_def=return_type_def, ) - code = _wrap_code_in_try_except(code) + code = _wrap_code_in_try_except(function_name, code) return code + "\n\n", func_type_defs \ No newline at end of file diff --git a/polyapi/config.py b/polyapi/config.py index 60eb16f..c9e1799 100644 --- a/polyapi/config.py +++ b/polyapi/config.py @@ -12,6 +12,10 @@ MTLS_CERT_PATH = None MTLS_KEY_PATH = None MTLS_CA_PATH = None +LAST_GENERATE_CONTEXTS = None +LAST_GENERATE_NAMES = None +LAST_GENERATE_FUNCTION_IDS = None +LAST_GENERATE_NO_TYPES = None def get_config_file_path() -> str: @@ -55,6 +59,16 @@ def get_api_key_and_url() -> Tuple[str | None, str | None]: MTLS_CERT_PATH = config.get("polyapi", "mtls_cert_path", fallback=None) MTLS_KEY_PATH = config.get("polyapi", "mtls_key_path", fallback=None) MTLS_CA_PATH = config.get("polyapi", "mtls_ca_path", fallback=None) + + # Read and cache generate command arguments + global LAST_GENERATE_CONTEXTS, LAST_GENERATE_NAMES, LAST_GENERATE_FUNCTION_IDS, LAST_GENERATE_NO_TYPES + contexts_str = config.get("polyapi", "last_generate_contexts_used", fallback=None) + LAST_GENERATE_CONTEXTS = contexts_str.split(",") if contexts_str else None + names_str = config.get("polyapi", "last_generate_names_used", fallback=None) + LAST_GENERATE_NAMES = names_str.split(",") if names_str else None + function_ids_str = config.get("polyapi", "last_generate_function_ids_used", fallback=None) + LAST_GENERATE_FUNCTION_IDS = function_ids_str.split(",") if function_ids_str else None + LAST_GENERATE_NO_TYPES = config.get("polyapi", "last_generate_no_types_used", fallback="false").lower() == "true" return key, url @@ -133,4 +147,58 @@ def get_direct_execute_config() -> bool: if API_FUNCTION_DIRECT_EXECUTE is None: # Force a config read if value isn't cached get_api_key_and_url() - return bool(API_FUNCTION_DIRECT_EXECUTE) \ No newline at end of file + return bool(API_FUNCTION_DIRECT_EXECUTE) + + +def get_cached_generate_args() -> Tuple[list | None, list | None, list | None, bool]: + """Return cached generate command arguments""" + global LAST_GENERATE_CONTEXTS, LAST_GENERATE_NAMES, LAST_GENERATE_FUNCTION_IDS, LAST_GENERATE_NO_TYPES + if LAST_GENERATE_CONTEXTS is None and LAST_GENERATE_NAMES is None and LAST_GENERATE_FUNCTION_IDS is None and LAST_GENERATE_NO_TYPES is None: + # Force a config read if values aren't cached + get_api_key_and_url() + return LAST_GENERATE_CONTEXTS, LAST_GENERATE_NAMES, LAST_GENERATE_FUNCTION_IDS, bool(LAST_GENERATE_NO_TYPES) + + +def cache_generate_args(contexts: list | None = None, names: list | None = None, function_ids: list | None = None, no_types: bool = False): + """Cache generate command arguments to config file""" + from typing import List + + # Read existing config + path = get_config_file_path() + config = configparser.ConfigParser() + + if os.path.exists(path): + with open(path, "r") as f: + config.read_file(f) + + # Ensure polyapi section exists + if "polyapi" not in config: + config["polyapi"] = {} + + # Update cached values + global LAST_GENERATE_CONTEXTS, LAST_GENERATE_NAMES, LAST_GENERATE_FUNCTION_IDS, LAST_GENERATE_NO_TYPES + LAST_GENERATE_CONTEXTS = contexts + LAST_GENERATE_NAMES = names + LAST_GENERATE_FUNCTION_IDS = function_ids + LAST_GENERATE_NO_TYPES = no_types + + # Write values to config + if contexts is not None: + config.set("polyapi", "last_generate_contexts_used", ",".join(contexts)) + elif config.has_option("polyapi", "last_generate_contexts_used"): + config.remove_option("polyapi", "last_generate_contexts_used") + + if names is not None: + config.set("polyapi", "last_generate_names_used", ",".join(names)) + elif config.has_option("polyapi", "last_generate_names_used"): + config.remove_option("polyapi", "last_generate_names_used") + + if function_ids is not None: + config.set("polyapi", "last_generate_function_ids_used", ",".join(function_ids)) + elif config.has_option("polyapi", "last_generate_function_ids_used"): + config.remove_option("polyapi", "last_generate_function_ids_used") + + config.set("polyapi", "last_generate_no_types_used", str(no_types).lower()) + + with open(path, "w") as f: + config.write(f) \ No newline at end of file diff --git a/polyapi/deployables.py b/polyapi/deployables.py index ac4f743..a3feb61 100644 --- a/polyapi/deployables.py +++ b/polyapi/deployables.py @@ -31,6 +31,7 @@ class ParsedDeployableConfig(TypedDict): context: str name: str type: DeployableTypes + description: Optional[str] disableAi: Optional[bool] config: Dict[str, Any] @@ -112,20 +113,22 @@ class PolyDeployConfig(TypedDict): def get_all_deployable_files_windows(config: PolyDeployConfig) -> List[str]: # Constructing the Windows command using dir and findstr - include_pattern = " ".join(f"*.{f}" if "." in f else f"*.{f}" for f in config["include_files_or_extensions"]) or "*" - exclude_pattern = '|'.join(config["exclude_dirs"]) - pattern = '|'.join(f"polyConfig: {name}" for name in config["type_names"]) or 'polyConfig' + include_pattern = " ".join(f"*.{f}" for f in config["include_files_or_extensions"]) or "*" + exclude_pattern = ' '.join(f"\\{f}" for f in config["exclude_dirs"]) + pattern = ' '.join(f"/C:\"polyConfig: {name}\"" for name in config["type_names"]) or '/C:"polyConfig"' exclude_command = f" | findstr /V /I \"{exclude_pattern}\"" if exclude_pattern else '' - search_command = f" | findstr /M /I /F:/ /C:\"{pattern}\"" + search_command = f" | findstr /S /M /I /F:/ {pattern} *.*" result = [] for dir_path in config["include_dirs"]: - dir_command = f"dir /S /P /B {include_pattern} {dir_path}" + if dir_path != '.': + include_pattern = " ".join(f"{dir_path}*.{f}" for f in config["include_files_or_extensions"]) or "*" + dir_command = f"dir {include_pattern} /S /P /B > NUL" full_command = f"{dir_command}{exclude_command}{search_command}" try: output = subprocess.check_output(full_command, shell=True, text=True) - result.extend(output.strip().split('\r\n')) + result.extend(output.strip().split('\n')) except subprocess.CalledProcessError: pass return result @@ -154,7 +157,7 @@ def get_all_deployable_files(config: PolyDeployConfig) -> List[str]: if not config.get("include_files_or_extensions"): config["include_files_or_extensions"] = ["py"] if not config.get("exclude_dirs"): - config["exclude_dirs"] = ["poly", "node_modules", "dist", "build", "output", ".vscode", ".poly", ".github", ".husky", ".yarn"] + config["exclude_dirs"] = ["Lib", "node_modules", "dist", "build", "output", ".vscode", ".poly", ".github", ".husky", ".yarn", ".venv"] is_windows = os.name == "nt" if is_windows: diff --git a/polyapi/function_cli.py b/polyapi/function_cli.py index bc99f2b..bdadd0d 100644 --- a/polyapi/function_cli.py +++ b/polyapi/function_cli.py @@ -1,7 +1,7 @@ import sys from typing import Any, List, Optional import requests -from polyapi.generate import generate as generate_library + from polyapi.config import get_api_key_and_url from polyapi.utils import get_auth_headers, print_green, print_red, print_yellow from polyapi.parser import parse_function_code, get_jsonschema_type @@ -91,7 +91,9 @@ def function_add_or_update( function_id = resp.json()["id"] print(f"Function ID: {function_id}") if generate: - generate_library() + # Use cached generate arguments when regenerating after function deployment + from polyapi.generate import generate_from_cache + generate_from_cache() else: print("Error adding function.") print(resp.status_code) diff --git a/polyapi/generate.py b/polyapi/generate.py index df848a1..c0cd9e0 100644 --- a/polyapi/generate.py +++ b/polyapi/generate.py @@ -2,7 +2,9 @@ import requests import os import shutil -from typing import List, Optional, Tuple, cast +import logging +import tempfile +from typing import Any, List, Optional, Tuple, cast from .auth import render_auth_function from .client import render_client_function @@ -14,7 +16,7 @@ from .server import render_server_function from .utils import add_import_to_init, get_auth_headers, init_the_init, print_green, to_func_namespace from .variables import generate_variables -from .config import get_api_key_and_url, get_direct_execute_config +from .config import get_api_key_and_url, get_direct_execute_config, get_cached_generate_args SUPPORTED_FUNCTION_TYPES = { "apiFunction", @@ -36,15 +38,21 @@ path:''' -def get_specs(contexts=Optional[List[str]], no_types: bool = False) -> List: +def get_specs(contexts: Optional[List[str]] = None, names: Optional[List[str]] = None, function_ids: Optional[List[str]] = None, no_types: bool = False) -> List: api_key, api_url = get_api_key_and_url() assert api_key headers = get_auth_headers(api_key) url = f"{api_url}/specs" - params = {"noTypes": str(no_types).lower()} + params: Any = {"noTypes": str(no_types).lower()} if contexts: params["contexts"] = contexts + + if names: + params["names"] = names + + if function_ids: + params["functionIds"] = function_ids # Add apiFunctionDirectExecute parameter if direct execute is enabled if get_direct_execute_config(): @@ -149,7 +157,7 @@ def parse_function_specs( # Functions with serverSideAsync True will always return a Dict with execution ID if spec.get('serverSideAsync') and spec.get("function"): - spec['function']['returnType'] = {'kind': 'plain', 'value': 'object'} + spec['function']['returnType'] = {'kind': 'plain', 'value': 'object'} # type: ignore functions.append(spec) @@ -264,12 +272,26 @@ def __class_getitem__(cls, item): ''') -def generate(contexts: Optional[List[str]] = None, no_types: bool = False) -> None: +def generate_from_cache() -> None: + """ + Generate using cached values after non-explicit call. + """ + cached_contexts, cached_names, cached_function_ids, cached_no_types = get_cached_generate_args() + + generate( + contexts=cached_contexts, + names=cached_names, + function_ids=cached_function_ids, + no_types=cached_no_types + ) + + +def generate(contexts: Optional[List[str]] = None, names: Optional[List[str]] = None, function_ids: Optional[List[str]] = None, no_types: bool = False) -> None: generate_msg = f"Generating Poly Python SDK for contexts ${contexts}..." if contexts else "Generating Poly Python SDK..." print(generate_msg, end="", flush=True) remove_old_library() - specs = get_specs(no_types=no_types, contexts=contexts) + specs = get_specs(contexts=contexts, names=names, function_ids=function_ids, no_types=no_types) cache_specs(specs) limit_ids: List[str] = [] # useful for narrowing down generation to a single function to debug @@ -301,11 +323,9 @@ def generate(contexts: Optional[List[str]] = None, no_types: bool = False) -> No ) exit() - # Only process variables if no_types is False - if not no_types: - variables = get_variables() - if variables: - generate_variables(variables) + variables = get_variables() + if variables: + generate_variables(variables) # indicator to vscode extension that this is a polyapi-python project file_path = os.path.join(os.getcwd(), ".polyapi-python") @@ -334,8 +354,9 @@ def render_spec(spec: SpecificationDto) -> Tuple[str, str]: function_id = spec["id"] arguments: List[PropertySpecification] = [] - return_type = {} + return_type: Any = {} if spec.get("function"): + assert spec["function"] # Handle cases where arguments might be missing or None if spec["function"].get("arguments"): arguments = [ @@ -407,48 +428,124 @@ def add_function_file( function_name: str, spec: SpecificationDto, ): - # first lets add the import to the __init__ - init_the_init(full_path) + """ + Atomically add a function file to prevent partial corruption during generation failures. + + This function generates all content first, then writes files atomically using temporary files + to ensure that either the entire operation succeeds or no changes are made to the filesystem. + """ + try: + # first lets add the import to the __init__ + init_the_init(full_path) - func_str, func_type_defs = render_spec(spec) + func_str, func_type_defs = render_spec(spec) - if func_str: - # add function to init - init_path = os.path.join(full_path, "__init__.py") - with open(init_path, "a") as f: - f.write(f"\n\nfrom . import {to_func_namespace(function_name)}\n\n{func_str}") + if not func_str: + # If render_spec failed and returned empty string, don't create any files + raise Exception("Function rendering failed - empty function string returned") - # add type_defs to underscore file - file_path = os.path.join(full_path, f"{to_func_namespace(function_name)}.py") - with open(file_path, "w") as f: - f.write(func_type_defs) + # Prepare all content first before writing any files + func_namespace = to_func_namespace(function_name) + init_path = os.path.join(full_path, "__init__.py") + func_file_path = os.path.join(full_path, f"{func_namespace}.py") + + # Read current __init__.py content if it exists + init_content = "" + if os.path.exists(init_path): + with open(init_path, "r") as f: + init_content = f.read() + + # Prepare new content to append to __init__.py + new_init_content = init_content + f"\n\nfrom . import {func_namespace}\n\n{func_str}" + + # Use temporary files for atomic writes + # Write to __init__.py atomically + with tempfile.NamedTemporaryFile(mode="w", delete=False, dir=full_path, suffix=".tmp") as temp_init: + temp_init.write(new_init_content) + temp_init_path = temp_init.name + + # Write to function file atomically + with tempfile.NamedTemporaryFile(mode="w", delete=False, dir=full_path, suffix=".tmp") as temp_func: + temp_func.write(func_type_defs) + temp_func_path = temp_func.name + + # Atomic operations: move temp files to final locations + shutil.move(temp_init_path, init_path) + shutil.move(temp_func_path, func_file_path) + + except Exception as e: + # Clean up any temporary files that might have been created + try: + if 'temp_init_path' in locals() and os.path.exists(temp_init_path): + os.unlink(temp_init_path) + if 'temp_func_path' in locals() and os.path.exists(temp_func_path): + os.unlink(temp_func_path) + except: + pass # Best effort cleanup + + # Re-raise the original exception + raise e def create_function( spec: SpecificationDto ) -> None: + """ + Create a function with atomic directory and file operations. + + Tracks directory creation to enable cleanup on failure. + """ full_path = os.path.dirname(os.path.abspath(__file__)) folders = f"poly.{spec['context']}.{spec['name']}".split(".") - for idx, folder in enumerate(folders): - if idx + 1 == len(folders): - # special handling for final level - add_function_file( - full_path, - folder, - spec, - ) - else: - full_path = os.path.join(full_path, folder) - if not os.path.exists(full_path): - os.makedirs(full_path) - - # append to __init__.py file if nested folders - next = folders[idx + 1] if idx + 2 < len(folders) else "" - if next: - init_the_init(full_path) - add_import_to_init(full_path, next) + created_dirs = [] # Track directories we create for cleanup on failure + + try: + for idx, folder in enumerate(folders): + if idx + 1 == len(folders): + # special handling for final level + add_function_file( + full_path, + folder, + spec, + ) + else: + full_path = os.path.join(full_path, folder) + if not os.path.exists(full_path): + os.makedirs(full_path) + created_dirs.append(full_path) # Track for cleanup + + # append to __init__.py file if nested folders + next = folders[idx + 1] if idx + 2 < len(folders) else "" + if next: + init_the_init(full_path) + add_import_to_init(full_path, next) + + except Exception as e: + # Clean up directories we created (in reverse order) + for dir_path in reversed(created_dirs): + try: + if os.path.exists(dir_path) and not os.listdir(dir_path): # Only remove if empty + os.rmdir(dir_path) + except: + pass # Best effort cleanup + + # Re-raise the original exception + raise e def generate_functions(functions: List[SpecificationDto]) -> None: + failed_functions = [] for func in functions: - create_function(func) + try: + create_function(func) + except Exception as e: + function_path = f"{func.get('context', 'unknown')}.{func.get('name', 'unknown')}" + function_id = func.get('id', 'unknown') + failed_functions.append(f"{function_path} (id: {function_id})") + logging.warning(f"WARNING: Failed to generate function {function_path} (id: {function_id}): {str(e)}") + continue + + if failed_functions: + logging.warning(f"WARNING: {len(failed_functions)} function(s) failed to generate:") + for failed_func in failed_functions: + logging.warning(f" - {failed_func}") diff --git a/polyapi/parser.py b/polyapi/parser.py index 8ae3397..f474693 100644 --- a/polyapi/parser.py +++ b/polyapi/parser.py @@ -513,7 +513,13 @@ def generic_visit(self, node): deployable["context"] = context or deployable["config"].get("context", "") deployable["name"] = name or deployable["config"].get("name", "") deployable["disableAi"] = deployable["config"].get("disableAi", False) - deployable["description"] = deployable["types"].get("description", "") + deployable["description"] = deployable["config"].get("description", "") + if deployable["description"]: + if deployable["description"] != deployable["types"].get("description", ""): + deployable["types"]["description"] = deployable["description"] + deployable["dirty"] = True + else: + deployable["description"] = deployable["types"].get("description", "") if not deployable["name"]: print_red("ERROR") print("Function config is missing a name.") diff --git a/polyapi/poly_schemas.py b/polyapi/poly_schemas.py index 6b42ec7..30d5ab5 100644 --- a/polyapi/poly_schemas.py +++ b/polyapi/poly_schemas.py @@ -1,4 +1,7 @@ import os +import logging +import tempfile +import shutil from typing import Any, Dict, List, Tuple from polyapi.schema import wrapped_generate_schema_types @@ -21,13 +24,67 @@ def generate_schemas(specs: List[SchemaSpecDto], limit_ids: List[str] = None): + failed_schemas = [] + successful_schemas = [] if limit_ids: for spec in specs: if spec["id"] in limit_ids: - create_schema(spec) + try: + create_schema(spec) + successful_schemas.append(f"{spec.get('context', 'unknown')}.{spec.get('name', 'unknown')}") + except Exception as e: + schema_path = f"{spec.get('context', 'unknown')}.{spec.get('name', 'unknown')}" + schema_id = spec.get('id', 'unknown') + failed_schemas.append(f"{schema_path} (id: {schema_id})") + logging.warning(f"WARNING: Failed to generate schema {schema_path} (id: {schema_id}): {str(e)}") + continue else: for spec in specs: - create_schema(spec) + try: + create_schema(spec) + successful_schemas.append(f"{spec.get('context', 'unknown')}.{spec.get('name', 'unknown')}") + except Exception as e: + schema_path = f"{spec.get('context', 'unknown')}.{spec.get('name', 'unknown')}" + schema_id = spec.get('id', 'unknown') + failed_schemas.append(f"{schema_path} (id: {schema_id})") + logging.warning(f"WARNING: Failed to generate schema {schema_path} (id: {schema_id}): {str(e)}") + continue + + if failed_schemas: + logging.warning(f"WARNING: {len(failed_schemas)} schema(s) failed to generate:") + for failed_schema in failed_schemas: + logging.warning(f" - {failed_schema}") + logging.warning(f"Successfully generated {len(successful_schemas)} schema(s)") + + +def validate_schema_content(schema_content: str, schema_name: str) -> bool: + """ + Validate that the schema content is meaningful and not just imports. + Returns True if the schema is valid, False otherwise. + """ + if not schema_content or not schema_content.strip(): + logging.debug(f"Schema {schema_name} failed validation: Empty content") + return False + + lines = schema_content.strip().split('\n') + + # Check if the content has any actual class definitions or type aliases + has_class_definition = any(line.strip().startswith('class ') for line in lines) + has_type_alias = any(schema_name in line and '=' in line and not line.strip().startswith('#') for line in lines) + + # Check if it's essentially just imports (less than 5 lines and no meaningful definitions) + meaningful_lines = [line for line in lines if line.strip() and not line.strip().startswith('from ') and not line.strip().startswith('import ') and not line.strip().startswith('#')] + + # Enhanced logging for debugging + if not (has_class_definition or has_type_alias) or len(meaningful_lines) < 1: + # Determine the specific reason for failure + if len(meaningful_lines) == 0: + logging.debug(f"Schema {schema_name} failed validation: No meaningful content (only imports) - likely empty object or unresolved reference") + elif not has_class_definition and not has_type_alias: + logging.debug(f"Schema {schema_name} failed validation: No class definition or type alias found") + return False + + return True def add_schema_file( @@ -35,51 +92,114 @@ def add_schema_file( schema_name: str, spec: SchemaSpecDto, ): - # first lets add the import to the __init__ - init_the_init(full_path, SCHEMA_CODE_IMPORTS) - - if not spec["definition"].get("title"): - # very empty schemas like mews.Unit are possible - # add a title here to be sure they render - spec["definition"]["title"] = schema_name - - schema_defs = render_poly_schema(spec) - - if schema_defs: - # add function to init + """ + Atomically add a schema file to prevent partial corruption during generation failures. + + This function generates all content first, then writes files atomically using temporary files + to ensure that either the entire operation succeeds or no changes are made to the filesystem. + """ + try: + # first lets add the import to the __init__ + init_the_init(full_path, SCHEMA_CODE_IMPORTS) + + if not spec["definition"].get("title"): + # very empty schemas like mews.Unit are possible + # add a title here to be sure they render + spec["definition"]["title"] = schema_name + + schema_defs = render_poly_schema(spec) + + # Validate schema content before proceeding + if not validate_schema_content(schema_defs, schema_name): + raise Exception(f"Schema rendering failed or produced invalid content for {schema_name}") + + # Prepare all content first before writing any files + schema_namespace = to_func_namespace(schema_name) init_path = os.path.join(full_path, "__init__.py") - with open(init_path, "a") as f: - f.write(f"\n\nfrom ._{to_func_namespace(schema_name)} import {schema_name}\n__all__.append('{schema_name}')\n") - - # add type_defs to underscore file - file_path = os.path.join(full_path, f"_{to_func_namespace(schema_name)}.py") - with open(file_path, "w") as f: - f.write(schema_defs) + schema_file_path = os.path.join(full_path, f"_{schema_namespace}.py") + + # Read current __init__.py content if it exists + init_content = "" + if os.path.exists(init_path): + with open(init_path, "r") as f: + init_content = f.read() + + # Prepare new content to append to __init__.py + new_init_content = init_content + f"\n\nfrom ._{schema_namespace} import {schema_name}\n__all__.append('{schema_name}')\n" + + # Use temporary files for atomic writes + # Write to __init__.py atomically + with tempfile.NamedTemporaryFile(mode="w", delete=False, dir=full_path, suffix=".tmp") as temp_init: + temp_init.write(new_init_content) + temp_init_path = temp_init.name + + # Write to schema file atomically + with tempfile.NamedTemporaryFile(mode="w", delete=False, dir=full_path, suffix=".tmp") as temp_schema: + temp_schema.write(schema_defs) + temp_schema_path = temp_schema.name + + # Atomic operations: move temp files to final locations + shutil.move(temp_init_path, init_path) + shutil.move(temp_schema_path, schema_file_path) + + except Exception as e: + # Clean up any temporary files that might have been created + try: + if 'temp_init_path' in locals() and os.path.exists(temp_init_path): + os.unlink(temp_init_path) + if 'temp_schema_path' in locals() and os.path.exists(temp_schema_path): + os.unlink(temp_schema_path) + except: + pass # Best effort cleanup + + # Re-raise the original exception + raise e def create_schema( spec: SchemaSpecDto ) -> None: + """ + Create a schema with atomic directory and file operations. + + Tracks directory creation to enable cleanup on failure. + """ full_path = os.path.dirname(os.path.abspath(__file__)) folders = f"schemas.{spec['context']}.{spec['name']}".split(".") - for idx, folder in enumerate(folders): - if idx + 1 == len(folders): - # special handling for final level - add_schema_file( - full_path, - folder, - spec, - ) - else: - full_path = os.path.join(full_path, folder) - if not os.path.exists(full_path): - os.makedirs(full_path) - - # append to __init__.py file if nested folders - next = folders[idx + 1] if idx + 2 < len(folders) else "" - if next: - init_the_init(full_path, SCHEMA_CODE_IMPORTS) - add_import_to_init(full_path, next) + created_dirs = [] # Track directories we create for cleanup on failure + + try: + for idx, folder in enumerate(folders): + if idx + 1 == len(folders): + # special handling for final level + add_schema_file( + full_path, + folder, + spec, + ) + else: + full_path = os.path.join(full_path, folder) + if not os.path.exists(full_path): + os.makedirs(full_path) + created_dirs.append(full_path) # Track for cleanup + + # append to __init__.py file if nested folders + next = folders[idx + 1] if idx + 2 < len(folders) else "" + if next: + init_the_init(full_path, SCHEMA_CODE_IMPORTS) + add_import_to_init(full_path, next) + + except Exception as e: + # Clean up directories we created (in reverse order) + for dir_path in reversed(created_dirs): + try: + if os.path.exists(dir_path) and not os.listdir(dir_path): # Only remove if empty + os.rmdir(dir_path) + except: + pass # Best effort cleanup + + # Re-raise the original exception + raise e def add_schema_to_init(full_path: str, spec: SchemaSpecDto): diff --git a/polyapi/prepare.py b/polyapi/prepare.py index af953ec..565d139 100644 --- a/polyapi/prepare.py +++ b/polyapi/prepare.py @@ -1,5 +1,6 @@ import os import sys +import subprocess from typing import List, Tuple, Literal import requests @@ -31,7 +32,7 @@ def get_server_function_description(description: str, arguments, code: str) -> s api_key, api_url = get_api_key_and_url() headers = get_auth_headers(api_key) data = {"description": description, "arguments": arguments, "code": code} - response = requests.post(f"{api_url}/server-function-description", headers=headers, json=data) + response = requests.post(f"{api_url}/functions/server/description-generation", headers=headers, json=data) return response.json() def get_client_function_description(description: str, arguments, code: str) -> str: @@ -39,7 +40,7 @@ def get_client_function_description(description: str, arguments, code: str) -> s headers = get_auth_headers(api_key) # Simulated API call to generate client function descriptions data = {"description": description, "arguments": arguments, "code": code} - response = requests.post(f"{api_url}/client-function-description", headers=headers, json=data) + response = requests.post(f"{api_url}/functions/client/description-generation", headers=headers, json=data) return response.json() def fill_in_missing_function_details(deployable: DeployableRecord, code: str) -> DeployableRecord: @@ -135,6 +136,16 @@ def prepare_deployables(lazy: bool = False, disable_docs: bool = False, disable_ # NOTE: write_updated_deployable has side effects that update deployable.fileRevision which is in both this list and parsed_deployables for deployable in dirty_deployables: write_updated_deployable(deployable, disable_docs) + # Re-stage any updated staged files. + staged = subprocess.check_output('git diff --name-only --cached', shell=True, text=True, ).split('\n') + for deployable in dirty_deployables: + try: + if deployable["file"] in staged: + print(f'Staging {deployable["file"]}') + subprocess.run(['git', 'add', deployable["file"]]) + except: + print('Warning: File staging failed, check that all files are staged properly.') + print("Poly deployments are prepared.") save_deployable_records(parsed_deployables) diff --git a/polyapi/schema.py b/polyapi/schema.py index 6ca3391..1523e7f 100644 --- a/polyapi/schema.py +++ b/polyapi/schema.py @@ -64,7 +64,7 @@ def wrapped_generate_schema_types(type_spec: dict, root, fallback_type): # {'$ref': '#/definitions/FinanceAccountListModel'} return fallback_type, "" except: - logging.error(f"Error when generating schema type: {type_spec}\nusing fallback type '{fallback_type}'") + logging.warning(f"WARNING parsing jsonschema failed: {type_spec}\nusing fallback type '{fallback_type}'") return fallback_type, "" @@ -104,12 +104,26 @@ def generate_schema_types(input_data: Dict, root=None): # Regex to match everything between "# example: {\n" and "^}$" MALFORMED_EXAMPLES_PATTERN = re.compile(r"# example: \{\n.*?^\}$", flags=re.DOTALL | re.MULTILINE) +# Regex to fix invalid escape sequences in docstrings +INVALID_ESCAPE_PATTERNS = [ + # Fix "\ " (backslash space) which is not a valid escape sequence + (re.compile(r'\\(\s)', re.DOTALL), r'\1'), + # Fix other common invalid escape sequences in docstrings + (re.compile(r'\\([^nrtbfav"\'\\])', re.DOTALL), r'\\\\\1'), +] + def clean_malformed_examples(example: str) -> str: """ there is a bug in the `jsonschmea_gentypes` library where if an example from a jsonchema is an object, - it will break the code because the object won't be properly commented out + it will break the code because the object won't be properly commented out. Also fixes invalid escape sequences. """ + # Remove malformed examples cleaned_example = MALFORMED_EXAMPLES_PATTERN.sub("", example) + + # Fix invalid escape sequences in docstrings + for pattern, replacement in INVALID_ESCAPE_PATTERNS: + cleaned_example = pattern.sub(replacement, cleaned_example) + return cleaned_example diff --git a/polyapi/typedefs.py b/polyapi/typedefs.py index 6d6ff18..b887103 100644 --- a/polyapi/typedefs.py +++ b/polyapi/typedefs.py @@ -78,6 +78,7 @@ class SchemaSpecDto(TypedDict): class PolyDeployable(TypedDict, total=False): context: str name: str + description: NotRequired[str] disable_ai: NotRequired[bool] # Optional field to disable AI diff --git a/polyapi/variables.py b/polyapi/variables.py index 673a195..76975cc 100644 --- a/polyapi/variables.py +++ b/polyapi/variables.py @@ -1,4 +1,7 @@ import os +import logging +import tempfile +import shutil from typing import List from polyapi.schema import map_primitive_types @@ -70,8 +73,21 @@ def inject(path=None) -> {variable_type}: def generate_variables(variables: List[VariableSpecDto]): + failed_variables = [] for variable in variables: - create_variable(variable) + try: + create_variable(variable) + except Exception as e: + variable_path = f"{variable.get('context', 'unknown')}.{variable.get('name', 'unknown')}" + variable_id = variable.get('id', 'unknown') + failed_variables.append(f"{variable_path} (id: {variable_id})") + logging.warning(f"WARNING: Failed to generate variable {variable_path} (id: {variable_id}): {str(e)}") + continue + + if failed_variables: + logging.warning(f"WARNING: {len(failed_variables)} variable(s) failed to generate:") + for failed_var in failed_variables: + logging.warning(f" - {failed_var}") def render_variable(variable: VariableSpecDto): @@ -116,26 +132,84 @@ def _get_variable_type(type_spec: PropertyType) -> str: def create_variable(variable: VariableSpecDto) -> None: + """ + Create a variable with atomic directory and file operations. + + Tracks directory creation to enable cleanup on failure. + """ folders = ["vari"] if variable["context"]: folders += variable["context"].split(".") # build up the full_path by adding all the folders full_path = os.path.join(os.path.dirname(os.path.abspath(__file__))) - - for idx, folder in enumerate(folders): - full_path = os.path.join(full_path, folder) - if not os.path.exists(full_path): - os.makedirs(full_path) - next = folders[idx + 1] if idx + 1 < len(folders) else None - if next: - add_import_to_init(full_path, next) - - add_variable_to_init(full_path, variable) + created_dirs = [] # Track directories we create for cleanup on failure + + try: + for idx, folder in enumerate(folders): + full_path = os.path.join(full_path, folder) + if not os.path.exists(full_path): + os.makedirs(full_path) + created_dirs.append(full_path) # Track for cleanup + next = folders[idx + 1] if idx + 1 < len(folders) else None + if next: + add_import_to_init(full_path, next) + + add_variable_to_init(full_path, variable) + + except Exception as e: + # Clean up directories we created (in reverse order) + for dir_path in reversed(created_dirs): + try: + if os.path.exists(dir_path) and not os.listdir(dir_path): # Only remove if empty + os.rmdir(dir_path) + except: + pass # Best effort cleanup + + # Re-raise the original exception + raise e def add_variable_to_init(full_path: str, variable: VariableSpecDto): - init_the_init(full_path) - init_path = os.path.join(full_path, "__init__.py") - with open(init_path, "a") as f: - f.write(render_variable(variable) + "\n\n") + """ + Atomically add a variable to __init__.py to prevent partial corruption during generation failures. + + This function generates all content first, then writes the file atomically using temporary files + to ensure that either the entire operation succeeds or no changes are made to the filesystem. + """ + try: + init_the_init(full_path) + init_path = os.path.join(full_path, "__init__.py") + + # Generate variable content first + variable_content = render_variable(variable) + if not variable_content: + raise Exception("Variable rendering failed - empty content returned") + + # Read current __init__.py content if it exists + init_content = "" + if os.path.exists(init_path): + with open(init_path, "r") as f: + init_content = f.read() + + # Prepare new content to append + new_init_content = init_content + variable_content + "\n\n" + + # Write to temporary file first, then atomic move + with tempfile.NamedTemporaryFile(mode="w", delete=False, dir=full_path, suffix=".tmp") as temp_file: + temp_file.write(new_init_content) + temp_file_path = temp_file.name + + # Atomic operation: move temp file to final location + shutil.move(temp_file_path, init_path) + + except Exception as e: + # Clean up temporary file if it exists + try: + if 'temp_file_path' in locals() and os.path.exists(temp_file_path): + os.unlink(temp_file_path) + except: + pass # Best effort cleanup + + # Re-raise the original exception + raise e diff --git a/polyapi/webhook.py b/polyapi/webhook.py index b27987d..2f11707 100644 --- a/polyapi/webhook.py +++ b/polyapi/webhook.py @@ -2,6 +2,7 @@ import socketio # type: ignore from socketio.exceptions import ConnectionError # type: ignore import uuid +import logging from typing import Any, Dict, List, Tuple from polyapi.config import get_api_key_and_url @@ -121,22 +122,27 @@ def render_webhook_handle( arguments: List[PropertySpecification], return_type: Dict[str, Any], ) -> Tuple[str, str]: - function_args, function_args_def = parse_arguments(function_name, arguments) - - if "WebhookEventType" in function_args: - # let's add the function name import! - function_args = function_args.replace("WebhookEventType", f"{to_func_namespace(function_name)}.WebhookEventType") - - func_str = WEBHOOK_TEMPLATE.format( - description=function_description, - client_id=uuid.uuid4().hex, - function_id=function_id, - function_name=function_name, - function_args=function_args, - function_path=poly_full_path(function_context, function_name), - ) - func_defs = WEBHOOK_DEFS_TEMPLATE.format(function_args_def=function_args_def) - return func_str, func_defs + try: + function_args, function_args_def = parse_arguments(function_name, arguments) + + if "WebhookEventType" in function_args: + # let's add the function name import! + function_args = function_args.replace("WebhookEventType", f"{to_func_namespace(function_name)}.WebhookEventType") + + func_str = WEBHOOK_TEMPLATE.format( + description=function_description, + client_id=uuid.uuid4().hex, + function_id=function_id, + function_name=function_name, + function_args=function_args, + function_path=poly_full_path(function_context, function_name), + ) + func_defs = WEBHOOK_DEFS_TEMPLATE.format(function_args_def=function_args_def) + return func_str, func_defs + except Exception as e: + logging.warning(f"Failed to render webhook handle {function_context}.{function_name} (id: {function_id}): {str(e)}") + # Return empty strings to indicate generation failure - this will be caught by generate_functions error handling + return "", "" def start(*args): diff --git a/pyproject.toml b/pyproject.toml index 96d4c69..53041fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,14 +3,14 @@ requires = ["setuptools>=61.2", "wheel"] [project] name = "polyapi-python" -version = "0.3.7" +version = "0.3.8" description = "The Python Client for PolyAPI, the IPaaS by Developers for Developers" authors = [{ name = "Dan Fellin", email = "dan@polyapi.io" }] dependencies = [ "requests>=2.32.3", "typing_extensions>=4.12.2", "jsonschema-gentypes==2.6.0", - "pydantic==2.6.4", + "pydantic>=2.8.0", "stdlib_list==0.10.0", "colorama==0.4.4", "python-socketio[asyncio_client]==5.11.1", diff --git a/requirements.txt b/requirements.txt index d967c75..d34defb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ requests>=2.32.3 typing_extensions>=4.10.0 jsonschema-gentypes==2.10.0 -pydantic==2.6.4 +pydantic>=2.8.0 stdlib_list==0.10.0 colorama==0.4.4 python-socketio[asyncio_client]==5.11.1 diff --git a/tests/test_deployables.py b/tests/test_deployables.py index 2339fd6..80ec742 100644 --- a/tests/test_deployables.py +++ b/tests/test_deployables.py @@ -66,11 +66,11 @@ def foobar(foo: str, bar: Dict[str, str]) -> int: """A function that does something really import. Args: - foo (str): - bar (Dict[str, str]): + foo (str): + bar (Dict[str, str]): Returns: - int: + int: """ print("Okay then!") return 7 diff --git a/tests/test_generate.py b/tests/test_generate.py index b738ba6..f6f08fa 100644 --- a/tests/test_generate.py +++ b/tests/test_generate.py @@ -2,8 +2,11 @@ import os import shutil import importlib.util +from unittest.mock import patch, MagicMock from polyapi.utils import get_type_and_def, rewrite_reserved -from polyapi.generate import render_spec, create_empty_schemas_module +from polyapi.generate import render_spec, create_empty_schemas_module, generate_functions, create_function +from polyapi.poly_schemas import generate_schemas, create_schema +from polyapi.variables import generate_variables, create_variable OPENAPI_FUNCTION = { "kind": "function", @@ -287,3 +290,373 @@ def test_nested_function() -> schemas.api.v1.user.profile: # Clean up schemas directory shutil.rmtree(schemas_path) + + def test_error_handling_generate_functions(self): + """Test that generate_functions handles errors gracefully and continues with other functions""" + # Mock create_function to raise an exception for one function + failing_spec = { + "id": "failing-function-123", + "type": "serverFunction", + "context": "test", + "name": "failingFunction", + "description": "A function that will fail to generate", + } + + working_spec = { + "id": "working-function-456", + "type": "serverFunction", + "context": "test", + "name": "workingFunction", + "description": "A function that will generate successfully", + } + + specs = [failing_spec, working_spec] + + # Mock create_function to fail on the first call and succeed on the second + with patch('polyapi.generate.create_function') as mock_create: + mock_create.side_effect = [Exception("Schema generation failed"), None] + + # Capture logging output + with patch('polyapi.generate.logging.warning') as mock_warning: + generate_functions(specs) + + # Verify that create_function was called twice (once for each spec) + self.assertEqual(mock_create.call_count, 2) + + # Verify that warning messages were logged + mock_warning.assert_any_call("WARNING: Failed to generate function test.failingFunction (id: failing-function-123): Schema generation failed") + mock_warning.assert_any_call("WARNING: 1 function(s) failed to generate:") + mock_warning.assert_any_call(" - test.failingFunction (id: failing-function-123)") + + def test_error_handling_generate_schemas(self): + """Test that generate_schemas handles errors gracefully and continues with other schemas""" + from polyapi.typedefs import SchemaSpecDto + + failing_spec = { + "id": "failing-schema-123", + "type": "schema", + "context": "test", + "name": "failingSchema", + "description": "A schema that will fail to generate", + "definition": {} + } + + working_spec = { + "id": "working-schema-456", + "type": "schema", + "context": "test", + "name": "workingSchema", + "description": "A schema that will generate successfully", + "definition": {} + } + + specs = [failing_spec, working_spec] + + # Mock create_schema to fail on the first call and succeed on the second + with patch('polyapi.poly_schemas.create_schema') as mock_create: + mock_create.side_effect = [Exception("Schema generation failed"), None] + + # Capture logging output + with patch('polyapi.poly_schemas.logging.warning') as mock_warning: + generate_schemas(specs) + + # Verify that create_schema was called twice (once for each spec) + self.assertEqual(mock_create.call_count, 2) + + # Verify that warning messages were logged + mock_warning.assert_any_call("WARNING: Failed to generate schema test.failingSchema (id: failing-schema-123): Schema generation failed") + mock_warning.assert_any_call("WARNING: 1 schema(s) failed to generate:") + mock_warning.assert_any_call(" - test.failingSchema (id: failing-schema-123)") + + def test_error_handling_generate_variables(self): + """Test that generate_variables handles errors gracefully and continues with other variables""" + from polyapi.typedefs import VariableSpecDto + + failing_spec = { + "id": "failing-variable-123", + "type": "serverVariable", + "context": "test", + "name": "failingVariable", + "description": "A variable that will fail to generate", + "variable": { + "valueType": {"kind": "primitive", "type": "string"}, + "secrecy": "PUBLIC" + } + } + + working_spec = { + "id": "working-variable-456", + "type": "serverVariable", + "context": "test", + "name": "workingVariable", + "description": "A variable that will generate successfully", + "variable": { + "valueType": {"kind": "primitive", "type": "string"}, + "secrecy": "PUBLIC" + } + } + + specs = [failing_spec, working_spec] + + # Mock create_variable to fail on the first call and succeed on the second + with patch('polyapi.variables.create_variable') as mock_create: + mock_create.side_effect = [Exception("Variable generation failed"), None] + + # Capture logging output + with patch('polyapi.variables.logging.warning') as mock_warning: + generate_variables(specs) + + # Verify that create_variable was called twice (once for each spec) + self.assertEqual(mock_create.call_count, 2) + + # Verify that warning messages were logged + mock_warning.assert_any_call("WARNING: Failed to generate variable test.failingVariable (id: failing-variable-123): Variable generation failed") + mock_warning.assert_any_call("WARNING: 1 variable(s) failed to generate:") + mock_warning.assert_any_call(" - test.failingVariable (id: failing-variable-123)") + + def test_error_handling_webhook_generation(self): + """Test that render_webhook_handle handles errors gracefully during generation""" + from polyapi.webhook import render_webhook_handle + + # Test with problematic arguments that might cause rendering to fail + with patch('polyapi.webhook.parse_arguments') as mock_parse: + mock_parse.side_effect = Exception("Invalid webhook arguments") + + with patch('polyapi.webhook.logging.warning') as mock_warning: + func_str, func_defs = render_webhook_handle( + function_type="webhookHandle", + function_context="test", + function_name="failingWebhook", + function_id="webhook-123", + function_description="A webhook that fails to generate", + arguments=[], + return_type={} + ) + + # Should return empty strings on failure + self.assertEqual(func_str, "") + self.assertEqual(func_defs, "") + + # Should log a warning + mock_warning.assert_called_once_with("Failed to render webhook handle test.failingWebhook (id: webhook-123): Invalid webhook arguments") + + def test_atomic_function_generation_failure(self): + """Test that function generation failures don't leave partial corrupted files""" + import tempfile + from polyapi.generate import add_function_file + + failing_spec = { + "id": "failing-function-123", + "type": "serverFunction", + "context": "test", + "name": "failingFunction", + "description": "A function that will fail to generate", + } + + # Create a temporary directory for testing + with tempfile.TemporaryDirectory() as temp_dir: + # Mock render_spec to fail after being called + with patch('polyapi.generate.render_spec') as mock_render: + mock_render.side_effect = Exception("Rendering failed") + + # Verify that the function generation fails + with self.assertRaises(Exception): + add_function_file(temp_dir, "failingFunction", failing_spec) + + # Verify no partial files were left behind + files_in_dir = os.listdir(temp_dir) + # Should only have __init__.py from init_the_init, no corrupted function files + self.assertNotIn("failing_function.py", files_in_dir) + self.assertNotIn("failingFunction.py", files_in_dir) + + # If __init__.py exists, it should not contain partial imports + init_path = os.path.join(temp_dir, "__init__.py") + if os.path.exists(init_path): + with open(init_path, "r") as f: + init_content = f.read() + self.assertNotIn("from . import failing_function", init_content) + self.assertNotIn("from . import failingFunction", init_content) + + def test_atomic_variable_generation_failure(self): + """Test that variable generation failures don't leave partial corrupted files""" + import tempfile + from polyapi.variables import add_variable_to_init + + failing_spec = { + "id": "failing-variable-123", + "type": "serverVariable", + "context": "test", + "name": "failingVariable", + "description": "A variable that will fail to generate", + "variable": { + "valueType": {"kind": "primitive", "type": "string"}, + "secrecy": "PUBLIC" + } + } + + # Create a temporary directory for testing + with tempfile.TemporaryDirectory() as temp_dir: + # Mock render_variable to fail + with patch('polyapi.variables.render_variable') as mock_render: + mock_render.side_effect = Exception("Variable rendering failed") + + # Verify that the variable generation fails + with self.assertRaises(Exception): + add_variable_to_init(temp_dir, failing_spec) + + # Verify no partial files were left behind and __init__.py wasn't corrupted + init_path = os.path.join(temp_dir, "__init__.py") + if os.path.exists(init_path): + with open(init_path, "r") as f: + init_content = f.read() + # Should not contain partial variable content or broken imports + self.assertNotIn("failingVariable", init_content) + self.assertNotIn("class failingVariable", init_content) + + def test_atomic_schema_generation_failure(self): + """Test that schema generation failures don't leave partial files or directories""" + with patch('tempfile.TemporaryDirectory') as mock_temp_dir: + mock_temp_dir.return_value.__enter__.return_value = "/tmp/test_dir" + + # Mock the render function to fail + with patch('polyapi.poly_schemas.render_poly_schema', side_effect=Exception("Schema generation failed")): + with patch('logging.warning') as mock_warning: + # This should not crash and should log a warning + schemas = [ + { + "id": "schema1", + "name": "TestSchema", + "context": "", + "type": "schema", + "definition": {"type": "object", "properties": {"test": {"type": "string"}}} + } + ] + generate_schemas(schemas) + + # Should have logged a warning about the failed schema + mock_warning.assert_called() + warning_calls = [call[0][0] for call in mock_warning.call_args_list] + # Check that both the main warning and summary warning are present + self.assertTrue(any("Failed to generate schema" in call for call in warning_calls)) + self.assertTrue(any("TestSchema" in call for call in warning_calls)) + self.assertTrue(any("schema1" in call for call in warning_calls)) + + def test_broken_imports_not_left_on_function_failure(self): + """Test that if a function fails after directories are created, we don't leave broken imports""" + import tempfile + import shutil + import os + from polyapi import generate + + with tempfile.TemporaryDirectory() as temp_dir: + # Create a mock polyapi directory structure + polyapi_dir = os.path.join(temp_dir, "polyapi") + os.makedirs(polyapi_dir) + + # Mock spec that would create a nested structure: poly/context/function_name + spec = { + "id": "test-func-id", + "name": "test_function", + "context": "test_context", + "type": "apiFunction", + "description": "Test function", + "function": { + "arguments": [], + "returnType": {"kind": "any"} + } + } + + # Mock the add_function_file to fail AFTER directories are created + + def failing_add_function_file(*args, **kwargs): + raise Exception("Function file creation failed") + + with patch('polyapi.generate.add_function_file', side_effect=failing_add_function_file): + with patch('os.path.dirname') as mock_dirname: + mock_dirname.return_value = polyapi_dir + with patch('logging.warning') as mock_warning: + + # This should fail gracefully + try: + generate.create_function(spec) + except: + pass # Expected to fail + + # Check that no intermediate directories were left behind + poly_dir = os.path.join(polyapi_dir, "poly") + if os.path.exists(poly_dir): + context_dir = os.path.join(poly_dir, "test_context") + + # If intermediate directories exist, they should not have broken imports + if os.path.exists(context_dir): + init_file = os.path.join(context_dir, "__init__.py") + if os.path.exists(init_file): + with open(init_file, 'r') as f: + content = f.read() + # Should not contain import for the failed function + self.assertNotIn("test_function", content) + + # The function directory should not exist + func_dir = os.path.join(context_dir, "test_function") + self.assertFalse(os.path.exists(func_dir)) + + def test_intermediate_init_files_handle_failure_correctly(self): + """Test that intermediate __init__.py files are handled correctly when function generation fails""" + import tempfile + import os + from polyapi import generate + + with tempfile.TemporaryDirectory() as temp_dir: + polyapi_dir = os.path.join(temp_dir, "polyapi") + os.makedirs(polyapi_dir) + + # Create a poly directory and context directory beforehand + poly_dir = os.path.join(polyapi_dir, "poly") + context_dir = os.path.join(poly_dir, "test_context") + os.makedirs(context_dir) + + # Put some existing content in the context __init__.py + init_file = os.path.join(context_dir, "__init__.py") + with open(init_file, 'w') as f: + f.write("# Existing context init file\nfrom . import existing_function\n") + + spec = { + "id": "test-func-id", + "name": "failing_function", + "context": "test_context", + "type": "apiFunction", + "description": "Test function", + "function": { + "arguments": [], + "returnType": {"kind": "any"} + } + } + + # Mock add_function_file to fail + def failing_add_function_file(full_path, function_name, spec): + # This simulates failure AFTER intermediate directories are processed + # but BEFORE the final function file is written + raise Exception("Function file creation failed") + + with patch('polyapi.generate.add_function_file', side_effect=failing_add_function_file): + with patch('os.path.dirname') as mock_dirname: + mock_dirname.return_value = polyapi_dir + + # This should fail but handle cleanup gracefully + try: + generate.create_function(spec) + except: + pass # Expected to fail + + # The context __init__.py should not contain import for failed function + with open(init_file, 'r') as f: + content = f.read() + + # Should still have existing content + self.assertIn("existing_function", content) + # Should NOT have the failed function + self.assertNotIn("failing_function", content) + + # The failed function directory should not exist + func_dir = os.path.join(context_dir, "failing_function") + self.assertFalse(os.path.exists(func_dir))