Skip to content

Commit a42d3fd

Browse files
authored
Merge pull request #2072 from appwrite/feat/cli-types-python
feat(cli): generate Pydantic models with appwrite types -l python
2 parents d50ff37 + 0d3e2e1 commit a42d3fd

12 files changed

Lines changed: 1181 additions & 18 deletions

‎.github/workflows/validation.yml‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,21 @@ jobs:
398398
./appwrite --help | grep -q "GET STARTED"
399399
SHELL=bash ./appwrite completion bash >/dev/null
400400
401+
- name: Check generated Python types
402+
if: matrix.sdk == 'cli'
403+
working-directory: examples/cli/internal/typegen/testdata
404+
run: |
405+
environment="$RUNNER_TEMP/typegen-python"
406+
python3 -m venv "$environment"
407+
"$environment/bin/pip" install --quiet black==26.10.1 mypy==2.4.0 pydantic==2.14.1
408+
"$environment/bin/black" --check python.*.py
409+
for baseline in python.*.py; do
410+
directory="$environment/baselines/$baseline"
411+
mkdir -p "$directory"
412+
cp "$baseline" "$directory/appwrite_types.py"
413+
(cd "$directory" && "$environment/bin/mypy" --strict appwrite_types.py && "$environment/bin/python" -W error -c 'import appwrite_types')
414+
done
415+
401416
# The CLI ships as a native binary behind an npm launcher, so it has no
402417
# dist/ entry points to check -- what can break is the launcher itself
403418
# and the two scripts that assemble the platform packages.

‎src/SDK/Language/CLI.php‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1807,6 +1807,36 @@ public function getFiles(): array
18071807
'destination' => 'internal/typegen/testdata/cs.strict.BooksZines.cs',
18081808
'template' => 'cli/internal/typegen/testdata/cs.strict.BooksZines.cs',
18091809
],
1810+
[
1811+
'scope' => 'copy',
1812+
'destination' => 'internal/typegen/python.go',
1813+
'template' => 'cli/internal/typegen/python.go',
1814+
],
1815+
[
1816+
'scope' => 'copy',
1817+
'destination' => 'internal/typegen/testdata/python.collections.json',
1818+
'template' => 'cli/internal/typegen/testdata/python.collections.json',
1819+
],
1820+
[
1821+
'scope' => 'copy',
1822+
'destination' => 'internal/typegen/testdata/python.loose.appwrite_types.py',
1823+
'template' => 'cli/internal/typegen/testdata/python.loose.appwrite_types.py',
1824+
],
1825+
[
1826+
'scope' => 'copy',
1827+
'destination' => 'internal/typegen/testdata/python.strict.appwrite_types.py',
1828+
'template' => 'cli/internal/typegen/testdata/python.strict.appwrite_types.py',
1829+
],
1830+
[
1831+
'scope' => 'copy',
1832+
'destination' => 'internal/typegen/testdata/python.coverage.loose.appwrite_types.py',
1833+
'template' => 'cli/internal/typegen/testdata/python.coverage.loose.appwrite_types.py',
1834+
],
1835+
[
1836+
'scope' => 'copy',
1837+
'destination' => 'internal/typegen/testdata/python.coverage.strict.appwrite_types.py',
1838+
'template' => 'cli/internal/typegen/testdata/python.coverage.strict.appwrite_types.py',
1839+
],
18101840
[
18111841
'scope' => 'default',
18121842
'destination' => 'internal/generator/generator.go',

‎templates/cli/internal/cmd/typegen_commands_test.go‎

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package cmd
22

33
import (
4+
"os"
5+
"path/filepath"
46
"sort"
57
"strings"
68
"testing"
@@ -94,8 +96,8 @@ func TestTypesRequiresAnOutputDirectory(t *testing.T) {
9496

9597
// A rejected `-l` value has to say what the accepted ones are: they are short
9698
// names, several of which are not the language's usual spelling, and the reject
97-
// is also where DETECTION lands -- a Python or Ruby project has no emitter at
98-
// all, so no spelling of it would have worked.
99+
// is also where DETECTION lands -- a Ruby project has no emitter at all, so no
100+
// spelling of it would have worked.
99101
func TestUnsupportedLanguageListsTheSupportedOnes(t *testing.T) {
100102
message := unsupportedLanguage("typescript").Error()
101103

@@ -121,23 +123,41 @@ func TestUnsupportedLanguageListsTheSupportedOnes(t *testing.T) {
121123
// directory holding a .csproj, so `appwrite types .` in a C# project is rejected
122124
// by a value the CLI chose itself.
123125
func TestDetectedLanguagesWithoutAnEmitterAreExplained(t *testing.T) {
124-
for requested, want := range map[string]string{"dotnet": "cs", "javascript": "js"} {
126+
for requested, want := range map[string]string{"dotnet": "cs", "javascript": "js", "py": "python"} {
125127
message := unsupportedLanguage(requested).Error()
126128
if !strings.Contains(message, "did you mean `"+want+"`") {
127129
t.Errorf("%q was not pointed at %q:\n %s", requested, want, message)
128130
}
129131
}
130132

131-
// Python and Ruby are detectable and genuinely unsupported, so there is
132-
// nothing to suggest -- only the list.
133-
for _, requested := range []string{"python", "ruby"} {
134-
message := unsupportedLanguage(requested).Error()
135-
if strings.Contains(message, "did you mean") {
136-
t.Errorf("invented a spelling for %q, which has no emitter:\n %s", requested, message)
137-
}
138-
if !strings.Contains(message, "The supported languages are") {
139-
t.Errorf("left %q with no way forward:\n %s", requested, message)
140-
}
133+
// Ruby is detectable and genuinely unsupported, so there is nothing to
134+
// suggest -- only the list.
135+
message := unsupportedLanguage("ruby").Error()
136+
if strings.Contains(message, "did you mean") {
137+
t.Errorf("invented a spelling for ruby, which has no emitter:\n %s", message)
138+
}
139+
if !strings.Contains(message, "The supported languages are") {
140+
t.Errorf("left ruby with no way forward:\n %s", message)
141+
}
142+
}
143+
144+
func TestTypesDetectsPythonProjects(t *testing.T) {
145+
for _, marker := range []string{"pyproject.toml", "requirements.txt", "Pipfile", "setup.py"} {
146+
t.Run(marker, func(t *testing.T) {
147+
directory := t.TempDir()
148+
if err := os.WriteFile(filepath.Join(directory, marker), nil, 0o600); err != nil {
149+
t.Fatal(err)
150+
}
151+
t.Chdir(directory)
152+
153+
name, emitter, err := resolveTypeLanguage(NewRootCommand(), "auto")
154+
if err != nil {
155+
t.Fatal(err)
156+
}
157+
if name != "python" || emitter.FileName(nil) != "appwrite_types.py" {
158+
t.Errorf("resolved %q writing %q, want python writing appwrite_types.py", name, emitter.FileName(nil))
159+
}
160+
})
141161
}
142162
}
143163

‎templates/cli/internal/cmd/types.go‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,12 @@ var typeLanguages = map[string]func(directory string) typegen.Language{
4141
"java": func(string) typegen.Language { return typegen.Java{} },
4242
"dart": func(string) typegen.Language { return typegen.Dart{} },
4343
"cs": func(string) typegen.Language { return typegen.CSharp{} },
44+
"python": func(string) typegen.Language { return typegen.Python{} },
4445
}
4546

4647
// typeLanguageChoices is the accepted `-l` values, in the order the help lists
4748
// them.
48-
var typeLanguageChoices = []string{"auto", "ts", "js", "php", "kotlin", "swift", "java", "dart", "cs"}
49+
var typeLanguageChoices = []string{"auto", "ts", "js", "php", "kotlin", "swift", "java", "dart", "cs", "python"}
4950

5051
// typeLanguageAliases maps a name a user is likely to reach for to the value
5152
// the CLI accepts.
@@ -60,6 +61,8 @@ var typeLanguageAliases = map[string]string{
6061
"javascript": "js",
6162
"node": "js",
6263
"nodejs": "js",
64+
"py": "python",
65+
"python3": "python",
6366
"typescript": "ts",
6467
}
6568

@@ -85,7 +88,7 @@ func typeLanguageNames() string {
8588
// a suggestion rather than only a list.
8689
//
8790
// Detection reaches this too, and there the list is the only thing that says
88-
// what to do next: a Python or Ruby project resolves to a language the CLI has
91+
// what to do next: a Ruby project resolves to a language the CLI has
8992
// no emitter for, so no spelling of it would have worked.
9093
func unsupportedLanguage(requested string) error {
9194
if suggestion, ok := typeLanguageAliases[strings.ToLower(requested)]; ok {

‎templates/cli/internal/typegen/language.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ var languageMarkers = []struct {
203203
{"ts", []string{"tsconfig.json", "deno.json"}},
204204
{"js", []string{"package.json"}},
205205
{"php", []string{"composer.json"}},
206-
{"python", []string{"requirements.txt", "Pipfile", "pyproject.toml"}},
206+
{"python", []string{"requirements.txt", "Pipfile", "pyproject.toml", "setup.py"}},
207207
{"ruby", []string{"Gemfile", "Rakefile"}},
208208
{"kotlin", []string{"build.gradle.kts"}},
209209
{"java", []string{"build.gradle", "pom.xml"}},

‎templates/cli/internal/typegen/language_test.go‎

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"os"
66
"path/filepath"
7+
"strings"
78
"testing"
89
)
910

@@ -21,7 +22,13 @@ const baselineInvocation = "types ./types"
2122
func loadCollections(t *testing.T) []Collection {
2223
t.Helper()
2324

24-
payload, err := os.ReadFile(filepath.Join("testdata", "collections.json"))
25+
return loadFixture(t, "collections.json")
26+
}
27+
28+
func loadFixture(t *testing.T, name string) []Collection {
29+
t.Helper()
30+
31+
payload, err := os.ReadFile(filepath.Join("testdata", name))
2532
if err != nil {
2633
t.Fatalf("reading fixture: %v", err)
2734
}
@@ -52,7 +59,13 @@ func loadBaseline(t *testing.T, name string) string {
5259
func assertRendersBaseline(t *testing.T, language Language, current *Collection, strict bool, baseline string) {
5360
t.Helper()
5461

55-
rendered, err := language.Render(loadCollections(t), current, strict, baselineInvocation)
62+
assertRendersFixture(t, language, loadCollections(t), current, strict, baseline)
63+
}
64+
65+
func assertRendersFixture(t *testing.T, language Language, collections []Collection, current *Collection, strict bool, baseline string) {
66+
t.Helper()
67+
68+
rendered, err := language.Render(collections, current, strict, baselineInvocation)
5669
if err != nil {
5770
t.Fatalf("render: %v", err)
5871
}
@@ -183,6 +196,105 @@ func TestCSharpMatchesBaseline(t *testing.T) {
183196
assertMultiFileBaselines(t, CSharp{}, "cs")
184197
}
185198

199+
func TestPythonMatchesBaseline(t *testing.T) {
200+
for _, fixture := range []struct{ prefix, file string }{
201+
{"python", "collections.json"},
202+
{"python.coverage", "python.collections.json"},
203+
} {
204+
for _, mode := range []string{"loose", "strict"} {
205+
t.Run(fixture.prefix+"."+mode, func(t *testing.T) {
206+
baseline := fixture.prefix + "." + mode + "." + (Python{}).FileName(nil)
207+
assertRendersFixture(t, Python{}, loadFixture(t, fixture.file), nil, mode == "strict", baseline)
208+
})
209+
}
210+
}
211+
}
212+
213+
func TestPythonFieldNamesAreUsableIdentifiers(t *testing.T) {
214+
attributes := []Attribute{
215+
{Key: "fullName"}, {Key: "full_name"}, {Key: "class"}, {Key: "_hidden"},
216+
{Key: "9lives"}, {Key: "!!!"}, {Key: "model_config"}, {Key: "Items"}, {Key: "published.at"},
217+
}
218+
classes := pythonClasses{order: []string{"Items"}}
219+
220+
for strict, want := range map[bool][]string{
221+
false: {"fullName", "full_name", "class_", "hidden", "field_9lives", "field", "model_config_", "Items_", "published_at"},
222+
true: {"full_name_1", "full_name", "class_", "hidden", "field_9lives", "field", "model_config_", "items", "published_at"},
223+
} {
224+
got := pythonFieldNames(attributes, strict, classes)
225+
if strings.Join(got, " ") != strings.Join(want, " ") {
226+
t.Errorf("strict=%v:\n want %v\n got %v", strict, want, got)
227+
}
228+
}
229+
}
230+
231+
func TestPythonClassNamesNeverCollide(t *testing.T) {
232+
classes := newPythonClasses([]Collection{
233+
{ID: "a", Name: "Orders Status"},
234+
{ID: "b", Name: "Orders", Attributes: []Attribute{{Key: "status", Format: AttributeTypeEnum}}},
235+
{ID: "c", Name: "orders"},
236+
{ID: "d", Name: "2024 sales"},
237+
})
238+
239+
want := map[string]string{"a": "OrdersStatus", "b": "Orders", "c": "Orders1", "d": "Model2024Sales"}
240+
for id, name := range want {
241+
if model := classes.models[pythonModelKey(Collection{ID: id})]; model != name {
242+
t.Errorf("model %s = %q, want %q", id, model, name)
243+
}
244+
}
245+
if enum := classes.enums[pythonEnumKey(Collection{ID: "b"}, "status")]; enum != "OrdersStatus1" {
246+
t.Errorf("enum = %q, want OrdersStatus1", enum)
247+
}
248+
}
249+
250+
func TestPythonModelsAreKeyedByDatabase(t *testing.T) {
251+
collections := []Collection{
252+
{ID: "items", DatabaseID: "a", Name: "First", Attributes: []Attribute{
253+
{Key: "status", Type: AttributeTypeString, Format: AttributeTypeEnum, Required: true, Elements: []string{"open"}},
254+
{Key: "owner", Type: AttributeTypeRelationship, Required: true, RelatedTable: "owners", RelationType: "oneToOne", Side: "parent"},
255+
}},
256+
{ID: "items", DatabaseID: "b", Name: "Second", Attributes: []Attribute{
257+
{Key: "status", Type: AttributeTypeString, Format: AttributeTypeEnum, Required: true, Elements: []string{"closed"}},
258+
{Key: "owner", Type: AttributeTypeRelationship, Required: true, RelatedTable: "owners", RelationType: "oneToOne", Side: "parent"},
259+
}},
260+
{ID: "owners", DatabaseID: "a", Name: "First Owners"},
261+
{ID: "owners", DatabaseID: "b", Name: "Second Owners"},
262+
}
263+
264+
rendered, err := (Python{}).Render(collections, nil, false, baselineInvocation)
265+
if err != nil {
266+
t.Fatalf("render: %v", err)
267+
}
268+
269+
for _, want := range []string{
270+
"class FirstStatus(str, Enum):\n OPEN = \"open\"\n",
271+
"class SecondStatus(str, Enum):\n CLOSED = \"closed\"\n",
272+
"class First(BaseModel):\n model_config = ConfigDict(extra=\"allow\", populate_by_name=True)\n\n status: FirstStatus\n owner: Union[FirstOwners, str]\n",
273+
"class Second(BaseModel):\n model_config = ConfigDict(extra=\"allow\", populate_by_name=True)\n\n status: SecondStatus\n owner: Union[SecondOwners, str]\n",
274+
"class FirstOwners(BaseModel):",
275+
"class SecondOwners(BaseModel):",
276+
} {
277+
if strings.Count(rendered, want) != 1 {
278+
t.Errorf("want exactly one %q in:\n%s", want, rendered)
279+
}
280+
}
281+
}
282+
283+
func TestPythonStringMatchesBlackQuoting(t *testing.T) {
284+
for value, want := range map[string]string{
285+
"plain": `"plain"`,
286+
`say "hi"`: `'say "hi"'`,
287+
"it's": `"it's"`,
288+
`it's "x"`: `'it\'s "x"'`,
289+
`it's "x`: `"it's \"x"`,
290+
"a\\b\nc\x01d": `"a\\b\nc\x01d"`,
291+
} {
292+
if got := pythonString(value); got != want {
293+
t.Errorf("pythonString(%q) = %s, want %s", value, got, want)
294+
}
295+
}
296+
}
297+
186298
// TestPHPArrayNeverGainsNullBranch pins the early return in PHP.Type: an
187299
// optional array attribute is `array`, not `array|null`, because the array
188300
// check precedes the nullable suffix.

0 commit comments

Comments
 (0)