From 56c6cba08224eab085fa87041e0d581e05dd6a9f Mon Sep 17 00:00:00 2001 From: Richard Dzurus Date: Tue, 27 May 2025 23:09:49 +0200 Subject: [PATCH] create mock schemas to fit everything when using no types flag --- polyapi/function_cli.py | 2 +- polyapi/generate.py | 133 ++++++++++++++++++++------ polyapi/utils.py | 57 ++++++++--- pyproject.toml | 2 +- tests/test_generate.py | 206 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 359 insertions(+), 41 deletions(-) diff --git a/polyapi/function_cli.py b/polyapi/function_cli.py index bff9a90..bc99f2b 100644 --- a/polyapi/function_cli.py +++ b/polyapi/function_cli.py @@ -86,7 +86,7 @@ def function_add_or_update( headers = get_auth_headers(api_key) resp = requests.post(url, headers=headers, json=data) - if resp.status_code == 201: + if resp.status_code in [200, 201]: print_green("DEPLOYED") function_id = resp.json()["id"] print(f"Function ID: {function_id}") diff --git a/polyapi/generate.py b/polyapi/generate.py index 1558b9e..df848a1 100644 --- a/polyapi/generate.py +++ b/polyapi/generate.py @@ -129,24 +129,26 @@ def parse_function_specs( ) -> List[SpecificationDto]: functions = [] for spec in specs: - if not spec or "function" not in spec: - continue - - if not spec["function"]: - continue - - if limit_ids and spec["id"] not in limit_ids: + if not spec: continue + # For no_types mode, we might not have function data, but we still want to include the spec + # if it's a supported function type if spec["type"] not in SUPPORTED_FUNCTION_TYPES: continue - if spec["type"] == "customFunction" and spec["language"] != "python": - # poly libraries only support client functions of same language + # Skip if we have a limit and this spec is not in it + if limit_ids and spec.get("id") not in limit_ids: continue + # For customFunction, check language if we have function data + if spec["type"] == "customFunction": + if spec.get("language") and spec["language"] != "python": + # poly libraries only support client functions of same language + continue + # Functions with serverSideAsync True will always return a Dict with execution ID - if spec.get('serverSideAsync'): + if spec.get('serverSideAsync') and spec.get("function"): spec['function']['returnType'] = {'kind': 'plain', 'value': 'object'} functions.append(spec) @@ -205,6 +207,63 @@ def remove_old_library(): shutil.rmtree(path) +def create_empty_schemas_module(): + """Create an empty schemas module for no-types mode so user code can still import from polyapi.schemas""" + currdir = os.path.dirname(os.path.abspath(__file__)) + schemas_path = os.path.join(currdir, "schemas") + + # Create the schemas directory + if not os.path.exists(schemas_path): + os.makedirs(schemas_path) + + # Create an __init__.py file with dynamic schema resolution + init_path = os.path.join(schemas_path, "__init__.py") + with open(init_path, "w") as f: + f.write('''"""Empty schemas module for no-types mode""" +from typing import Any, Dict + +class _GenericSchema(Dict[str, Any]): + """Generic schema type that acts like a Dict for no-types mode""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + +class _SchemaModule: + """Dynamic module that returns itself for attribute access, allowing infinite nesting""" + + def __getattr__(self, name: str): + # For callable access (like schemas.Response()), return the generic schema class + # For further attribute access (like schemas.random.random2), return self to allow nesting + return _NestedSchemaAccess() + + def __call__(self, *args, **kwargs): + # If someone tries to call the module itself, return a generic schema + return _GenericSchema(*args, **kwargs) + + def __dir__(self): + # Return common schema names for introspection + return ['Response', 'Request', 'Error', 'Data', 'Result'] + +class _NestedSchemaAccess: + """Handles nested attribute access and final callable resolution""" + + def __getattr__(self, name: str): + # Continue allowing nested access + return _NestedSchemaAccess() + + def __call__(self, *args, **kwargs): + # When finally called, return a generic schema instance + return _GenericSchema(*args, **kwargs) + + def __class_getitem__(cls, item): + # Support type annotations like schemas.Response[str] + return _GenericSchema + +# Replace this module with our dynamic module +import sys +sys.modules[__name__] = _SchemaModule() +''') + + def generate(contexts: 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) @@ -216,14 +275,23 @@ def generate(contexts: Optional[List[str]] = None, no_types: bool = False) -> No limit_ids: List[str] = [] # useful for narrowing down generation to a single function to debug functions = parse_function_specs(specs, limit_ids=limit_ids) - schemas = get_schemas() - schema_index = build_schema_index(schemas) - if schemas: - schema_limit_ids: List[str] = [] # useful for narrowing down generation to a single function to debug - schemas = replace_poly_refs_in_schemas(schemas, schema_index) - generate_schemas(schemas, limit_ids=schema_limit_ids) - - functions = replace_poly_refs_in_functions(functions, schema_index) + # Only process schemas if no_types is False + if not no_types: + schemas = get_schemas() + schema_index = build_schema_index(schemas) + if schemas: + schema_limit_ids: List[str] = [] # useful for narrowing down generation to a single function to debug + schemas = replace_poly_refs_in_schemas(schemas, schema_index) + generate_schemas(schemas, limit_ids=schema_limit_ids) + + functions = replace_poly_refs_in_functions(functions, schema_index) + else: + # When no_types is True, we still need to process functions but without schema resolution + # Use an empty schema index to avoid poly-ref resolution + schema_index = {} + + # Create an empty schemas module so user code can still import from polyapi.schemas + create_empty_schemas_module() if functions: generate_functions(functions) @@ -233,10 +301,11 @@ def generate(contexts: Optional[List[str]] = None, no_types: bool = False) -> No ) exit() - variables = get_variables() - if variables: - generate_variables(variables) - + # Only process variables if no_types is False + if not no_types: + 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") @@ -266,11 +335,19 @@ def render_spec(spec: SpecificationDto) -> Tuple[str, str]: arguments: List[PropertySpecification] = [] return_type = {} - if spec["function"]: - arguments = [ - arg for arg in spec["function"]["arguments"] - ] - return_type = spec["function"]["returnType"] + if spec.get("function"): + # Handle cases where arguments might be missing or None + if spec["function"].get("arguments"): + arguments = [ + arg for arg in spec["function"]["arguments"] + ] + + # Handle cases where returnType might be missing or None + if spec["function"].get("returnType"): + return_type = spec["function"]["returnType"] + else: + # Provide a fallback return type when missing + return_type = {"kind": "any"} if function_type == "apiFunction": func_str, func_type_defs = render_api_function( @@ -284,7 +361,7 @@ def render_spec(spec: SpecificationDto) -> Tuple[str, str]: elif function_type == "customFunction": func_str, func_type_defs = render_client_function( function_name, - spec["code"], + spec.get("code", ""), arguments, return_type, ) diff --git a/polyapi/utils.py b/polyapi/utils.py index b9ffa7f..470c4b2 100644 --- a/polyapi/utils.py +++ b/polyapi/utils.py @@ -97,20 +97,32 @@ def get_type_and_def( ) -> Tuple[str, str]: """ returns type and type definition for a given PropertyType """ + # Handle cases where type_spec might be None or empty + if not type_spec: + return "Any", "" + + # Handle cases where kind might be missing + if "kind" not in type_spec: + return "Any", "" + if type_spec["kind"] == "plain": - value = type_spec["value"] + value = type_spec.get("value", "") if value.endswith("[]"): primitive = map_primitive_types(value[:-2]) return f"List[{primitive}]", "" else: return map_primitive_types(value), "" elif type_spec["kind"] == "primitive": - return map_primitive_types(type_spec["type"]), "" + return map_primitive_types(type_spec.get("type", "any")), "" elif type_spec["kind"] == "array": if type_spec.get("items"): items = type_spec["items"] if items.get("$ref"): - return wrapped_generate_schema_types(type_spec, "ResponseType", "Dict") # type: ignore + # For no-types mode, avoid complex schema generation + try: + return wrapped_generate_schema_types(type_spec, "ResponseType", "Dict") # type: ignore + except: + return "List[Dict]", "" else: item_type, _ = get_type_and_def(items) title = f"List[{item_type}]" @@ -130,13 +142,20 @@ def get_type_and_def( return "List", "" elif title: assert isinstance(title, str) - return wrapped_generate_schema_types(schema, title, "Dict") # type: ignore + # For no-types mode, avoid complex schema generation + try: + return wrapped_generate_schema_types(schema, title, "Dict") # type: ignore + except: + return "Dict", "" elif schema.get("allOf") and len(schema["allOf"]): # we are in a case of a single allOf, lets strip off the allOf and move on! # our library doesn't handle allOf well yet allOf = schema["allOf"][0] title = allOf.get("title", allOf.get("name", title_fallback)) - return wrapped_generate_schema_types(allOf, title, "Dict") + try: + return wrapped_generate_schema_types(allOf, title, "Dict") + except: + return "Dict", "" elif schema.get("items"): # fallback to schema $ref name if no explicit title items = schema.get("items") # type: ignore @@ -150,7 +169,10 @@ def get_type_and_def( return "List", "" title = f"List[{title}]" - return wrapped_generate_schema_types(schema, title, "List") + try: + return wrapped_generate_schema_types(schema, title, "List") + except: + return "List[Dict]", "" elif schema.get("properties"): result = wrapped_generate_schema_types(schema, "ResponseType", "Dict") # type: ignore return result @@ -190,9 +212,13 @@ def get_type_and_def( def _maybe_add_fallback_schema_name(a: PropertySpecification): - if a["type"]["kind"] == "object" and a["type"].get("schema"): + # Handle cases where type might be missing + if not a.get("type"): + return + + if a["type"].get("kind") == "object" and a["type"].get("schema"): schema = a["type"].get("schema", {}) - if not schema.get("title") and not schema.get("name") and a["name"]: + if not schema.get("title") and not schema.get("name") and a.get("name"): schema["title"] = a["name"].title() @@ -203,14 +229,23 @@ def parse_arguments( arg_string = "" for idx, a in enumerate(arguments): _maybe_add_fallback_schema_name(a) - arg_type, arg_def = get_type_and_def(a["type"]) + + # Handle cases where type might be missing + arg_type_spec = a.get("type", {"kind": "any"}) + arg_type, arg_def = get_type_and_def(arg_type_spec) if arg_def: args_def.append(arg_def) - a["name"] = rewrite_arg_name(a["name"]) + + # Handle cases where name might be missing + arg_name = a.get("name", f"arg{idx}") + a["name"] = rewrite_arg_name(arg_name) + arg_string += ( f" {a['name']}: {add_type_import_path(function_name, arg_type)}" ) - if not a["required"]: + + # Handle cases where required might be missing + if not a.get("required", True): arg_string += " = None" description = a.get("description", "") diff --git a/pyproject.toml b/pyproject.toml index bb639af..f5ff503 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=61.2", "wheel"] [project] name = "polyapi-python" -version = "0.3.7.dev2" +version = "0.3.7.dev3" description = "The Python Client for PolyAPI, the IPaaS by Developers for Developers" authors = [{ name = "Dan Fellin", email = "dan@polyapi.io" }] dependencies = [ diff --git a/tests/test_generate.py b/tests/test_generate.py index 2e6bcb2..b738ba6 100644 --- a/tests/test_generate.py +++ b/tests/test_generate.py @@ -1,5 +1,9 @@ import unittest +import os +import shutil +import importlib.util from polyapi.utils import get_type_and_def, rewrite_reserved +from polyapi.generate import render_spec, create_empty_schemas_module OPENAPI_FUNCTION = { "kind": "function", @@ -72,6 +76,28 @@ }, } +# Test spec with missing function data (simulating no_types=true) +NO_TYPES_SPEC = { + "id": "test-id-123", + "type": "serverFunction", + "context": "test", + "name": "testFunction", + "description": "A test function for no-types mode", + # Note: no "function" field, simulating no_types=true response +} + +# Test spec with minimal function data +MINIMAL_FUNCTION_SPEC = { + "id": "test-id-456", + "type": "apiFunction", + "context": "test", + "name": "minimalFunction", + "description": "A minimal function spec", + "function": { + # Note: no "arguments" or "returnType" fields + } +} + class T(unittest.TestCase): def test_get_type_and_def(self): @@ -81,3 +107,183 @@ def test_get_type_and_def(self): def test_rewrite_reserved(self): rv = rewrite_reserved("from") self.assertEqual(rv, "_from") + + def test_render_spec_no_function_data(self): + """Test that render_spec handles specs with no function data gracefully""" + func_str, func_type_defs = render_spec(NO_TYPES_SPEC) + + # Should generate a function even without function data + self.assertIsNotNone(func_str) + self.assertIsNotNone(func_type_defs) + self.assertIn("testFunction", func_str) + self.assertIn("test-id-123", func_str) + + def test_render_spec_minimal_function_data(self): + """Test that render_spec handles specs with minimal function data""" + func_str, func_type_defs = render_spec(MINIMAL_FUNCTION_SPEC) + + # Should generate a function with fallback types + self.assertIsNotNone(func_str) + self.assertIsNotNone(func_type_defs) + self.assertIn("minimalFunction", func_str) + self.assertIn("test-id-456", func_str) + # Should use Any as fallback return type in the type definitions + self.assertIn("Any", func_type_defs) + + def test_create_empty_schemas_module(self): + """Test that create_empty_schemas_module creates the necessary files""" + # Clean up any existing schemas directory + schemas_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "polyapi", "schemas") + if os.path.exists(schemas_path): + shutil.rmtree(schemas_path) + + # Create empty schemas module + create_empty_schemas_module() + + # Verify the directory and __init__.py file were created + self.assertTrue(os.path.exists(schemas_path)) + init_path = os.path.join(schemas_path, "__init__.py") + self.assertTrue(os.path.exists(init_path)) + + # Verify the content of __init__.py includes dynamic schema handling + with open(init_path, "r") as f: + content = f.read() + self.assertIn("Empty schemas module for no-types mode", content) + self.assertIn("_GenericSchema", content) + self.assertIn("_SchemaModule", content) + self.assertIn("__getattr__", content) + + # Clean up + shutil.rmtree(schemas_path) + + def test_no_types_workflow(self): + """Test the complete no-types workflow including schema imports and function parsing""" + import tempfile + import sys + from unittest.mock import patch + + # Clean up any existing schemas directory + schemas_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "polyapi", "schemas") + if os.path.exists(schemas_path): + shutil.rmtree(schemas_path) + + # Mock get_specs to return empty list (simulating no functions) + with patch('polyapi.generate.get_specs', return_value=[]): + try: + # This should exit with SystemExit due to no functions + from polyapi.generate import generate + generate(no_types=True) + except SystemExit: + pass # Expected when no functions exist + + # Verify schemas module was created + self.assertTrue(os.path.exists(schemas_path)) + init_path = os.path.join(schemas_path, "__init__.py") + self.assertTrue(os.path.exists(init_path)) + + # Test that we can import schemas and use arbitrary schema names + from polyapi import schemas + + # Test various schema access + Response = schemas.Response + CustomType = schemas.CustomType + AnyName = schemas.SomeArbitrarySchemaName + + # All should return the same generic schema class type + self.assertEqual(type(Response).__name__, '_NestedSchemaAccess') + self.assertEqual(type(CustomType).__name__, '_NestedSchemaAccess') + self.assertEqual(type(AnyName).__name__, '_NestedSchemaAccess') + + # Test creating instances + response_instance = Response() + custom_instance = CustomType() + + self.assertIsInstance(response_instance, dict) + self.assertIsInstance(custom_instance, dict) + + # Test that function code with schema references can be parsed + test_code = ''' +from polyapi import polyCustom, schemas + +def test_function() -> schemas.Response: + polyCustom["executionId"] = "123" + return polyCustom +''' + + # Create a temporary file with the test code + with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f: + f.write(test_code) + temp_file = f.name + + try: + # Test that the parser can handle this code + from polyapi.parser import parse_function_code + result = parse_function_code(test_code, 'test_function', 'test_context') + + self.assertEqual(result['name'], 'test_function') + self.assertEqual(result['context'], 'test_context') + # Return type should be Any since we're in no-types mode + self.assertEqual(result['types']['returns']['type'], 'Any') + + finally: + # Clean up temp file + os.unlink(temp_file) + + # Clean up schemas directory + shutil.rmtree(schemas_path) + + def test_nested_schema_access(self): + """Test that nested schema access like schemas.random.random2.random3 works""" + # Clean up any existing schemas directory + schemas_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "polyapi", "schemas") + if os.path.exists(schemas_path): + shutil.rmtree(schemas_path) + + # Create empty schemas module + create_empty_schemas_module() + + # Test that we can import and use nested schemas + from polyapi import schemas + + # Test various levels of nesting + simple = schemas.Response + nested = schemas.random.random2 + deep_nested = schemas.api.v1.user.profile.data + very_deep = schemas.some.very.deep.nested.schema.access + + # All should be _NestedSchemaAccess instances + self.assertEqual(type(simple).__name__, '_NestedSchemaAccess') + self.assertEqual(type(nested).__name__, '_NestedSchemaAccess') + self.assertEqual(type(deep_nested).__name__, '_NestedSchemaAccess') + self.assertEqual(type(very_deep).__name__, '_NestedSchemaAccess') + + # Test that they can be called and return generic schemas + simple_instance = simple() + nested_instance = nested() + deep_instance = deep_nested() + very_deep_instance = very_deep() + + # All should be dictionaries + self.assertIsInstance(simple_instance, dict) + self.assertIsInstance(nested_instance, dict) + self.assertIsInstance(deep_instance, dict) + self.assertIsInstance(very_deep_instance, dict) + + # Test that function code with nested schemas can be parsed + test_code = ''' +from polyapi import polyCustom, schemas + +def test_nested_function() -> schemas.api.v1.user.profile: + return schemas.api.v1.user.profile() +''' + + from polyapi.parser import parse_function_code + result = parse_function_code(test_code, 'test_nested_function', 'test_context') + + self.assertEqual(result['name'], 'test_nested_function') + self.assertEqual(result['context'], 'test_context') + # Return type should be Any since we're in no-types mode + self.assertEqual(result['types']['returns']['type'], 'Any') + + # Clean up schemas directory + shutil.rmtree(schemas_path)