Skip to content

Commit dab82e9

Browse files
committed
feat: add Generic[T] support to Python SDK models
Add generic type support for models with additionalProperties (e.g., Row): - Add hasGenericType() method and Twig filters to detect generic models - Update model.py.twig to generate Generic[T] classes with: - PrivateAttr _data for storing typed user data - data property with type T for IDE autocomplete - with_data() class method for typed deserialization - to_dict() override for proper serialization - Update service.py.twig to accept model_type parameter - Update api.twig to use with_data() for generic response models - Support nested generic types (RowList -> Row[T]) Usage: row = tables.get_row(..., model_type=Post) print(row.data.title) # Fully typed access rows = tables.list_rows(..., model_type=Post) for row in rows.rows: print(row.data.title) # Also typed!
1 parent c56d645 commit dab82e9

File tree

4 files changed

+134
-10
lines changed

4 files changed

+134
-10
lines changed

src/SDK/Language/Python.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,39 @@ protected function getResponseType(array $method, string $serviceName = ''): str
671671
return 'Dict[str, Any]';
672672
}
673673

674+
/**
675+
* Check if a model or any of its sub-schemas has additionalProperties
676+
*
677+
* @param string|null $model
678+
* @param array $spec
679+
* @return bool
680+
*/
681+
protected function hasGenericType(?string $model, array $spec): bool
682+
{
683+
if (empty($model) || $model === 'any' || !array_key_exists($model, $spec['definitions'] ?? [])) {
684+
return false;
685+
}
686+
687+
$modelDef = $spec['definitions'][$model];
688+
689+
// Check if model has additionalProperties (dynamic fields)
690+
if (!empty($modelDef['additionalProperties'])) {
691+
return true;
692+
}
693+
694+
// Recursively check sub-schemas
695+
foreach ($modelDef['properties'] ?? [] as $property) {
696+
if (!\array_key_exists('sub_schema', $property) || !$property['sub_schema']) {
697+
continue;
698+
}
699+
if ($this->hasGenericType($property['sub_schema'], $spec)) {
700+
return true;
701+
}
702+
}
703+
704+
return false;
705+
}
706+
674707
public function getFilters(): array
675708
{
676709
return [
@@ -680,6 +713,17 @@ public function getFilters(): array
680713
new TwigFilter('getPropertyType', function ($value, $method = []) {
681714
return $this->getTypeName($value, $method);
682715
}),
716+
new TwigFilter('hasGenericType', function (string $model, array $spec) {
717+
return $this->hasGenericType($model, $spec);
718+
}),
719+
new TwigFilter('hasGenericTypeProperty', function (array $properties, array $spec) {
720+
foreach ($properties as $property) {
721+
if (!empty($property['sub_schema']) && $this->hasGenericType($property['sub_schema'], $spec)) {
722+
return true;
723+
}
724+
}
725+
return false;
726+
}),
683727
new TwigFilter('getServicePropertyType', function (array $value, string $serviceName) {
684728
return $this->getServicePropertyType($value, $serviceName);
685729
}),

templates/python/base/requests/api.twig

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,14 @@
1616

1717
return self._parse_response(response, union_models=({% for responseModel in validResponseModels %}{% if (responseModel | caseUcfirst) == (service.name | caseUcfirst) %}{{ responseModel | caseUcfirst }}Model{% else %}{{ responseModel | caseUcfirst }}{% endif %}, {% endfor %}))
1818
{% elseif method.responseModel and method.responseModel != 'any' %}
19+
{% set isGenericResponse = method.responseModel | hasGenericType(spec) %}
20+
{% if isGenericResponse %}
21+
22+
return {{ method.responseModel | caseUcfirst }}.with_data(response, model_type)
23+
{% else %}
1924

2025
return self._parse_response(response, model={% if (method.responseModel | caseUcfirst) == (service.name | caseUcfirst) %}{{ method.responseModel | caseUcfirst }}Model{% else %}{{ method.responseModel | caseUcfirst }}{% endif %})
26+
{% endif %}
2127
{% else %}
2228

2329
return response

templates/python/package/models/model.py.twig

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
from typing import Any, Dict, List, Optional, Union
1+
{% set hasGenericProperty = definition.properties|hasGenericTypeProperty(spec) %}
2+
{% set isGeneric = definition.additionalProperties or hasGenericProperty %}
3+
from typing import Any, Dict, List, Optional, Union, cast{% if isGeneric %}, Generic, TypeVar, Type{% endif %}
24

3-
from pydantic import Field
5+
from pydantic import Field, PrivateAttr
46

57
from .base_model import AppwriteModel
68
{% set added = [] %}
@@ -25,16 +27,22 @@ from .{{ subSchema | caseSnake }} import {{ subSchema | caseUcfirst }}
2527
{%~ endfor %}
2628
{%~ endif %}
2729
{%~ endfor %}
30+
{% if isGeneric %}
2831

29-
class {{ definition.name | caseUcfirst }}(AppwriteModel):
32+
T = TypeVar('T')
33+
{% endif %}
34+
35+
class {{ definition.name | caseUcfirst }}(AppwriteModel{% if isGeneric %}, Generic[T]{% endif %}):
3036
"""
3137
{{ definition.description }}
3238
{% if definition.properties | length > 0 %}
3339

3440
Attributes
3541
----------
3642
{% for property in definition.properties %}
37-
{% set propertyType %}{% if not property.required and not (property.nullable ?? false) %}Optional[{{ property | getModelPropertyType(definition.name) | raw }}]{% else %}{{ property | getModelPropertyType(definition.name) | raw }}{% endif %}{% endset %}
43+
{% set baseType = property | getModelPropertyType(definition.name) | raw %}
44+
{% set propertyType %}{% if property.sub_schema and property.sub_schema | hasGenericType(spec) %}{{ baseType | replace({(property.sub_schema | caseUcfirst): (property.sub_schema | caseUcfirst) ~ '[T]'}) }}{% else %}{{ baseType }}{% endif %}{% endset %}
45+
{% if not property.required and not (property.nullable ?? false) %}{% set propertyType %}Optional[{{ propertyType | trim }}]{% endset %}{% endif %}
3846
{{ property | getModelFieldName(definition.properties) }} : {{ propertyType | trim }}
3947
{% if property.description %}
4048
{{ property.description | replace({"\n": "\n "}) }}
@@ -48,6 +56,62 @@ class {{ definition.name | caseUcfirst }}(AppwriteModel):
4856
pass
4957
{% else %}
5058
{% for property in definition.properties %}
51-
{{ property | getModelFieldName(definition.properties) }}: {% if not property.required and not (property.nullable ?? false) %}Optional[{{ property | getModelPropertyType(definition.name) | raw }}]{% else %}{{ property | getModelPropertyType(definition.name) | raw }}{% endif %} = Field({% if property.required %}...{% else %}default=None{% endif %}, alias='{{ property.name }}')
59+
{% set baseType = property | getModelPropertyType(definition.name) | raw %}
60+
{% set propType %}{% if property.sub_schema and property.sub_schema | hasGenericType(spec) %}{{ baseType | replace({(property.sub_schema | caseUcfirst): (property.sub_schema | caseUcfirst) ~ '[T]'}) }}{% else %}{{ baseType }}{% endif %}{% endset %}
61+
{{ property | getModelFieldName(definition.properties) }}: {% if not property.required and not (property.nullable ?? false) %}Optional[{{ propType | trim }}]{% else %}{{ propType | trim }}{% endif %} = Field({% if property.required %}...{% else %}default=None{% endif %}, alias='{{ property.name }}')
62+
{% endfor %}
63+
{% endif %}
64+
{% if isGeneric %}
65+
66+
@classmethod
67+
def with_data(cls, data: Dict[str, Any], model_type: Type[T] = dict) -> '{{ definition.name | caseUcfirst }}[T]':
68+
"""Create {{ definition.name | caseUcfirst }} instance with typed data."""
69+
{% if definition.additionalProperties %}
70+
internal_fields = {k: v for k, v in data.items() if k.startswith('$')}
71+
user_data = {k: v for k, v in data.items() if not k.startswith('$')}
72+
instance = cls.model_validate(internal_fields)
73+
instance._data = model_type(**user_data) if model_type is not dict else user_data
74+
return instance
75+
{% else %}
76+
instance = cls.model_validate(data)
77+
{% for property in definition.properties %}
78+
{% if property.sub_schema and property.sub_schema | hasGenericType(spec) %}
79+
if '{{ property.name }}' in data and data['{{ property.name }}'] is not None:
80+
{% if property.type == 'array' %}
81+
instance.{{ property | getModelFieldName(definition.properties) }} = [
82+
{{ property.sub_schema | caseUcfirst }}.with_data(row, model_type)
83+
for row in data['{{ property.name }}']
84+
]
85+
{% else %}
86+
instance.{{ property | getModelFieldName(definition.properties) }} = {{ property.sub_schema | caseUcfirst }}.with_data(
87+
data['{{ property.name }}'], model_type
88+
)
89+
{% endif %}
90+
{% endif %}
5291
{% endfor %}
92+
return instance
93+
{% endif %}
94+
{% endif %}
95+
{% if definition.additionalProperties %}
96+
97+
_data: Any = PrivateAttr(default_factory=dict)
98+
99+
@property
100+
def data(self) -> T:
101+
return cast(T, self._data)
102+
103+
@data.setter
104+
def data(self, value: T) -> None:
105+
object.__setattr__(self, '_data', value)
106+
107+
def to_dict(self) -> Dict[str, Any]:
108+
result = super().to_dict()
109+
if hasattr(self, '_data'):
110+
if isinstance(self._data, dict):
111+
result['data'] = self._data
112+
elif hasattr(self._data, 'model_dump'):
113+
result['data'] = self._data.model_dump(mode='json')
114+
else:
115+
result['data'] = self._data
116+
return result
53117
{% endif %}

templates/python/package/services/service.py.twig

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from ..service import Service
2-
from typing import Any, Dict, List, Optional, Union
2+
from typing import Any, Dict, List, Optional, Union{% set hasGenericModel = false %}{% for method in service.methods %}{% if method.responseModel and method.responseModel != 'any' and method.responseModel | hasGenericType(spec) %}{% set hasGenericModel = true %}{% endif %}{% endfor %}{% if hasGenericModel %}, Type, TypeVar{% endif %}
3+
34
from ..exception import AppwriteException
45
from appwrite.utils.deprecated import deprecated
56
{% set added = [] %}
@@ -50,6 +51,10 @@ from ..models.{{ responseModel | caseSnake }} import {{ responseModel | caseUcfi
5051
{% endfor %}
5152
{% endif %}
5253
{% endfor %}
54+
{% if hasGenericModel %}
55+
56+
T = TypeVar('T')
57+
{% endif %}
5358

5459
class {{ service.name | caseUcfirst }}(Service):
5560

@@ -75,6 +80,7 @@ class {{ service.name | caseUcfirst }}(Service):
7580
{% endif %}
7681
{% endfor %}
7782
{% endif %}
83+
{% set isGenericResponse = method.responseModel and method.responseModel != 'any' and method.responseModel | hasGenericType(spec) %}
7884

7985
{% if method.deprecated %}
8086
{% if method.since and method.replaceWith %}
@@ -93,20 +99,21 @@ Union[{% for responseModel in validResponseModels %}{% if not loop.first %}, {%
9399
{% elseif validResponseModels|length == 1 %}
94100
{% if (validResponseModels[0] | caseUcfirst) == (service.name | caseUcfirst) %}{{ validResponseModels[0] | caseUcfirst }}Model{% else %}{{ validResponseModels[0] | caseUcfirst }}{% endif %}
95101
{% elseif method.responseModel and method.responseModel != 'any' %}
96-
{% if (method.responseModel | caseUcfirst) == (service.name | caseUcfirst) %}{{ method.responseModel | caseUcfirst }}Model{% else %}{{ method.responseModel | caseUcfirst }}{% endif %}
102+
{% if isGenericResponse %}{{ method.responseModel | caseUcfirst }}[T]{% elseif (method.responseModel | caseUcfirst) == (service.name | caseUcfirst) %}{{ method.responseModel | caseUcfirst }}Model{% else %}{{ method.responseModel | caseUcfirst }}{% endif %}
97103
{% else %}
98104
Dict[str, Any]
99105
{% endif %}
100106
{% endset %}
101107
def {{ method.name | caseSnake }}(
102108
self{% for parameter in method.parameters.all %},
103109
{{ parameter.name | escapeKeyword | caseSnake }}: {{ parameter | getServicePropertyType(service.name) | raw }}{% if not parameter.required %} = None{% endif %}{% endfor %}{% if 'multipart/form-data' in method.consumes %},
104-
on_progress = None{% endif %}
110+
on_progress = None{% endif %}{% if isGenericResponse %},
111+
model_type: Type[T] = dict{% endif %}
105112
) -> {{ returnType | trim }}:
106113
"""
107114
{% autoescape false %}{{ method.description | replace({"\n": "\n "}) }}{% endautoescape %}
108115

109-
{% if method.parameters.all|length > 0 or 'multipart/form-data' in method.consumes %}
116+
{% if method.parameters.all|length > 0 or 'multipart/form-data' in method.consumes or isGenericResponse %}
110117

111118
{% if method.deprecated %}
112119
{%~ if method.since and method.replaceWith %}
@@ -125,6 +132,9 @@ Dict[str, Any]
125132
{% endfor %}{% if 'multipart/form-data' in method.consumes %}
126133
on_progress : callable, optional
127134
Optional callback for upload progress
135+
{% endif %}{%~ if isGenericResponse ~%}
136+
model_type : Type[T], optional
137+
Pydantic model class for the user-defined data. Defaults to dict for backward compatibility.
128138
{% endif %}{% endif %}
129139

130140
Returns
@@ -137,7 +147,7 @@ Dict[str, Any]
137147
API response as one of the typed response models
138148
{% elseif validResponseModels|length == 1 %}{{ validResponseModels[0] | caseUcfirst }}
139149
API response as a typed Pydantic model
140-
{% elseif method.responseModel and method.responseModel != 'any' %}{{ method | getResponseType(service.name) }}
150+
{% elseif method.responseModel and method.responseModel != 'any' %}{% if isGenericResponse %}{{ method.responseModel | caseUcfirst }}[T]{% else %}{{ method | getResponseType(service.name) }}{% endif %}
141151
API response as a typed Pydantic model
142152
{% else %}Dict[str, Any]
143153
API response as a dictionary

0 commit comments

Comments
 (0)