Skip to content

Commit e895ae9

Browse files
authored
add poly schemas support (#31)
* onward * adding schemas for Pythonland! * onward * next * next * next * next * test * next * next * next * little tweak for A-Aron * fix * next
1 parent 4ff9ba9 commit e895ae9

File tree

15 files changed

+252
-44
lines changed

15 files changed

+252
-44
lines changed

.flake8

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
[flake8]
2-
extend-ignore = E203,E303,E402,E501,E722,W391,F401,W292
2+
ignore = E203,E303,E402,E501,E722,W391,F401,W292,F811
33
max-line-length = 150
4-
max-complexity = 20
4+
max-complexity = 22

.gitignore

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,6 @@ __pycache__
3434
.polyapi-python
3535
function_add_test.py
3636
lib_test*.py
37-
polyapi/poly/
38-
polyapi/vari/
37+
polyapi/poly
38+
polyapi/vari
39+
polyapi/schemas

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,16 @@ To run this library's unit tests, please clone the repo then run:
143143
python -m unittest discover
144144
```
145145

146+
## Linting
147+
148+
The flake8 config is at the root of this repo at `.flake8`.
149+
150+
When hacking on this library, please enable flake8 and add this line to your flake8 args (e.g., in your VSCode Workspace Settings):
151+
152+
```
153+
--config=.flake8
154+
```
155+
146156
## Support
147157

148158
If you run into any issues or want help getting started with this project, please contact support@polyapi.io

polyapi/api.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from typing import List, Dict, Any, TypedDict
99
{args_def}
1010
{return_type_def}
11+
1112
class {api_response_type}(TypedDict):
1213
status: int
1314
headers: Dict

polyapi/cli.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
CLI_COMMANDS = ["setup", "generate", "function", "clear", "help", "update_rendered_spec"]
1515

16+
1617
def execute_from_cli():
1718
# First we setup all our argument parsing logic
1819
# Then we parse the arguments (waaay at the bottom)
@@ -46,7 +47,7 @@ def setup(args):
4647

4748
def generate_command(args):
4849
initialize_config()
49-
print("Generating Poly functions...", end="")
50+
print("Generating Poly Python SDK...", end="")
5051
generate()
5152
print_green("DONE")
5253

polyapi/function_cli.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import sys
22
from typing import Any, List, Optional
33
import requests
4-
from polyapi.generate import get_functions_and_parse, generate_functions
4+
from polyapi.generate import cache_specs, generate_functions, get_specs, parse_function_specs
55
from polyapi.config import get_api_key_and_url
66
from polyapi.utils import get_auth_headers, print_green, print_red, print_yellow
77
from polyapi.parser import parse_function_code, get_jsonschema_type
@@ -88,7 +88,10 @@ def function_add_or_update(
8888
print(f"Function ID: {function_id}")
8989
if generate:
9090
print("Generating new custom function...", end="")
91-
functions = get_functions_and_parse(limit_ids=[function_id])
91+
# TODO do something more efficient here rather than regetting ALL the specs again
92+
specs = get_specs()
93+
cache_specs(specs)
94+
functions = parse_function_specs(specs)
9295
generate_functions(functions)
9396
print_green("DONE")
9497
else:

polyapi/generate.py

Lines changed: 93 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22
import requests
33
import os
44
import shutil
5-
from typing import List
5+
from typing import List, cast
66

7+
from polyapi import schema
78
from polyapi.auth import render_auth_function
89
from polyapi.client import render_client_function
10+
from polyapi.poly_schemas import generate_schemas
911
from polyapi.webhook import render_webhook_handle
1012

11-
from .typedefs import PropertySpecification, SpecificationDto, VariableSpecDto
13+
from .typedefs import PropertySpecification, SchemaSpecDto, SpecificationDto, VariableSpecDto
1214
from .api import render_api_function
1315
from .server import render_server_function
1416
from .utils import add_import_to_init, get_auth_headers, init_the_init, to_func_namespace
@@ -18,12 +20,21 @@
1820
SUPPORTED_FUNCTION_TYPES = {
1921
"apiFunction",
2022
"authFunction",
21-
"customFunction",
23+
"customFunction", # client function - this is badly named in /specs atm
2224
"serverFunction",
2325
"webhookHandle",
2426
}
2527

26-
SUPPORTED_TYPES = SUPPORTED_FUNCTION_TYPES | {"serverVariable"}
28+
SUPPORTED_TYPES = SUPPORTED_FUNCTION_TYPES | {"serverVariable", "schema", "snippet"}
29+
30+
31+
X_POLY_REF_WARNING = '''"""
32+
x-poly-ref:
33+
path:'''
34+
35+
X_POLY_REF_BETTER_WARNING = '''"""
36+
Unresolved schema, please add the following schema to complete it:
37+
path:'''
2738

2839

2940
def get_specs() -> List:
@@ -38,9 +49,56 @@ def get_specs() -> List:
3849
raise NotImplementedError(resp.content)
3950

4051

52+
def build_schema_index(items):
53+
index = {}
54+
for item in items:
55+
if item.get("type") == "schema" and "contextName" in item:
56+
index[item["contextName"]] = {**item.get("definition", {}), "name": item.get("name")}
57+
return index
58+
59+
60+
def resolve_poly_refs(obj, schema_index):
61+
if isinstance(obj, dict):
62+
if "x-poly-ref" in obj:
63+
ref = obj["x-poly-ref"]
64+
if isinstance(ref, dict) and "path" in ref:
65+
path = ref["path"]
66+
if path in schema_index:
67+
return resolve_poly_refs(schema_index[path], schema_index)
68+
else:
69+
return obj
70+
return {k: resolve_poly_refs(v, schema_index) for k, v in obj.items()}
71+
elif isinstance(obj, list):
72+
return [resolve_poly_refs(item, schema_index) for item in obj]
73+
else:
74+
return obj
75+
76+
77+
def replace_poly_refs_in_functions(specs: List[SpecificationDto], schema_index):
78+
spec_idxs_to_remove = []
79+
for idx, spec in enumerate(specs):
80+
if spec.get("type") in ("apiFunction", "customFunction", "serverFunction"):
81+
func = spec.get("function")
82+
if func:
83+
try:
84+
spec["function"] = resolve_poly_refs(func, schema_index)
85+
except Exception:
86+
print()
87+
print(f"{spec['context']}.{spec['name']} (id: {spec['id']}) failed to resolve poly refs, skipping!")
88+
spec_idxs_to_remove.append(idx)
89+
90+
# reverse the list so we pop off later indexes first
91+
spec_idxs_to_remove.reverse()
92+
93+
for idx in spec_idxs_to_remove:
94+
specs.pop(idx)
95+
96+
return specs
97+
98+
4199
def parse_function_specs(
42100
specs: List[SpecificationDto],
43-
limit_ids: List[str] | None, # optional list of ids to limit to
101+
limit_ids: List[str] | None = None, # optional list of ids to limit to
44102
) -> List[SpecificationDto]:
45103
functions = []
46104
for spec in specs:
@@ -91,23 +149,14 @@ def read_cached_specs() -> List[SpecificationDto]:
91149
return json.loads(f.read())
92150

93151

94-
def get_functions_and_parse(limit_ids: List[str] | None = None) -> List[SpecificationDto]:
95-
specs = get_specs()
96-
cache_specs(specs)
97-
return parse_function_specs(specs, limit_ids=limit_ids)
152+
def get_variables() -> List[VariableSpecDto]:
153+
specs = read_cached_specs()
154+
return [cast(VariableSpecDto, spec) for spec in specs if spec["type"] == "serverVariable"]
98155

99156

100-
def get_variables() -> List[VariableSpecDto]:
101-
api_key, api_url = get_api_key_and_url()
102-
headers = {"Authorization": f"Bearer {api_key}"}
103-
# TODO do some caching so this and get_functions just do 1 function call
104-
url = f"{api_url}/specs"
105-
resp = requests.get(url, headers=headers)
106-
if resp.status_code == 200:
107-
specs = resp.json()
108-
return [spec for spec in specs if spec["type"] == "serverVariable"]
109-
else:
110-
raise NotImplementedError(resp.content)
157+
def get_schemas() -> List[SchemaSpecDto]:
158+
specs = read_cached_specs()
159+
return [cast(SchemaSpecDto, spec) for spec in specs if spec["type"] == "schema"]
111160

112161

113162
def remove_old_library():
@@ -120,12 +169,28 @@ def remove_old_library():
120169
if os.path.exists(path):
121170
shutil.rmtree(path)
122171

172+
path = os.path.join(currdir, "schemas")
173+
if os.path.exists(path):
174+
shutil.rmtree(path)
175+
123176

124177
def generate() -> None:
125178

126179
remove_old_library()
127180

128-
functions = get_functions_and_parse()
181+
limit_ids: List[str] = [] # useful for narrowing down generation to a single function to debug
182+
183+
specs = get_specs()
184+
cache_specs(specs)
185+
functions = parse_function_specs(specs, limit_ids=limit_ids)
186+
187+
schemas = get_schemas()
188+
if schemas:
189+
generate_schemas(schemas)
190+
191+
schema_index = build_schema_index(schemas)
192+
functions = replace_poly_refs_in_functions(functions, schema_index)
193+
129194
if functions:
130195
generate_functions(functions)
131196
else:
@@ -138,6 +203,7 @@ def generate() -> None:
138203
if variables:
139204
generate_variables(variables)
140205

206+
141207
# indicator to vscode extension that this is a polyapi-python project
142208
file_path = os.path.join(os.getcwd(), ".polyapi-python")
143209
open(file_path, "w").close()
@@ -214,6 +280,12 @@ def render_spec(spec: SpecificationDto):
214280
arguments,
215281
return_type,
216282
)
283+
284+
if X_POLY_REF_WARNING in func_type_defs:
285+
# this indicates that jsonschema_gentypes has detected an x-poly-ref
286+
# let's add a more user friendly error explaining what is going on
287+
func_type_defs = func_type_defs.replace(X_POLY_REF_WARNING, X_POLY_REF_BETTER_WARNING)
288+
217289
return func_str, func_type_defs
218290

219291

polyapi/poly/__init__.py

Whitespace-only changes.

polyapi/poly_schemas.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import os
2+
from typing import Any, Dict, List, Tuple
3+
4+
from polyapi.schema import wrapped_generate_schema_types
5+
from polyapi.utils import add_import_to_init, init_the_init
6+
from tests.test_schema import SCHEMA
7+
8+
from .typedefs import SchemaSpecDto
9+
10+
SCHEMA_CODE_IMPORTS = """from typing_extensions import TypedDict, NotRequired
11+
12+
13+
"""
14+
15+
16+
FALLBACK_SPEC_TEMPLATE = """class {name}(TypedDict, total=False):
17+
''' unable to generate schema for {name}, defaulting to permissive type '''
18+
pass
19+
"""
20+
21+
22+
def generate_schemas(specs: List[SchemaSpecDto]):
23+
for spec in specs:
24+
create_schema(spec)
25+
26+
27+
def create_schema(spec: SchemaSpecDto) -> None:
28+
folders = ["schemas"]
29+
if spec["context"]:
30+
folders += [s for s in spec["context"].split(".")]
31+
32+
# build up the full_path by adding all the folders
33+
full_path = os.path.join(os.path.dirname(os.path.abspath(__file__)))
34+
35+
for idx, folder in enumerate(folders):
36+
full_path = os.path.join(full_path, folder)
37+
if not os.path.exists(full_path):
38+
os.makedirs(full_path)
39+
next = folders[idx + 1] if idx + 1 < len(folders) else None
40+
if next:
41+
add_import_to_init(full_path, next, code_imports=SCHEMA_CODE_IMPORTS)
42+
43+
add_schema_to_init(full_path, spec)
44+
45+
46+
def add_schema_to_init(full_path: str, spec: SchemaSpecDto):
47+
init_the_init(full_path, code_imports="")
48+
init_path = os.path.join(full_path, "__init__.py")
49+
with open(init_path, "a") as f:
50+
f.write(render_poly_schema(spec) + "\n\n")
51+
52+
53+
def render_poly_schema(spec: SchemaSpecDto) -> str:
54+
definition = spec["definition"]
55+
if not definition.get("type"):
56+
definition["type"] = "object"
57+
root, schema_types = wrapped_generate_schema_types(
58+
definition, root=spec["name"], fallback_type=Dict
59+
)
60+
return schema_types
61+
# return FALLBACK_SPEC_TEMPLATE.format(name=spec["name"])

polyapi/schema.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
1+
""" NOTE: this file represents the schema parsing logic for jsonschema_gentypes
2+
"""
3+
import random
4+
import string
15
import logging
26
import contextlib
37
from typing import Dict
48
from jsonschema_gentypes.cli import process_config
59
from jsonschema_gentypes import configuration
10+
import referencing
611
import tempfile
712
import json
813

14+
import referencing.exceptions
15+
916
from polyapi.constants import JSONSCHEMA_TO_PYTHON_TYPE_MAP
1017

1118

@@ -33,8 +40,18 @@ def _temp_store_input_data(input_data: Dict) -> str:
3340

3441

3542
def wrapped_generate_schema_types(type_spec: dict, root, fallback_type):
43+
from polyapi.utils import pascalCase
3644
if not root:
37-
root = "MyList" if fallback_type == "List" else "MyDict"
45+
root = "List" if fallback_type == "List" else "Dict"
46+
if type_spec.get("x-poly-ref") and type_spec["x-poly-ref"].get("path"):
47+
# x-poly-ref occurs when we have an unresolved reference
48+
# lets name the root after the reference for some level of visibility
49+
root += pascalCase(type_spec["x-poly-ref"]["path"].replace(".", " "))
50+
else:
51+
# add three random letters for uniqueness
52+
root += random.choice(string.ascii_letters).upper()
53+
root += random.choice(string.ascii_letters).upper()
54+
root += random.choice(string.ascii_letters).upper()
3855

3956
root = clean_title(root)
4057

@@ -44,8 +61,13 @@ def wrapped_generate_schema_types(type_spec: dict, root, fallback_type):
4461
# some schemas are so huge, our library cant handle it
4562
# TODO identify critical recursion penalty and maybe switch underlying logic to iterative?
4663
return fallback_type, ""
64+
except referencing.exceptions.CannotDetermineSpecification:
65+
# just go with fallback_type here
66+
# we couldn't match the right $ref earlier in resolve_poly_refs
67+
# {'$ref': '#/definitions/FinanceAccountListModel'}
68+
return fallback_type, ""
4769
except:
48-
logging.exception(f"Error when generating schema type: {type_spec}")
70+
logging.error(f"Error when generating schema type: {type_spec}\nusing fallback type '{fallback_type}'")
4971
return fallback_type, ""
5072

5173

0 commit comments

Comments
 (0)