diff --git a/.gitignore b/.gitignore index 49ae8f4..135534e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,5 @@ -.env -.env* -.venv/ -.venv/* +*env +*venv .DS_Store # Pip diff --git a/polyapi/__init__.py b/polyapi/__init__.py index 2a30c36..583d1f3 100644 --- a/polyapi/__init__.py +++ b/polyapi/__init__.py @@ -1,7 +1,9 @@ import os import sys +import copy import truststore -from typing import Dict, Any +from typing import Any, Dict, Optional, overload, Literal +from typing_extensions import TypedDict truststore.inject_into_ssl() from .cli import CLI_COMMANDS @@ -15,9 +17,85 @@ sys.exit(1) -polyCustom: Dict[str, Any] = { - "executionId": None, - "executionApiKey": None, - "responseStatusCode": 200, - "responseContentType": None, -} \ No newline at end of file +class PolyCustomDict(TypedDict, total=False): + """Type definition for polyCustom dictionary.""" + executionId: Optional[str] # Read-only + executionApiKey: Optional[str] + responseStatusCode: int + responseContentType: Optional[str] + responseHeaders: Dict[str, str] + + +class _PolyCustom: + def __init__(self): + self._internal_store = { + "executionId": None, + "executionApiKey": None, + "responseStatusCode": 200, + "responseContentType": None, + "responseHeaders": {}, + } + self._execution_id_locked = False + + def set_once(self, key: str, value: Any) -> None: + if key == "executionId" and self._execution_id_locked: + # Silently ignore attempts to overwrite locked executionId + return + self._internal_store[key] = value + if key == "executionId": + # Lock executionId after setting it + self.lock_execution_id() + + def get(self, key: str, default: Any = None) -> Any: + return self._internal_store.get(key, default) + + def lock_execution_id(self) -> None: + self._execution_id_locked = True + + def unlock_execution_id(self) -> None: + self._execution_id_locked = False + + @overload + def __getitem__(self, key: Literal["executionId"]) -> Optional[str]: ... + + @overload + def __getitem__(self, key: Literal["executionApiKey"]) -> Optional[str]: ... + + @overload + def __getitem__(self, key: Literal["responseStatusCode"]) -> int: ... + + @overload + def __getitem__(self, key: Literal["responseContentType"]) -> Optional[str]: ... + + @overload + def __getitem__(self, key: Literal["responseHeaders"]) -> Dict[str, str]: ... + + def __getitem__(self, key: str) -> Any: + return self.get(key) + + @overload + def __setitem__(self, key: Literal["executionApiKey"], value: Optional[str]) -> None: ... + + @overload + def __setitem__(self, key: Literal["responseStatusCode"], value: int) -> None: ... + + @overload + def __setitem__(self, key: Literal["responseContentType"], value: Optional[str]) -> None: ... + + @overload + def __setitem__(self, key: Literal["responseHeaders"], value: Dict[str, str]) -> None: ... + + def __setitem__(self, key: str, value: Any) -> None: + self.set_once(key, value) + + def __repr__(self) -> str: + return f"PolyCustom({self._internal_store})" + + def copy(self) -> '_PolyCustom': + new = _PolyCustom() + new._internal_store = copy.deepcopy(self._internal_store) + new._execution_id_locked = self._execution_id_locked + return new + + +polyCustom: PolyCustomDict = _PolyCustom() \ No newline at end of file diff --git a/polyapi/api.py b/polyapi/api.py index 9533b21..fc243ba 100644 --- a/polyapi/api.py +++ b/polyapi/api.py @@ -23,8 +23,16 @@ def {function_name}( Function ID: {function_id} \""" - resp = execute("{function_type}", "{function_id}", {data}) - return {api_response_type}(resp.json()) # type: ignore + if get_direct_execute_config(): + resp = direct_execute("{function_type}", "{function_id}", {data}) + return {api_response_type}({{ + "status": resp.status_code, + "headers": dict(resp.headers), + "data": resp.json() + }}) # type: ignore + else: + resp = execute("{function_type}", "{function_id}", {data}) + return {api_response_type}(resp.json()) # type: ignore """ diff --git a/polyapi/cli.py b/polyapi/cli.py index 67866d7..7299841 100644 --- a/polyapi/cli.py +++ b/polyapi/cli.py @@ -44,10 +44,13 @@ def setup(args): ########################################################################### # Generate command 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") def generate_command(args): initialize_config() - generate() + contexts = args.contexts.split(",") if args.contexts else None + generate(contexts=contexts, no_types=args.no_types) generate_parser.set_defaults(command=generate_command) @@ -68,6 +71,7 @@ def generate_command(args): fn_add_parser.add_argument("--logs", choices=["enabled", "disabled"], default=None, help="Enable or disable logs for the function.") fn_add_parser.add_argument("--execution-api-key", required=False, default="", help="API key for execution (for server functions only).") fn_add_parser.add_argument("--disable-ai", "--skip-generate", action="store_true", help="Pass --disable-ai skip AI generation of missing descriptions") + fn_add_parser.add_argument("--generate-contexts", type=str, help="Server function only – only include certain contexts to speed up function execution") def add_function(args): initialize_config() @@ -79,6 +83,8 @@ def add_function(args): err = "You must specify `--server` or `--client`." elif logs_enabled and not args.server: err = "Option `logs` is only for server functions (--server)." + elif args.generate_contexts and not args.server: + err = "Option `generate-contexts` is only for server functions (--server)." if err: print_red("ERROR") @@ -94,7 +100,8 @@ def add_function(args): server=args.server, logs_enabled=logs_enabled, generate=not args.disable_ai, - execution_api_key=args.execution_api_key + execution_api_key=args.execution_api_key, + generate_contexts=args.generate_contexts ) fn_add_parser.set_defaults(command=add_function) diff --git a/polyapi/config.py b/polyapi/config.py index 2d8dedf..60eb16f 100644 --- a/polyapi/config.py +++ b/polyapi/config.py @@ -3,11 +3,15 @@ import configparser from typing import Tuple -from polyapi.utils import is_valid_polyapi_url, is_valid_uuid, print_green, print_yellow +from polyapi.utils import is_valid_polyapi_url, print_green, print_yellow # cached values API_KEY = None API_URL = None +API_FUNCTION_DIRECT_EXECUTE = None +MTLS_CERT_PATH = None +MTLS_KEY_PATH = None +MTLS_CA_PATH = None def get_config_file_path() -> str: @@ -45,6 +49,13 @@ def get_api_key_and_url() -> Tuple[str | None, str | None]: API_KEY = key API_URL = url + # Read and cache MTLS and direct execute settings + global API_FUNCTION_DIRECT_EXECUTE, MTLS_CERT_PATH, MTLS_KEY_PATH, MTLS_CA_PATH + API_FUNCTION_DIRECT_EXECUTE = config.get("polyapi", "api_function_direct_execute", fallback="false").lower() == "true" + 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) + return key, url @@ -78,8 +89,6 @@ def initialize_config(force=False): errors = [] if not is_valid_polyapi_url(url): errors.append(f"{url} is not a valid Poly API Base URL") - if not is_valid_uuid(key): - errors.append(f"{key} is not a valid Poly App Key or User Key") if errors: print_yellow("\n".join(errors)) sys.exit(1) @@ -106,4 +115,22 @@ def clear_config(): path = get_config_file_path() if os.path.exists(path): - os.remove(path) \ No newline at end of file + os.remove(path) + + +def get_mtls_config() -> Tuple[bool, str | None, str | None, str | None]: + """Return MTLS configuration settings""" + global MTLS_CERT_PATH, MTLS_KEY_PATH, MTLS_CA_PATH + if MTLS_CERT_PATH is None or MTLS_KEY_PATH is None or MTLS_CA_PATH is None: + # Force a config read if values aren't cached + get_api_key_and_url() + return bool(MTLS_CERT_PATH and MTLS_KEY_PATH and MTLS_CA_PATH), MTLS_CERT_PATH, MTLS_KEY_PATH, MTLS_CA_PATH + + +def get_direct_execute_config() -> bool: + """Return whether direct execute is enabled""" + global API_FUNCTION_DIRECT_EXECUTE + 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 diff --git a/polyapi/execute.py b/polyapi/execute.py index 26b5948..d066574 100644 --- a/polyapi/execute.py +++ b/polyapi/execute.py @@ -1,26 +1,68 @@ -from typing import Dict +from typing import Dict, Optional import requests from requests import Response -from polyapi.config import get_api_key_and_url +from polyapi.config import get_api_key_and_url, get_mtls_config from polyapi.exceptions import PolyApiException +def direct_execute(function_type, function_id, data) -> Response: + """ execute a specific function id/type + """ + api_key, api_url = get_api_key_and_url() + headers = {"Authorization": f"Bearer {api_key}"} + url = f"{api_url}/functions/{function_type}/{function_id}/direct-execute" + + endpoint_info = requests.post(url, json=data, headers=headers) + if endpoint_info.status_code < 200 or endpoint_info.status_code >= 300: + raise PolyApiException(f"{endpoint_info.status_code}: {endpoint_info.content.decode('utf-8', errors='ignore')}") + + endpoint_info_data = endpoint_info.json() + request_params = endpoint_info_data.copy() + request_params.pop("url", None) + + if "maxRedirects" in request_params: + request_params["allow_redirects"] = request_params.pop("maxRedirects") > 0 + + has_mtls, cert_path, key_path, ca_path = get_mtls_config() + + if has_mtls: + resp = requests.request( + url=endpoint_info_data["url"], + cert=(cert_path, key_path), + verify=ca_path, + **request_params + ) + else: + resp = requests.request( + url=endpoint_info_data["url"], + verify=False, + **request_params + ) + + if resp.status_code < 200 or resp.status_code >= 300: + error_content = resp.content.decode("utf-8", errors="ignore") + raise PolyApiException(f"{resp.status_code}: {error_content}") + + return resp def execute(function_type, function_id, data) -> Response: """ execute a specific function id/type """ - data_without_None = data - if isinstance(data, Dict): - data_without_None = {k: v for k, v in data.items() if v is not None} - api_key, api_url = get_api_key_and_url() headers = {"Authorization": f"Bearer {api_key}"} + url = f"{api_url}/functions/{function_type}/{function_id}/execute" - resp = requests.post(url, json=data_without_None, headers=headers) - # print(resp.status_code) - # print(resp.headers["content-type"]) + + # Make the request + resp = requests.post( + url, + json=data, + headers=headers, + ) + if resp.status_code < 200 or resp.status_code >= 300: error_content = resp.content.decode("utf-8", errors="ignore") raise PolyApiException(f"{resp.status_code}: {error_content}") + return resp diff --git a/polyapi/function_cli.py b/polyapi/function_cli.py index bc836fa..bc99f2b 100644 --- a/polyapi/function_cli.py +++ b/polyapi/function_cli.py @@ -24,6 +24,7 @@ def function_add_or_update( client: bool, server: bool, logs_enabled: Optional[bool], + generate_contexts: Optional[str], generate: bool = True, execution_api_key: str = "" ): @@ -59,6 +60,9 @@ def function_add_or_update( "logsEnabled": logs_enabled, } + if generate_contexts: + data["generateContexts"] = generate_contexts.split(",") + if server and parsed["dependencies"]: print_yellow( "\nPlease note that deploying your functions will take a few minutes because it makes use of libraries other than polyapi." @@ -82,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 6873450..df848a1 100644 --- a/polyapi/generate.py +++ b/polyapi/generate.py @@ -2,7 +2,7 @@ import requests import os import shutil -from typing import List, Tuple, cast +from typing import List, Optional, Tuple, cast from .auth import render_auth_function from .client import render_client_function @@ -14,7 +14,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 +from .config import get_api_key_and_url, get_direct_execute_config SUPPORTED_FUNCTION_TYPES = { "apiFunction", @@ -36,12 +36,21 @@ path:''' -def get_specs() -> List: +def get_specs(contexts=Optional[List[str]], 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" - resp = requests.get(url, headers=headers) + params = {"noTypes": str(no_types).lower()} + + if contexts: + params["contexts"] = contexts + + # Add apiFunctionDirectExecute parameter if direct execute is enabled + if get_direct_execute_config(): + params["apiFunctionDirectExecute"] = "true" + + resp = requests.get(url, headers=headers, params=params) if resp.status_code == 200: return resp.json() else: @@ -120,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) @@ -196,24 +207,91 @@ def remove_old_library(): shutil.rmtree(path) -def generate() -> None: - print("Generating Poly Python SDK...", end="", flush=True) +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) remove_old_library() - specs = get_specs() + specs = get_specs(no_types=no_types, contexts=contexts) cache_specs(specs) 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) @@ -223,10 +301,11 @@ def generate() -> None: ) 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") @@ -256,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( @@ -274,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/rendered_spec.py b/polyapi/rendered_spec.py index 7de7cad..2206417 100644 --- a/polyapi/rendered_spec.py +++ b/polyapi/rendered_spec.py @@ -35,11 +35,12 @@ def update_rendered_spec(spec: SpecificationDto): assert resp.status_code == 201, (resp.text, resp.status_code) -def _get_spec(spec_id: str) -> Optional[SpecificationDto]: +def _get_spec(spec_id: str, no_types: bool = False) -> Optional[SpecificationDto]: api_key, base_url = get_api_key_and_url() url = f"{base_url}/specs" headers = {"Authorization": f"Bearer {api_key}"} - resp = requests.get(url, headers=headers) + params = {"noTypes": str(no_types).lower()} + resp = requests.get(url, headers=headers, params=params) if resp.status_code == 200: specs = resp.json() for spec in specs: diff --git a/polyapi/schema.py b/polyapi/schema.py index 1dccd22..6ca3391 100644 --- a/polyapi/schema.py +++ b/polyapi/schema.py @@ -126,4 +126,8 @@ def clean_title(title: str) -> str: def map_primitive_types(type_: str) -> str: # Define your mapping logic here - return JSONSCHEMA_TO_PYTHON_TYPE_MAP.get(type_, "Any") \ No newline at end of file + return JSONSCHEMA_TO_PYTHON_TYPE_MAP.get(type_, "Any") + + +def is_primitive(type_: str) -> bool: + return type_ in JSONSCHEMA_TO_PYTHON_TYPE_MAP diff --git a/polyapi/typedefs.py b/polyapi/typedefs.py index 3a6d84a..6d6ff18 100644 --- a/polyapi/typedefs.py +++ b/polyapi/typedefs.py @@ -40,11 +40,15 @@ class SpecificationDto(TypedDict): language: str +# Enum for variable secrecy levels +Secrecy = Literal['SECRET', 'OBSCURED', 'NONE'] + + class VariableSpecification(TypedDict): environmentId: str value: Any valueType: PropertyType - secret: bool + secrecy: Secrecy class VariableSpecDto(TypedDict): diff --git a/polyapi/utils.py b/polyapi/utils.py index e09383d..3b61f5e 100644 --- a/polyapi/utils.py +++ b/polyapi/utils.py @@ -2,6 +2,7 @@ import re import os import uuid +from urllib.parse import urlparse from typing import Tuple, List from colorama import Fore, Style from polyapi.constants import BASIC_PYTHON_TYPES @@ -10,12 +11,13 @@ wrapped_generate_schema_types, clean_title, map_primitive_types, + is_primitive ) # this string should be in every __init__ file. # it contains all the imports needed for the function or variable code to run -CODE_IMPORTS = "from typing import List, Dict, Any, Optional, Callable\nfrom typing_extensions import TypedDict, NotRequired\nimport logging\nimport requests\nimport socketio # type: ignore\nfrom polyapi.config import get_api_key_and_url\nfrom polyapi.execute import execute, execute_post, variable_get, variable_update\n\n" +CODE_IMPORTS = "from typing import List, Dict, Any, Optional, Callable\nfrom typing_extensions import TypedDict, NotRequired\nimport logging\nimport requests\nimport socketio # type: ignore\nfrom polyapi.config import get_api_key_and_url, get_direct_execute_config\nfrom polyapi.execute import execute, execute_post, variable_get, variable_update, direct_execute\n\n" def init_the_init(full_path: str, code_imports="") -> None: @@ -96,20 +98,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}]" @@ -127,15 +141,28 @@ def get_type_and_def( # TODO fix me # we don't use ReturnType as name for the list type here, we use _ReturnTypeItem return "List", "" + elif title and title == "ReturnType" and schema.get("type"): + assert isinstance(title, str) + schema_type = schema.get("type", "Any") + root_type, generated_code = wrapped_generate_schema_types(schema, schema_type, "Dict") # type: ignore + return (map_primitive_types(root_type), "") if is_primitive(root_type) else (root_type, generated_code) # type: ignore 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: + root_type, generated_code = wrapped_generate_schema_types(schema, title, "Dict") # type: ignore + return ("Any", "") if root_type == "ReturnType" else 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 @@ -149,9 +176,15 @@ 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 else: - return "Any", "" + return "Dict", "" else: return "Dict", "" elif type_spec["kind"] == "function": @@ -186,12 +219,22 @@ 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() +def _clean_description(text: str) -> str: + """Flatten new-lines and collapse excess whitespace.""" + text = text.replace("\\n", " ").replace("\n", " ") + return re.sub(r"\s+", " ", text).strip() + + def parse_arguments( function_name: str, arguments: List[PropertySpecification] ) -> Tuple[str, str]: @@ -199,18 +242,27 @@ 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", "") - description = description.replace("\n", " ") + description = _clean_description(a.get("description", "")) + if description: if idx == len(arguments) - 1: arg_string += f" # {description}\n" @@ -261,21 +313,16 @@ def rewrite_arg_name(s: str): def is_valid_polyapi_url(_url: str): + # in dev allow localhost (and 127.0.0.1) over http *or* https + parsed = urlparse(_url) + if parsed.scheme in ("http", "https") and parsed.hostname in ("localhost", "127.0.0.1"): + return True + # Join the subdomains into a pattern subdomain_pattern = "|".join(valid_subdomains) pattern = rf"^https://({subdomain_pattern})\.polyapi\.io$" return re.match(pattern, _url) is not None - -def is_valid_uuid(uuid_string, version=4): - try: - uuid_obj = uuid.UUID(uuid_string, version=version) - except ValueError: - return False - - return str(uuid_obj) == uuid_string - - def return_type_already_defined_in_args(return_type_name: str, args_def: str) -> bool: """ Checks if the return_type_name preceded optionally by 'class ' and followed by ' =' exists in args_def. diff --git a/polyapi/variables.py b/polyapi/variables.py index 95f7d2f..673a195 100644 --- a/polyapi/variables.py +++ b/polyapi/variables.py @@ -2,11 +2,11 @@ from typing import List from polyapi.schema import map_primitive_types -from polyapi.typedefs import PropertyType, VariableSpecDto +from polyapi.typedefs import PropertyType, VariableSpecDto, Secrecy from polyapi.utils import add_import_to_init, init_the_init -# GET is only included if the variable is not a secret +# GET is only included if the variable is not SECRET GET_TEMPLATE = """ @staticmethod def get() -> {variable_type}: @@ -76,9 +76,10 @@ def generate_variables(variables: List[VariableSpecDto]): def render_variable(variable: VariableSpecDto): variable_type = _get_variable_type(variable["variable"]["valueType"]) + # Only include get() method if secrecy is not SECRET get_method = ( "" - if variable["variable"]["secret"] + if variable["variable"]["secrecy"] == "SECRET" else GET_TEMPLATE.format( variable_id=variable["id"], variable_type=variable_type ) diff --git a/polyapi/webhook.py b/polyapi/webhook.py index 855c300..b27987d 100644 --- a/polyapi/webhook.py +++ b/polyapi/webhook.py @@ -65,6 +65,7 @@ async def handleEvent(data): "data": resp, "statusCode": polyCustom.get("responseStatusCode", 200), "contentType": polyCustom.get("responseContentType", None), + "headers": polyCustom.get("responseHeaders", {{}}), }}, }}, namespace="/events") diff --git a/pyproject.toml b/pyproject.toml index 9f7ab3b..96d4c69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=61.2", "wheel"] [project] name = "polyapi-python" -version = "0.3.4" +version = "0.3.7" 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) diff --git a/tests/test_variables.py b/tests/test_variables.py index beea44d..72eac81 100644 --- a/tests/test_variables.py +++ b/tests/test_variables.py @@ -12,7 +12,7 @@ }, "variable": { "environmentId": "123818231", - "secret": False, + "secrecy": "NONE", "valueType": { "kind": "primitive", "type": "string"