From b39564a4cfdb1cf0a29288dbf58c2980b890aa93 Mon Sep 17 00:00:00 2001 From: Aaron Goin Date: Tue, 15 Jul 2025 12:13:05 -0700 Subject: [PATCH 1/5] fixing client_id to be singe shared value per generation--matching typescript behavior --- polyapi/auth.py | 6 +++--- polyapi/generate.py | 10 ++++++++++ polyapi/variables.py | 5 +---- polyapi/webhook.py | 5 ++--- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/polyapi/auth.py b/polyapi/auth.py index 199cfef..3d6c325 100644 --- a/polyapi/auth.py +++ b/polyapi/auth.py @@ -1,5 +1,4 @@ from typing import List, Dict, Any, Tuple -import uuid from polyapi.typedefs import PropertySpecification from polyapi.utils import parse_arguments, get_type_and_def @@ -26,7 +25,8 @@ async def getToken(clientId: str, clientSecret: str, scopes: List[str], callback Function ID: {function_id} \""" - eventsClientId = "{client_id}" + from polyapi.poly.client_id import client_id + eventsClientId = client_id function_id = "{function_id}" options = options or {{}} @@ -165,7 +165,7 @@ def render_auth_function( func_str = "" if function_name == "getToken": - func_str = GET_TOKEN_TEMPLATE.format(function_id=function_id, description=function_description, client_id=uuid.uuid4().hex) + func_str = GET_TOKEN_TEMPLATE.format(function_id=function_id, description=function_description) elif function_name == "introspectToken": func_str = INTROSPECT_TOKEN_TEMPLATE.format(function_id=function_id, description=function_description) elif function_name == "refreshToken": diff --git a/polyapi/generate.py b/polyapi/generate.py index c0cd9e0..3a4a725 100644 --- a/polyapi/generate.py +++ b/polyapi/generate.py @@ -272,6 +272,14 @@ def __class_getitem__(cls, item): ''') +def _generate_client_id() -> None: + full_path = os.path.dirname(os.path.abspath(__file__)) + full_path = os.path.join(full_path, "poly", "client_id.py") + with open(full_path, "w") as f: + f.write(f'client_id = "{uuid.uuid4().hex}"') + + + def generate_from_cache() -> None: """ Generate using cached values after non-explicit call. @@ -297,6 +305,8 @@ def generate(contexts: Optional[List[str]] = None, names: Optional[List[str]] = limit_ids: List[str] = [] # useful for narrowing down generation to a single function to debug functions = parse_function_specs(specs, limit_ids=limit_ids) + _generate_client_id() + # Only process schemas if no_types is False if not no_types: schemas = get_schemas() diff --git a/polyapi/variables.py b/polyapi/variables.py index 76975cc..1fb915d 100644 --- a/polyapi/variables.py +++ b/polyapi/variables.py @@ -19,10 +19,7 @@ def get() -> {variable_type}: TEMPLATE = """ -import uuid - - -client_id = uuid.uuid4().hex +from polyapi.poly.client_id import client_id class {variable_name}:{get_method} diff --git a/polyapi/webhook.py b/polyapi/webhook.py index 2f11707..8d68186 100644 --- a/polyapi/webhook.py +++ b/polyapi/webhook.py @@ -1,7 +1,6 @@ import asyncio import socketio # type: ignore from socketio.exceptions import ConnectionError # type: ignore -import uuid import logging from typing import Any, Dict, List, Tuple @@ -33,6 +32,7 @@ async def {function_name}( Function ID: {function_id} \""" from polyapi.webhook import client, active_handlers + from polyapi.poly.client_id import client_id print("Starting webhook handler for {function_path}...") @@ -40,7 +40,7 @@ async def {function_name}( raise Exception("Client not initialized. Abort!") options = options or {{}} - eventsClientId = "{client_id}" + eventsClientId = client_id function_id = "{function_id}" api_key, base_url = get_api_key_and_url() @@ -131,7 +131,6 @@ def render_webhook_handle( 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, From f36a326d76e55802481e1c3253bbf6eba4e78c62 Mon Sep 17 00:00:00 2001 From: Aaron Goin Date: Tue, 15 Jul 2025 12:14:10 -0700 Subject: [PATCH 2/5] fixing some little type errors --- polyapi/deployables.py | 16 ++++++++-------- polyapi/sync.py | 13 +++++++++---- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/polyapi/deployables.py b/polyapi/deployables.py index 48dafb7..55dbbd7 100644 --- a/polyapi/deployables.py +++ b/polyapi/deployables.py @@ -65,20 +65,20 @@ class SyncDeployment(TypedDict, total=False): context: str name: str description: str - type: str + type: DeployableTypes fileRevision: str file: str types: DeployableFunctionTypes - typeSchemas: Dict[str, any] + typeSchemas: Dict[str, Any] dependencies: List[str] - config: Dict[str, any] + config: Dict[str, Any] instance: str - id: Optional[str] = None - deployed: Optional[str] = None + id: Optional[str] + deployed: Optional[str] DeployableTypeEntries: List[Tuple[DeployableTypeNames, DeployableTypes]] = [ - ("PolyServerFunction", "server-function"), - ("PolyClientFunction", "client-function"), + ("PolyServerFunction", "server-function"), # type: ignore + ("PolyClientFunction", "client-function"), # type: ignore ] DeployableTypeToName: Dict[DeployableTypeNames, DeployableTypes] = {name: type for name, type in DeployableTypeEntries} @@ -175,7 +175,7 @@ def get_git_revision(branch_or_tag: str = "HEAD") -> str: return check_output(["git", "rev-parse", "--short", branch_or_tag], text=True).strip() except CalledProcessError: # Return a random 7-character hash as a fallback - return "".join(format(ord(c), 'x') for c in os.urandom(4))[:7] + return "".join(format(ord(str(c)), 'x') for c in os.urandom(4))[:7] def get_cache_deployments_revision() -> str: """Retrieve the cache deployments revision from a file.""" diff --git a/polyapi/sync.py b/polyapi/sync.py index 9dcfac6..921defa 100644 --- a/polyapi/sync.py +++ b/polyapi/sync.py @@ -1,6 +1,7 @@ import os from datetime import datetime from typing import List, Dict +from typing_extensions import cast # type: ignore import requests from polyapi.utils import get_auth_headers @@ -30,12 +31,14 @@ def group_by(items: List[Dict], key: str) -> Dict[str, List[Dict]]: def remove_deployable_function(deployable: SyncDeployment) -> bool: api_key, _ = get_api_key_and_url() + if not api_key: + raise Error("Missing api key!") headers = get_auth_headers(api_key) url = f'{deployable["instance"]}/functions/{deployable["type"].replace("-function", "")}/{deployable["id"]}' response = requests.get(url, headers=headers) if response.status_code != 200: return False - requests.delete(url, headers) + requests.delete(url, headers=headers) return True def remove_deployable(deployable: SyncDeployment) -> bool: @@ -47,6 +50,8 @@ def remove_deployable(deployable: SyncDeployment) -> bool: def sync_function_and_get_id(deployable: SyncDeployment, code: str) -> str: api_key, _ = get_api_key_and_url() + if not api_key: + raise Error("Missing api key!") headers = get_auth_headers(api_key) url = f'{deployable["instance"]}/functions/{deployable["type"].replace("-function", "")}' payload = { @@ -129,15 +134,15 @@ def sync_deployables(dry_run: bool, instance: str | None = None): else: sync_deployment = { **deployable, "instance": instance } if git_revision == deployable['gitRevision']: - deployment = sync_deployable(sync_deployment) + deployment = sync_deployable(cast(SyncDeployment, sync_deployment)) if previous_deployment: previous_deployment.update(deployment) else: deployable['deployments'].insert(0, deployment) else: - found = remove_deployable(sync_deployment) + found = remove_deployable(cast(SyncDeployment, sync_deployment)) action = 'NOT FOUND' if not found else action - remove_index = all_deployables.index(deployable) + remove_index = all_deployables.index(cast(DeployableRecord, deployable)) to_remove.append(all_deployables.pop(remove_index)) print(f"{'Would sync' if dry_run else 'Synced'} {deployable['type'].replace('-', ' ')} {deployable['context']}.{deployable['name']}: {'TO BE ' if dry_run else ''}{action}") From e9b02ebb674f1c0c6488bd9135eb0cf2a3fe880b Mon Sep 17 00:00:00 2001 From: Aaron Goin Date: Tue, 15 Jul 2025 12:14:29 -0700 Subject: [PATCH 3/5] tabi in the house! --- .gitignore | 1 + polyapi/generate.py | 37 +-- polyapi/poly_tables.py | 435 +++++++++++++++++++++++++++++ polyapi/typedefs.py | 110 +++++++- polyapi/utils.py | 11 +- pyproject.toml | 2 +- tests/test_tabi.py | 612 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1184 insertions(+), 24 deletions(-) create mode 100644 polyapi/poly_tables.py create mode 100644 tests/test_tabi.py diff --git a/.gitignore b/.gitignore index 135534e..9ca9ad0 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,5 @@ function_add_test.py lib_test*.py polyapi/poly polyapi/vari +polyapi/tabi polyapi/schemas diff --git a/polyapi/generate.py b/polyapi/generate.py index 3a4a725..62ed700 100644 --- a/polyapi/generate.py +++ b/polyapi/generate.py @@ -1,6 +1,7 @@ import json import requests import os +import uuid import shutil import logging import tempfile @@ -11,11 +12,12 @@ from .poly_schemas import generate_schemas from .webhook import render_webhook_handle -from .typedefs import PropertySpecification, SchemaSpecDto, SpecificationDto, VariableSpecDto +from .typedefs import PropertySpecification, SchemaSpecDto, SpecificationDto, VariableSpecDto, TableSpecDto from .api import render_api_function 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 .poly_tables import generate_tables from .config import get_api_key_and_url, get_direct_execute_config, get_cached_generate_args SUPPORTED_FUNCTION_TYPES = { @@ -26,7 +28,7 @@ "webhookHandle", } -SUPPORTED_TYPES = SUPPORTED_FUNCTION_TYPES | {"serverVariable", "schema", "snippet"} +SUPPORTED_TYPES = SUPPORTED_FUNCTION_TYPES | {"serverVariable", "schema", "snippet", "table"} X_POLY_REF_WARNING = '''""" @@ -190,16 +192,18 @@ def read_cached_specs() -> List[SpecificationDto]: return json.loads(f.read()) -def get_variables() -> List[VariableSpecDto]: - specs = read_cached_specs() +def get_variables(specs: List[SpecificationDto]) -> List[VariableSpecDto]: return [cast(VariableSpecDto, spec) for spec in specs if spec["type"] == "serverVariable"] -def get_schemas() -> List[SchemaSpecDto]: - specs = read_cached_specs() +def get_schemas(specs: List[SpecificationDto]) -> List[SchemaSpecDto]: return [cast(SchemaSpecDto, spec) for spec in specs if spec["type"] == "schema"] +def get_tables(specs: List[SpecificationDto]) -> List[TableSpecDto]: + return [cast(TableSpecDto, spec) for spec in specs if spec["type"] == "table"] + + def remove_old_library(): currdir = os.path.dirname(os.path.abspath(__file__)) path = os.path.join(currdir, "poly") @@ -214,6 +218,10 @@ def remove_old_library(): if os.path.exists(path): shutil.rmtree(path) + path = os.path.join(currdir, "tabi") + if os.path.exists(path): + 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""" @@ -309,7 +317,7 @@ def generate(contexts: Optional[List[str]] = None, names: Optional[List[str]] = # Only process schemas if no_types is False if not no_types: - schemas = get_schemas() + schemas = get_schemas(specs) schema_index = build_schema_index(schemas) if schemas: schema_limit_ids: List[str] = [] # useful for narrowing down generation to a single function to debug @@ -333,7 +341,11 @@ def generate(contexts: Optional[List[str]] = None, names: Optional[List[str]] = ) exit() - variables = get_variables() + tables = get_tables(specs) + if tables: + generate_tables(tables) + + variables = get_variables(specs) if variables: generate_variables(variables) @@ -345,14 +357,7 @@ def generate(contexts: Optional[List[str]] = None, names: Optional[List[str]] = def clear() -> None: - base = os.path.dirname(os.path.abspath(__file__)) - poly_path = os.path.join(base, "poly") - if os.path.exists(poly_path): - shutil.rmtree(poly_path) - - vari_path = os.path.join(base, "vari") - if os.path.exists(vari_path): - shutil.rmtree(vari_path) + remove_old_library() print("Cleared!") diff --git a/polyapi/poly_tables.py b/polyapi/poly_tables.py new file mode 100644 index 0000000..235bc8f --- /dev/null +++ b/polyapi/poly_tables.py @@ -0,0 +1,435 @@ +import os +import requests +from typing_extensions import NotRequired, TypedDict +from typing import List, Union, Type, Dict, Any, Literal, Tuple, Optional, get_args, get_origin +from polyapi.utils import add_import_to_init, init_the_init +from polyapi.typedefs import TableSpecDto +from polyapi.constants import JSONSCHEMA_TO_PYTHON_TYPE_MAP + + +def execute_query(table_id, method, query): + from polyapi import polyCustom + from polyapi.poly.client_id import client_id + try: + url = f"/tables/{table_id}/{method}?clientId={client_id}" + headers = {{ + 'x-poly-execution-id': polyCustom.get('executionId') + }} + response = requests.post(url, json=query, headers=headers) + response.raise_for_status() + return response.json() + except Exception as e: + return scrub_keys(e) + + +def first_result(rsp): + if isinstance(rsp, dict) and isinstance(rsp.get('results'), list): + return rsp['results'][0] if rsp['results'] else None + return rsp + + +_key_transform_map = { + "not_": "not", + "in": "in", + "starts_with": "startsWith", + "ends_with": "startsWith", + "not_in": "notIn", +} + + +def _transform_keys(obj: Any) -> Any: + if isinstance(obj, dict): + return { + _key_transform_map.get(k, k): _transform_keys(v) + for k, v in obj.items() + } + + elif isinstance(obj, list): + return [_transform_keys(v) for v in obj] + + else: + return obj + + +def transform_query(query: dict) -> dict: + if query["where"] or query["order_by"]: + return { + **query, + "where": _transform_keys(query["where"]) if query["where"] else None, + "orderBy": query["order_by"] if query["order_by"] else None + } + + return query + + +TABI_TABLE_TEMPLATE = """ +{table_name}Columns = Literal[{table_columns}] + + + +{table_row_classes} + + + +{table_row_subset_class} + + + +{table_where_class} + + + +class {table_name}SelectManyQuery(TypedDict): + where: NotRequired[{table_name}WhereFilter] + order_by: NotRequired[Dict[{table_name}Columns, SortOrder]] + limit: NotRequired[int] + offset: NotRequired[int] + + + +class {table_name}SelectOneQuery(TypedDict): + where: NotRequired[{table_name}WhereFilter] + order_by: NotRequired[Dict[{table_name}Columns, SortOrder]] + + + +class {table_name}InsertOneQuery(TypedDict): + data: {table_name}Subset + + + +class {table_name}InsertManyQuery(TypedDict): + data: List[{table_name}Subset] + + + +class {table_name}UpdateManyQuery(TypedDict): + where: NotRequired[{table_name}WhereFilter] + data: {table_name}Subset + + + +class {table_name}DeleteQuery(TypedDict): + where: NotRequired[{table_name}WhereFilter] + + + +class {table_name}QueryResults(TypedDict): + results: List[{table_name}Row] + pagination: None # Pagination not yet supported + + + +class {table_name}CountQuery(TypedDict): + where: NotRequired[{table_name}WhereFilter] + + + +class {table_name}: + + @overload + @staticmethod + def count(query: {table_name}CountQuery) -> PolyCountResult: ... + @overload + @staticmethod + def count(*, where: Optional[{table_name}WhereFilter]) -> PolyCountResult: ... + + @staticmethod + def count(*args, **kwargs) -> PolyCountResult: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + return execute_query("{table_id}", "count", transform_query(query)) + + @overload + @staticmethod + def select_many(query: {table_name}SelectManyQuery) -> {table_name}QueryResults: ... + @overload + @staticmethod + def select_many(*, where: Optional[{table_name}WhereFilter], order_by: Optional[Dict[{table_name}Columns, SortOrder]], limit: Optional[int], offset: Optional[int]) -> {table_name}QueryResults: ... + + @staticmethod + def select_many(*args, **kwargs) -> {table_name}QueryResults: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + if query.get('limit') is None: + query['limit'] = 1000 + if query['limit'] > 1000: + raise ValueError("Cannot select more than 1000 rows at a time.") + return execute_query("{table_id}", "select", transform_query(query)) + + @overload + @staticmethod + def select_one(query: {table_name}SelectOneQuery) -> {table_name}Row: ... + @overload + @staticmethod + def select_one(*, where: Optional[{table_name}WhereFilter], order_by: Optional[Dict[{table_name}Columns, SortOrder]]) -> {table_name}Row: ... + + @staticmethod + def select_one(*args, **kwargs) -> {table_name}Row: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + query['limit'] = 1 + return first_result(execute_query("{table_id}", "select", transform_query(query))) + + @overload + @staticmethod + def insert_many(query: {table_name}InsertManyQuery) -> {table_name}QueryResults: ... + @overload + @staticmethod + def insert_many(*, data: List[{table_name}Subset]) -> {table_name}QueryResults: ... + + @staticmethod + def insert_many(*args, **kwargs) -> {table_name}QueryResults: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + if len(query['data']) > 1000: + raise ValueError("Cannot insert more than 1000 rows at a time.") + return execute_query("{table_id}", "insert", query) + + @overload + @staticmethod + def insert_one(query: {table_name}InsertOneQuery) -> {table_name}Row: ... + @overload + @staticmethod + def insert_one(*, data: {table_name}Subset) -> {table_name}Row: ... + + @staticmethod + def insert_one(*args, **kwargs) -> {table_name}Row: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + return first_result(execute_query("{table_id}", "insert", {{ 'data': [query['data']] }})) + + @overload + @staticmethod + def upsert_many(query: {table_name}InsertManyQuery) -> {table_name}QueryResults: ... + @overload + @staticmethod + def upsert_many(*, data: List[{table_name}Subset]) -> {table_name}QueryResults: ... + + @staticmethod + def upsert_many(*args, **kwargs) -> {table_name}QueryResults: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + if len(data) > 1000: + raise ValueError("Cannot upsert more than 1000 rows at a time.") + return execute_query("{table_id}", "upsert", query) + + @overload + @staticmethod + def upsert_one(query: {table_name}InsertOneQuery) -> {table_name}Row: ... + @overload + @staticmethod + def upsert_one(*, data: {table_name}Subset) -> {table_name}Row: ... + + @staticmethod + def upsert_one(*args, **kwargs) -> {table_name}Row: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + return first_result(execute_query("{table_id}", "upsert", {{ 'data': [query['data']] }})) + + @overload + @staticmethod + def update_many(query: {table_name}UpdateManyQuery) -> {table_name}QueryResults: ... + @overload + @staticmethod + def update_many(*, where: Optional[{table_name}WhereFilter], data: {table_name}Subset) -> {table_name}QueryResults: ... + + @staticmethod + def update_many(*args, **kwargs) -> {table_name}QueryResults: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + return execute_query("{table_id}", 'update', transform_query(query)) + + @overload + @staticmethod + def delete_many(query: {table_name}DeleteQuery) -> PolyDeleteResults: ... + @overload + @staticmethod + def delete_many(*, where: Optional[{table_name}WhereFilter]) -> PolyDeleteResults: ... + + @staticmethod + def delete_many(*args, **kwargs) -> PolyDeleteResults: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + return execute_query("{table_id}", "delete", query) +""" + + +def _get_column_type_str(name: str, schema: Dict[str, Any], is_required: bool) -> str: + result = "" + + col_type = schema.get("type", "object") + if isinstance(col_type, list): + subtypes = [_get_column_type_str(name, { **schema, "type": t }, is_required) for t in col_type] + result = f"Union[{", ".join(subtypes)}]" + elif col_type == "array": + if isinstance(schema["items"], list): + subtypes = [_get_column_type_str(f"{name}{i}", s, True) for i, s in enumerate(schema["items"])] + result = f"Tuple[{", ".join(subtypes)}]" + elif isinstance(schema["items"], dict): + result = f"List[{_get_column_type_str(name, schema["items"], True)}]" + else: + result = "List[Any]" + elif col_type == "object": + if isinstance(schema.get("patternProperties"), dict): + # TODO: Handle multiple pattern properties + result = f"Dict[str, {_get_column_type_str(f"{name}_", schema["patternProperties"], True)}]" + elif isinstance(schema.get("properties"), dict) and len(schema["properties"].values()) > 0: + # TODO: Handle x-poly-refs + result = f'"{name}"' + else: + result = "Dict[str, Any]" + else: + result = JSONSCHEMA_TO_PYTHON_TYPE_MAP.get(schema["type"], "") + + if result: + return result if is_required else f"Optional[{result}]" + + return "Any" + + +def _render_table_row_classes(table_name: str, schema: Dict[str, Any]) -> str: + from polyapi.schema import wrapped_generate_schema_types + + output = wrapped_generate_schema_types(schema, f"{table_name}Row", "Dict") + + return output[1].split("\n", 1)[1].strip() + + +def _render_table_subset_class(table_name: str, columns: List[Tuple[str, Dict[str, Any]]], required: List[str]) -> str: + # Generate class which can match any subset of a table row + lines = [f"class {table_name}Subset(TypedDict):"] + + for name, schema in columns: + type_str = _get_column_type_str(f"_{table_name}Row{name}", schema, name in required) + lines.append(f" {name}: NotRequired[{type_str}]") + + return "\n".join(lines) + + +def _render_table_where_class(table_name: str, columns: List[Tuple[str, Dict[str, Any]]], required: List[str]) -> str: + # Generate class for the 'where' part of the query + lines = [f"class {table_name}WhereFilter(TypedDict):"] + + for name, schema in columns: + ftype_str = "" + type_str = _get_column_type_str(f"_{table_name}Row{name}", schema, True) # force required to avoid wrapping type in Optional[] + is_required = name in required + if type_str == "bool": + ftype_str = "BooleanFilter" if is_required else "NullableBooleanFilter" + elif type_str == "str": + ftype_str = "StringFilter" if is_required else "NullableStringFilter" + elif type_str in ["int", "float"]: + ftype_str = "NumberFilter" if is_required else "NullableNumberFilter" + elif is_required == False: + type_str = "None" + ftype_str = "NullableObjectFilter" + + if ftype_str: + lines.append(f" {name}: NotRequired[Union[{type_str}, {ftype_str}]]") + + lines.append(f' AND: NotRequired[Union["{table_name}WhereFilter", List["{table_name}WhereFilter"]]]') + lines.append(f' OR: NotRequired[List["{table_name}WhereFilter"]]') + lines.append(f' NOT: NotRequired[Union["{table_name}WhereFilter", List["{table_name}WhereFilter"]]]') + + return "\n".join(lines) + + +def _render_table(table: TableSpecDto) -> str: + columns = list(table["schema"]["properties"].items()) + required_colunms = table["schema"].get("required", []) + + table_columns = ",".join([ f'"{k}"' for k,_ in columns]) + table_row_classes = _render_table_row_classes(table["name"], table["schema"]) + table_row_subset_class = _render_table_subset_class(table["name"], columns, required_colunms) + table_where_class = _render_table_where_class(table["name"], columns, required_colunms) + + return TABI_TABLE_TEMPLATE.format( + table_name=table["name"], + table_id=table["id"], + table_columns=table_columns, + table_row_classes=table_row_classes, + table_row_subset_class=table_row_subset_class, + table_where_class=table_where_class, + ) + + +def generate_tables(tables: List[TableSpecDto]): + for table in tables: + _create_table(table) + + +def _create_table(table: TableSpecDto) -> None: + folders = ["tabi"] + if table["context"]: + folders += table["context"].split(".") + + # build up the full_path by adding all the folders + base_path = os.path.join(os.path.dirname(os.path.abspath(__file__))) + full_path = base_path + + 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, "") + + init_path = os.path.join(full_path, "__init__.py") + + imports = "\n".join([ + "from typing_extensions import NotRequired, TypedDict", + "from typing import Union, List, Dict, Any, Literal, Optional, Required, overload", + "from polyapi.poly_tables import execute_query, first_result, transform_query", + "from polyapi.typedefs import Table, PolyCountResult, PolyDeleteResults, SortOrder, StringFilter, NullableStringFilter, NumberFilter, NullableNumberFilter, BooleanFilter, NullableBooleanFilter, NullableObjectFilter", + ]) + table_contents = _render_table(table) + + file_contents = "" + if os.path.exists(init_path): + with open(init_path, "r") as f: + file_contents = f.read() + + with open(init_path, "w") as f: + if not file_contents.startswith(imports): + f.write(imports + "\n\n\n") + if file_contents: + f.write(file_contents + "\n\n\n") + f.write(table_contents) diff --git a/polyapi/typedefs.py b/polyapi/typedefs.py index b887103..5f45a78 100644 --- a/polyapi/typedefs.py +++ b/polyapi/typedefs.py @@ -11,7 +11,7 @@ class PropertySpecification(TypedDict): class PropertyType(TypedDict): - kind: Literal['void', 'primitive', 'array', 'object', 'function', 'plain'] + kind: Literal['void', 'primitive', 'array', 'object', 'function', 'plain', 'any'] spec: NotRequired[Dict] name: NotRequired[str] type: NotRequired[str] @@ -35,7 +35,7 @@ class SpecificationDto(TypedDict): description: str # function is none (or function key not present) if this is actually VariableSpecDto function: NotRequired[FunctionSpecification | None] - type: Literal['apiFunction', 'customFunction', 'serverFunction', 'authFunction', 'webhookHandle', 'serverVariable'] + type: Literal['apiFunction', 'customFunction', 'serverFunction', 'authFunction', 'webhookHandle', 'serverVariable', 'table'] code: NotRequired[str] language: str @@ -72,6 +72,16 @@ class SchemaSpecDto(TypedDict): # TODO add more +class TableSpecDto(TypedDict): + id: str + context: str + name: str + contextName: str + type: Literal['table'] + schema: Dict[Any, Any] + unresolvedPolySchemaRefs: List + + Visibility = Union[Literal['PUBLIC'], Literal['TENANT'], Literal['ENVIRONMENT']] @@ -91,3 +101,99 @@ class PolyServerFunction(PolyDeployable): class PolyClientFunction(PolyDeployable): logs_enabled: NotRequired[bool] visibility: NotRequired[Visibility] + + +class Table(TypedDict): + id: str + createdAt: str + updatedAt: str + + +class PolyCountResult(TypedDict): + count: int + + +class PolyDeleteResults(TypedDict): + deleted: int + + + +QueryMode = Literal["default", "insensitive"] + + +SortOrder = Literal["asc", "desc"] + +# Using functional form because of use of reserved keywords +StringFilter = TypedDict("StringFilter", { + "equals": NotRequired[str], + "in": NotRequired[List[str]], + "not_in": NotRequired[List[str]], + "lt": NotRequired[str], + "lte": NotRequired[str], + "gt": NotRequired[str], + "gte": NotRequired[str], + "contains": NotRequired[str], + "starts_with": NotRequired[str], + "ends_with": NotRequired[str], + "mode": NotRequired[QueryMode], + "not": NotRequired[Union[str, "StringFilter"]], +}) + +# Using functional form because of use of reserved keywords +NullableStringFilter = TypedDict("NullableStringFilter", { + "equals": NotRequired[Union[str, None]], + "in": NotRequired[List[str]], + "not_in": NotRequired[List[str]], + "lt": NotRequired[str], + "lte": NotRequired[str], + "gt": NotRequired[str], + "gte": NotRequired[str], + "contains": NotRequired[str], + "starts_with": NotRequired[str], + "ends_with": NotRequired[str], + "mode": NotRequired[QueryMode], + "not": NotRequired[Union[str, None, "NullableStringFilter"]], +}) + +# Using functional form because of use of reserved keywords +NumberFilter = TypedDict("NumberFilter", { + "equals": NotRequired[Union[int, float]], + "in": NotRequired[List[Union[int, float]]], + "not_in": NotRequired[List[Union[int, float]]], + "lt": NotRequired[Union[int, float]], + "lte": NotRequired[Union[int, float]], + "gt": NotRequired[Union[int, float]], + "gte": NotRequired[Union[int, float]], + "not": NotRequired[Union[int, float, "NumberFilter"]], +}) + +# Using functional form because of use of reserved keywords +NullableNumberFilter = TypedDict("NullableNumberFilter", { + "equals": NotRequired[Union[int, float, None]], + "in": NotRequired[List[Union[int, float]]], + "not_in": NotRequired[List[Union[int, float]]], + "lt": NotRequired[Union[int, float]], + "lte": NotRequired[Union[int, float]], + "gt": NotRequired[Union[int, float]], + "gte": NotRequired[Union[int, float]], + "not": NotRequired[Union[int, float, None, "NullableNumberFilter"]], +}) + + +# Using functional form because of use of reserved keywords +BooleanFilter = TypedDict("BooleanFilter", { + "equals": NotRequired[bool], + "not": NotRequired[Union[bool, "BooleanFilter"]], +}) + +# Using functional form because of use of reserved keywords +NullableBooleanFilter = TypedDict("NullableBooleanFilter", { + "equals": NotRequired[Union[bool, None]], + "not": NotRequired[Union[bool, None, "NullableBooleanFilter"]], +}) + +# Using functional form because of use of reserved keywords +NullableObjectFilter = TypedDict("NullableObjectFilter", { + "equals": NotRequired[None], + "not": NotRequired[Union[None, "NullableObjectFilter"]], +}) diff --git a/polyapi/utils.py b/polyapi/utils.py index 3b61f5e..4c803c0 100644 --- a/polyapi/utils.py +++ b/polyapi/utils.py @@ -1,9 +1,8 @@ import keyword import re import os -import uuid from urllib.parse import urlparse -from typing import Tuple, List +from typing import Tuple, List, Optional from colorama import Fore, Style from polyapi.constants import BASIC_PYTHON_TYPES from polyapi.typedefs import PropertySpecification, PropertyType @@ -20,15 +19,17 @@ 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: +def init_the_init(full_path: str, code_imports: Optional[str] = None) -> None: init_path = os.path.join(full_path, "__init__.py") if not os.path.exists(init_path): - code_imports = code_imports or CODE_IMPORTS + if code_imports is None: + code_imports = CODE_IMPORTS with open(init_path, "w") as f: f.write(code_imports) -def add_import_to_init(full_path: str, next: str, code_imports="") -> None: +def add_import_to_init(full_path: str, next: str, code_imports: Optional[str] = None) -> None: + init_the_init(full_path, code_imports=code_imports) init_the_init(full_path, code_imports=code_imports) init_path = os.path.join(full_path, "__init__.py") diff --git a/pyproject.toml b/pyproject.toml index cf5b8ca..fb824c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=61.2", "wheel"] [project] name = "polyapi-python" -version = "0.3.9.dev6" +version = "0.3.9.dev7" 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_tabi.py b/tests/test_tabi.py new file mode 100644 index 0000000..86c34bb --- /dev/null +++ b/tests/test_tabi.py @@ -0,0 +1,612 @@ +import unittest +from polyapi.poly_tables import _render_table + + +TABLE_SPEC_SIMPLE = { + "type": "table", + "id": "123456789", + "name": "MyTable", + "context": "some.context.here", + "contextName": "some.context.here.MyTable", + "schema": { + "$schema": "http://json-schema.org/draft-06/schema#", + "type": "object", + "properties": { + "id": { "type": "string" }, + "createdAt": { "type": "string" }, + "updatedAt": { "type": "string" }, + "name": { "type": "string" }, + "age": { "type": "integer" }, + "active": { "type": "boolean" }, + "optional": { "type": "object" } + }, + "required": [ + "id", + "createdAt", + "updatedAt", + "name", + "age", + "active" + ], + "additionalProperties": False, + } +} + +EXPECTED_SIMPLE = ''' +MyTableColumns = Literal["id","createdAt","updatedAt","name","age","active","optional"] + + + +class MyTableRow(TypedDict, total=False): + id: Required[str] + """ Required property """ + + createdAt: Required[str] + """ Required property """ + + updatedAt: Required[str] + """ Required property """ + + name: Required[str] + """ Required property """ + + age: Required[int] + """ Required property """ + + active: Required[bool] + """ Required property """ + + optional: Dict[str, Any] + + + +class MyTableSubset(TypedDict): + id: NotRequired[str] + createdAt: NotRequired[str] + updatedAt: NotRequired[str] + name: NotRequired[str] + age: NotRequired[int] + active: NotRequired[bool] + optional: NotRequired[Optional[Dict[str, Any]]] + + + +class MyTableWhereFilter(TypedDict): + id: NotRequired[Union[str, StringFilter]] + createdAt: NotRequired[Union[str, StringFilter]] + updatedAt: NotRequired[Union[str, StringFilter]] + name: NotRequired[Union[str, StringFilter]] + age: NotRequired[Union[int, NumberFilter]] + active: NotRequired[Union[bool, BooleanFilter]] + optional: NotRequired[Union[None, NullableObjectFilter]] + AND: NotRequired[Union["MyTableWhereFilter", List["MyTableWhereFilter"]]] + OR: NotRequired[List["MyTableWhereFilter"]] + NOT: NotRequired[Union["MyTableWhereFilter", List["MyTableWhereFilter"]]] + + + +class MyTableSelectManyQuery(TypedDict): + where: NotRequired[MyTableWhereFilter] + order_by: NotRequired[Dict[MyTableColumns, SortOrder]] + limit: NotRequired[int] + offset: NotRequired[int] + + + +class MyTableSelectOneQuery(TypedDict): + where: NotRequired[MyTableWhereFilter] + order_by: NotRequired[Dict[MyTableColumns, SortOrder]] + + + +class MyTableInsertOneQuery(TypedDict): + data: MyTableSubset + + + +class MyTableInsertManyQuery(TypedDict): + data: List[MyTableSubset] + + + +class MyTableUpdateManyQuery(TypedDict): + where: NotRequired[MyTableWhereFilter] + data: MyTableSubset + + + +class MyTableDeleteQuery(TypedDict): + where: NotRequired[MyTableWhereFilter] + + + +class MyTableQueryResults(TypedDict): + results: List[MyTableRow] + pagination: None # Pagination not yet supported + + + +class MyTableCountQuery(TypedDict): + where: NotRequired[MyTableWhereFilter] + + + +class MyTable: + + @overload + @staticmethod + def count(query: MyTableCountQuery) -> PolyCountResult: ... + @overload + @staticmethod + def count(*, where: Optional[MyTableWhereFilter]) -> PolyCountResult: ... + + @staticmethod + def count(*args, **kwargs) -> PolyCountResult: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + return execute_query(123456789, 'count', transform_query(query)) + + @overload + @staticmethod + def select_many(query: MyTableSelectManyQuery) -> MyTableQueryResults: ... + @overload + @staticmethod + def select_many(*, where: Optional[MyTableWhereFilter], order_by: Optional[Dict[MyTableColumns, SortOrder]], limit: Optional[int], offset: Optional[int]) -> MyTableQueryResults: ... + + @staticmethod + def select_many(*args, **kwargs) -> MyTableQueryResults: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + if query.get('limit') is None: + query['limit'] = 1000 + if query['limit'] > 1000: + raise ValueError("Cannot select more than 1000 rows at a time.") + return execute_query(123456789, 'select', transform_query(query)) + + @overload + @staticmethod + def select_one(query: MyTableSelectOneQuery) -> MyTableRow: ... + @overload + @staticmethod + def select_one(*, where: Optional[MyTableWhereFilter], order_by: Optional[Dict[MyTableColumns, SortOrder]]) -> MyTableRow: ... + + @staticmethod + def select_one(*args, **kwargs) -> MyTableRow: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + query['limit'] = 1 + return first_result(execute_query(123456789, 'select', transform_query(query))) + + @overload + @staticmethod + def insert_many(query: MyTableInsertManyQuery) -> MyTableQueryResults: ... + @overload + @staticmethod + def insert_many(*, data: List[MyTableSubset]) -> MyTableQueryResults: ... + + @staticmethod + def insert_many(*args, **kwargs) -> MyTableQueryResults: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + if len(query['data']) > 1000: + raise ValueError("Cannot insert more than 1000 rows at a time.") + return execute_query(123456789, 'insert', query) + + @overload + @staticmethod + def insert_one(query: MyTableInsertOneQuery) -> MyTableRow: ... + @overload + @staticmethod + def insert_one(*, data: MyTableSubset) -> MyTableRow: ... + + @staticmethod + def insert_one(*args, **kwargs) -> MyTableRow: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + return first_result(execute_query(123456789, 'insert', { 'data': [query['data']] })) + + @overload + @staticmethod + def upsert_many(query: MyTableInsertManyQuery) -> MyTableQueryResults: ... + @overload + @staticmethod + def upsert_many(*, data: List[MyTableSubset]) -> MyTableQueryResults: ... + + @staticmethod + def upsert_many(*args, **kwargs) -> MyTableQueryResults: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + if len(data) > 1000: + raise ValueError("Cannot upsert more than 1000 rows at a time.") + return execute_query(123456789, 'upsert', query) + + @overload + @staticmethod + def upsert_one(query: MyTableInsertOneQuery) -> MyTableRow: ... + @overload + @staticmethod + def upsert_one(*, data: MyTableSubset) -> MyTableRow: ... + + @staticmethod + def upsert_one(*args, **kwargs) -> MyTableRow: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + return first_result(execute_query(123456789, 'upsert', { 'data': [query['data']] })) + + @overload + @staticmethod + def update_many(query: MyTableUpdateManyQuery) -> MyTableQueryResults: ... + @overload + @staticmethod + def update_many(*, where: Optional[MyTableWhereFilter], data: MyTableSubset) -> MyTableQueryResults: ... + + @staticmethod + def update_many(*args, **kwargs) -> MyTableQueryResults: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + return execute_query(123456789, 'update', transform_query(query)) + + @overload + @staticmethod + def delete_many(query: MyTableDeleteQuery) -> PolyDeleteResults: ... + @overload + @staticmethod + def delete_many(*, where: Optional[MyTableWhereFilter]) -> PolyDeleteResults: ... + + @staticmethod + def delete_many(*args, **kwargs) -> PolyDeleteResults: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + return execute_query(123456789, 'delete', query) +''' + +TABLE_SPEC_COMPLEX = { + "type": "table", + "id": "123456789", + "name": "MyTable", + "context": "some.context.here", + "contextName": "some.context.here.MyTable", + "schema": { + "$schema": "http://json-schema.org/draft-06/schema#", + "type": "object", + "properties": { + "id": { "type": "string" }, + "createdAt": { "type": "string" }, + "updatedAt": { "type": "string" }, + "data": { + "type": "object", + "properties": { + "foo": { "type": "string" }, + "nested": { + "type": "array", + "items": { + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": ["name"] + } + }, + "other": { "x-poly-ref": { "path": "some.other.Schema" }} + } + } + }, + "required": [ + "id", + "createdAt", + "updatedAt", + "data" + ], + "additionalProperties": False, + } +} + +EXPECTED_COMPLEX = ''' +MyTableColumns = Literal["id","createdAt","updatedAt","data"] + + + +class MyTableRow(TypedDict, total=False): + id: Required[str] + """ Required property """ + + createdAt: Required[str] + """ Required property """ + + updatedAt: Required[str] + """ Required property """ + + data: Required["_MyTableRowdata"] + """ Required property """ + + + +class _MyTableRowdata(TypedDict, total=False): + foo: str + nested: List["_MyTableRowdatanesteditem"] + other: Union[str, Union[int, float], Dict[str, Any], List[Any], bool, None] + """ + x-poly-ref: + path: some.other.Schema + """ + + + +class _MyTableRowdatanesteditem(TypedDict, total=False): + name: Required[str] + """ Required property """ + + + +class MyTableSubset(TypedDict): + id: NotRequired[str] + createdAt: NotRequired[str] + updatedAt: NotRequired[str] + data: NotRequired["_MyTableRowdata"] + + + +class MyTableWhereFilter(TypedDict): + id: NotRequired[Union[str, StringFilter]] + createdAt: NotRequired[Union[str, StringFilter]] + updatedAt: NotRequired[Union[str, StringFilter]] + AND: NotRequired[Union["MyTableWhereFilter", List["MyTableWhereFilter"]]] + OR: NotRequired[List["MyTableWhereFilter"]] + NOT: NotRequired[Union["MyTableWhereFilter", List["MyTableWhereFilter"]]] + + + +class MyTableSelectManyQuery(TypedDict): + where: NotRequired[MyTableWhereFilter] + order_by: NotRequired[Dict[MyTableColumns, SortOrder]] + limit: NotRequired[int] + offset: NotRequired[int] + + + +class MyTableSelectOneQuery(TypedDict): + where: NotRequired[MyTableWhereFilter] + order_by: NotRequired[Dict[MyTableColumns, SortOrder]] + + + +class MyTableInsertOneQuery(TypedDict): + data: MyTableSubset + + + +class MyTableInsertManyQuery(TypedDict): + data: List[MyTableSubset] + + + +class MyTableUpdateManyQuery(TypedDict): + where: NotRequired[MyTableWhereFilter] + data: MyTableSubset + + + +class MyTableDeleteQuery(TypedDict): + where: NotRequired[MyTableWhereFilter] + + + +class MyTableQueryResults(TypedDict): + results: List[MyTableRow] + pagination: None # Pagination not yet supported + + + +class MyTableCountQuery(TypedDict): + where: NotRequired[MyTableWhereFilter] + + + +class MyTable: + + @overload + @staticmethod + def count(query: MyTableCountQuery) -> PolyCountResult: ... + @overload + @staticmethod + def count(*, where: Optional[MyTableWhereFilter]) -> PolyCountResult: ... + + @staticmethod + def count(*args, **kwargs) -> PolyCountResult: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + return execute_query(123456789, 'count', transform_query(query)) + + @overload + @staticmethod + def select_many(query: MyTableSelectManyQuery) -> MyTableQueryResults: ... + @overload + @staticmethod + def select_many(*, where: Optional[MyTableWhereFilter], order_by: Optional[Dict[MyTableColumns, SortOrder]], limit: Optional[int], offset: Optional[int]) -> MyTableQueryResults: ... + + @staticmethod + def select_many(*args, **kwargs) -> MyTableQueryResults: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + if query.get('limit') is None: + query['limit'] = 1000 + if query['limit'] > 1000: + raise ValueError("Cannot select more than 1000 rows at a time.") + return execute_query(123456789, 'select', transform_query(query)) + + @overload + @staticmethod + def select_one(query: MyTableSelectOneQuery) -> MyTableRow: ... + @overload + @staticmethod + def select_one(*, where: Optional[MyTableWhereFilter], order_by: Optional[Dict[MyTableColumns, SortOrder]]) -> MyTableRow: ... + + @staticmethod + def select_one(*args, **kwargs) -> MyTableRow: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + query['limit'] = 1 + return first_result(execute_query(123456789, 'select', transform_query(query))) + + @overload + @staticmethod + def insert_many(query: MyTableInsertManyQuery) -> MyTableQueryResults: ... + @overload + @staticmethod + def insert_many(*, data: List[MyTableSubset]) -> MyTableQueryResults: ... + + @staticmethod + def insert_many(*args, **kwargs) -> MyTableQueryResults: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + if len(query['data']) > 1000: + raise ValueError("Cannot insert more than 1000 rows at a time.") + return execute_query(123456789, 'insert', query) + + @overload + @staticmethod + def insert_one(query: MyTableInsertOneQuery) -> MyTableRow: ... + @overload + @staticmethod + def insert_one(*, data: MyTableSubset) -> MyTableRow: ... + + @staticmethod + def insert_one(*args, **kwargs) -> MyTableRow: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + return first_result(execute_query(123456789, 'insert', { 'data': [query['data']] })) + + @overload + @staticmethod + def upsert_many(query: MyTableInsertManyQuery) -> MyTableQueryResults: ... + @overload + @staticmethod + def upsert_many(*, data: List[MyTableSubset]) -> MyTableQueryResults: ... + + @staticmethod + def upsert_many(*args, **kwargs) -> MyTableQueryResults: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + if len(data) > 1000: + raise ValueError("Cannot upsert more than 1000 rows at a time.") + return execute_query(123456789, 'upsert', query) + + @overload + @staticmethod + def upsert_one(query: MyTableInsertOneQuery) -> MyTableRow: ... + @overload + @staticmethod + def upsert_one(*, data: MyTableSubset) -> MyTableRow: ... + + @staticmethod + def upsert_one(*args, **kwargs) -> MyTableRow: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + return first_result(execute_query(123456789, 'upsert', { 'data': [query['data']] })) + + @overload + @staticmethod + def update_many(query: MyTableUpdateManyQuery) -> MyTableQueryResults: ... + @overload + @staticmethod + def update_many(*, where: Optional[MyTableWhereFilter], data: MyTableSubset) -> MyTableQueryResults: ... + + @staticmethod + def update_many(*args, **kwargs) -> MyTableQueryResults: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + return execute_query(123456789, 'update', transform_query(query)) + + @overload + @staticmethod + def delete_many(query: MyTableDeleteQuery) -> PolyDeleteResults: ... + @overload + @staticmethod + def delete_many(*, where: Optional[MyTableWhereFilter]) -> PolyDeleteResults: ... + + @staticmethod + def delete_many(*args, **kwargs) -> PolyDeleteResults: + if args: + if len(args) != 1 or not isinstance(args[0], dict): + raise TypeError("Expected query as a single argument or as kwargs") + query = args[0] + else: + query = kwargs + return execute_query(123456789, 'delete', query) +''' + +class T(unittest.TestCase): + def test_render_simple(self): + self.maxDiff = None + output = _render_table(TABLE_SPEC_SIMPLE) + self.assertEqual(output, EXPECTED_SIMPLE) + + def test_render_complex(self): + output = _render_table(TABLE_SPEC_COMPLEX) + self.assertEqual(output, EXPECTED_COMPLEX) \ No newline at end of file From c229e4c52c4229441538f99323658a67e3f8f3a2 Mon Sep 17 00:00:00 2001 From: Aaron Goin Date: Wed, 16 Jul 2025 09:40:14 -0700 Subject: [PATCH 4/5] tweaked to make table_id available on class, and adding description as a docstring comment for the class --- polyapi/poly_tables.py | 32 +++++++++++++++++++----------- polyapi/typedefs.py | 1 + tests/test_tabi.py | 45 ++++++++++++++++++++++++------------------ 3 files changed, 47 insertions(+), 31 deletions(-) diff --git a/polyapi/poly_tables.py b/polyapi/poly_tables.py index 235bc8f..cad62c9 100644 --- a/polyapi/poly_tables.py +++ b/polyapi/poly_tables.py @@ -62,7 +62,7 @@ def transform_query(query: dict) -> dict: return query -TABI_TABLE_TEMPLATE = """ +TABI_TABLE_TEMPLATE = ''' {table_name}Columns = Literal[{table_columns}] @@ -125,7 +125,8 @@ class {table_name}CountQuery(TypedDict): -class {table_name}: +class {table_name}:{table_description} + table_id = "{table_id}" @overload @staticmethod @@ -142,7 +143,7 @@ def count(*args, **kwargs) -> PolyCountResult: query = args[0] else: query = kwargs - return execute_query("{table_id}", "count", transform_query(query)) + return execute_query({table_name}.table_id, "count", transform_query(query)) @overload @staticmethod @@ -163,7 +164,7 @@ def select_many(*args, **kwargs) -> {table_name}QueryResults: query['limit'] = 1000 if query['limit'] > 1000: raise ValueError("Cannot select more than 1000 rows at a time.") - return execute_query("{table_id}", "select", transform_query(query)) + return execute_query({table_name}.table_id, "select", transform_query(query)) @overload @staticmethod @@ -181,7 +182,7 @@ def select_one(*args, **kwargs) -> {table_name}Row: else: query = kwargs query['limit'] = 1 - return first_result(execute_query("{table_id}", "select", transform_query(query))) + return first_result(execute_query({table_name}.table_id, "select", transform_query(query))) @overload @staticmethod @@ -200,7 +201,7 @@ def insert_many(*args, **kwargs) -> {table_name}QueryResults: query = kwargs if len(query['data']) > 1000: raise ValueError("Cannot insert more than 1000 rows at a time.") - return execute_query("{table_id}", "insert", query) + return execute_query({table_name}.table_id, "insert", query) @overload @staticmethod @@ -217,7 +218,7 @@ def insert_one(*args, **kwargs) -> {table_name}Row: query = args[0] else: query = kwargs - return first_result(execute_query("{table_id}", "insert", {{ 'data': [query['data']] }})) + return first_result(execute_query({table_name}.table_id, "insert", {{ 'data': [query['data']] }})) @overload @staticmethod @@ -236,7 +237,7 @@ def upsert_many(*args, **kwargs) -> {table_name}QueryResults: query = kwargs if len(data) > 1000: raise ValueError("Cannot upsert more than 1000 rows at a time.") - return execute_query("{table_id}", "upsert", query) + return execute_query({table_name}.table_id, "upsert", query) @overload @staticmethod @@ -253,7 +254,7 @@ def upsert_one(*args, **kwargs) -> {table_name}Row: query = args[0] else: query = kwargs - return first_result(execute_query("{table_id}", "upsert", {{ 'data': [query['data']] }})) + return first_result(execute_query({table_name}.table_id, "upsert", {{ 'data': [query['data']] }})) @overload @staticmethod @@ -270,7 +271,7 @@ def update_many(*args, **kwargs) -> {table_name}QueryResults: query = args[0] else: query = kwargs - return execute_query("{table_id}", 'update', transform_query(query)) + return execute_query({table_name}.table_id, "update", transform_query(query)) @overload @staticmethod @@ -287,8 +288,8 @@ def delete_many(*args, **kwargs) -> PolyDeleteResults: query = args[0] else: query = kwargs - return execute_query("{table_id}", "delete", query) -""" + return execute_query({table_name}.table_id, "delete", query) +''' def _get_column_type_str(name: str, schema: Dict[str, Any], is_required: bool) -> str: @@ -379,10 +380,17 @@ def _render_table(table: TableSpecDto) -> str: table_row_classes = _render_table_row_classes(table["name"], table["schema"]) table_row_subset_class = _render_table_subset_class(table["name"], columns, required_colunms) table_where_class = _render_table_where_class(table["name"], columns, required_colunms) + if table.get("description", ""): + table_description = '\n """' + table_description += '\n '.join(table["description"].replace('"', "'").split("\n")) + table_description += '\n """' + else: + table_description = "" return TABI_TABLE_TEMPLATE.format( table_name=table["name"], table_id=table["id"], + table_description=table_description, table_columns=table_columns, table_row_classes=table_row_classes, table_row_subset_class=table_row_subset_class, diff --git a/polyapi/typedefs.py b/polyapi/typedefs.py index 5f45a78..7dac1bc 100644 --- a/polyapi/typedefs.py +++ b/polyapi/typedefs.py @@ -77,6 +77,7 @@ class TableSpecDto(TypedDict): context: str name: str contextName: str + description: str type: Literal['table'] schema: Dict[Any, Any] unresolvedPolySchemaRefs: List diff --git a/tests/test_tabi.py b/tests/test_tabi.py index 86c34bb..16d3868 100644 --- a/tests/test_tabi.py +++ b/tests/test_tabi.py @@ -8,6 +8,7 @@ "name": "MyTable", "context": "some.context.here", "contextName": "some.context.here.MyTable", + "description": "This table stores:\n - User name\n - User age\n - If user is active on the platform", "schema": { "$schema": "http://json-schema.org/draft-06/schema#", "type": "object", @@ -132,6 +133,12 @@ class MyTableCountQuery(TypedDict): class MyTable: + """This table stores: + - User name + - User age + - If user is active on the platform + """ + table_id = "123456789" @overload @staticmethod @@ -148,7 +155,7 @@ def count(*args, **kwargs) -> PolyCountResult: query = args[0] else: query = kwargs - return execute_query(123456789, 'count', transform_query(query)) + return execute_query(MyTable.table_id, "count", transform_query(query)) @overload @staticmethod @@ -169,7 +176,7 @@ def select_many(*args, **kwargs) -> MyTableQueryResults: query['limit'] = 1000 if query['limit'] > 1000: raise ValueError("Cannot select more than 1000 rows at a time.") - return execute_query(123456789, 'select', transform_query(query)) + return execute_query(MyTable.table_id, "select", transform_query(query)) @overload @staticmethod @@ -187,7 +194,7 @@ def select_one(*args, **kwargs) -> MyTableRow: else: query = kwargs query['limit'] = 1 - return first_result(execute_query(123456789, 'select', transform_query(query))) + return first_result(execute_query(MyTable.table_id, "select", transform_query(query))) @overload @staticmethod @@ -206,7 +213,7 @@ def insert_many(*args, **kwargs) -> MyTableQueryResults: query = kwargs if len(query['data']) > 1000: raise ValueError("Cannot insert more than 1000 rows at a time.") - return execute_query(123456789, 'insert', query) + return execute_query(MyTable.table_id, "insert", query) @overload @staticmethod @@ -223,7 +230,7 @@ def insert_one(*args, **kwargs) -> MyTableRow: query = args[0] else: query = kwargs - return first_result(execute_query(123456789, 'insert', { 'data': [query['data']] })) + return first_result(execute_query(MyTable.table_id, "insert", { 'data': [query['data']] })) @overload @staticmethod @@ -242,7 +249,7 @@ def upsert_many(*args, **kwargs) -> MyTableQueryResults: query = kwargs if len(data) > 1000: raise ValueError("Cannot upsert more than 1000 rows at a time.") - return execute_query(123456789, 'upsert', query) + return execute_query(MyTable.table_id, "upsert", query) @overload @staticmethod @@ -259,7 +266,7 @@ def upsert_one(*args, **kwargs) -> MyTableRow: query = args[0] else: query = kwargs - return first_result(execute_query(123456789, 'upsert', { 'data': [query['data']] })) + return first_result(execute_query(MyTable.table_id, "upsert", { 'data': [query['data']] })) @overload @staticmethod @@ -276,7 +283,7 @@ def update_many(*args, **kwargs) -> MyTableQueryResults: query = args[0] else: query = kwargs - return execute_query(123456789, 'update', transform_query(query)) + return execute_query(MyTable.table_id, "update", transform_query(query)) @overload @staticmethod @@ -293,7 +300,7 @@ def delete_many(*args, **kwargs) -> PolyDeleteResults: query = args[0] else: query = kwargs - return execute_query(123456789, 'delete', query) + return execute_query(MyTable.table_id, "delete", query) ''' TABLE_SPEC_COMPLEX = { @@ -437,6 +444,7 @@ class MyTableCountQuery(TypedDict): class MyTable: + table_id = "123456789" @overload @staticmethod @@ -453,7 +461,7 @@ def count(*args, **kwargs) -> PolyCountResult: query = args[0] else: query = kwargs - return execute_query(123456789, 'count', transform_query(query)) + return execute_query(MyTable.table_id, "count", transform_query(query)) @overload @staticmethod @@ -474,7 +482,7 @@ def select_many(*args, **kwargs) -> MyTableQueryResults: query['limit'] = 1000 if query['limit'] > 1000: raise ValueError("Cannot select more than 1000 rows at a time.") - return execute_query(123456789, 'select', transform_query(query)) + return execute_query(MyTable.table_id, "select", transform_query(query)) @overload @staticmethod @@ -492,7 +500,7 @@ def select_one(*args, **kwargs) -> MyTableRow: else: query = kwargs query['limit'] = 1 - return first_result(execute_query(123456789, 'select', transform_query(query))) + return first_result(execute_query(MyTable.table_id, "select", transform_query(query))) @overload @staticmethod @@ -511,7 +519,7 @@ def insert_many(*args, **kwargs) -> MyTableQueryResults: query = kwargs if len(query['data']) > 1000: raise ValueError("Cannot insert more than 1000 rows at a time.") - return execute_query(123456789, 'insert', query) + return execute_query(MyTable.table_id, "insert", query) @overload @staticmethod @@ -528,7 +536,7 @@ def insert_one(*args, **kwargs) -> MyTableRow: query = args[0] else: query = kwargs - return first_result(execute_query(123456789, 'insert', { 'data': [query['data']] })) + return first_result(execute_query(MyTable.table_id, "insert", { 'data': [query['data']] })) @overload @staticmethod @@ -547,7 +555,7 @@ def upsert_many(*args, **kwargs) -> MyTableQueryResults: query = kwargs if len(data) > 1000: raise ValueError("Cannot upsert more than 1000 rows at a time.") - return execute_query(123456789, 'upsert', query) + return execute_query(MyTable.table_id, "upsert", query) @overload @staticmethod @@ -564,7 +572,7 @@ def upsert_one(*args, **kwargs) -> MyTableRow: query = args[0] else: query = kwargs - return first_result(execute_query(123456789, 'upsert', { 'data': [query['data']] })) + return first_result(execute_query(MyTable.table_id, "upsert", { 'data': [query['data']] })) @overload @staticmethod @@ -581,7 +589,7 @@ def update_many(*args, **kwargs) -> MyTableQueryResults: query = args[0] else: query = kwargs - return execute_query(123456789, 'update', transform_query(query)) + return execute_query(MyTable.table_id, "update", transform_query(query)) @overload @staticmethod @@ -598,12 +606,11 @@ def delete_many(*args, **kwargs) -> PolyDeleteResults: query = args[0] else: query = kwargs - return execute_query(123456789, 'delete', query) + return execute_query(MyTable.table_id, "delete", query) ''' class T(unittest.TestCase): def test_render_simple(self): - self.maxDiff = None output = _render_table(TABLE_SPEC_SIMPLE) self.assertEqual(output, EXPECTED_SIMPLE) From 61ec1664df2ea409fea2964cc0ed4bc321be717a Mon Sep 17 00:00:00 2001 From: Aaron Goin Date: Thu, 17 Jul 2025 11:18:07 -0700 Subject: [PATCH 5/5] bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 86abf2f..6f274ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=61.2", "wheel"] [project] name = "polyapi-python" -version = "0.3.9.dev9" +version = "0.3.9.dev10" description = "The Python Client for PolyAPI, the IPaaS by Developers for Developers" authors = [{ name = "Dan Fellin", email = "dan@polyapi.io" }] dependencies = [