From 0e688e92e29467d829564c43c941f9df1c20c6bc Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 15 Oct 2026 22:37:08 +0100 Subject: [PATCH 1/2] feat(cli): generate Pydantic models with appwrite types -l python The Python SDK now takes a model_type on row reads and returns Row[T], but the CLI could not produce a T for it: -l python was rejected and auto detection of a pyproject.toml or requirements.txt project ended in an unsupported-language error. The new emitter writes one appwrite_types.py so mutual relationships resolve without import cycles. Fields keep the column key as an alias whenever the Python name differs, so rows validate straight from the API, and an unselected relationship is typed as the id string the API returns in its place. The output is Black-clean and passes mypy --strict, which the validation workflow now checks against the baselines. --- .github/workflows/validation.yml | 15 + src/SDK/Language/CLI.php | 30 ++ .../cli/internal/cmd/typegen_commands_test.go | 46 +- templates/cli/internal/cmd/types.go | 7 +- templates/cli/internal/typegen/language.go | 2 +- .../cli/internal/typegen/language_test.go | 83 ++- templates/cli/internal/typegen/python.go | 492 ++++++++++++++++++ .../typegen/testdata/python.collections.json | 135 +++++ .../python.coverage.loose.appwrite_types.py | 95 ++++ .../python.coverage.strict.appwrite_types.py | 95 ++++ .../testdata/python.loose.appwrite_types.py | 51 ++ .../testdata/python.strict.appwrite_types.py | 51 ++ 12 files changed, 1084 insertions(+), 18 deletions(-) create mode 100644 templates/cli/internal/typegen/python.go create mode 100644 templates/cli/internal/typegen/testdata/python.collections.json create mode 100644 templates/cli/internal/typegen/testdata/python.coverage.loose.appwrite_types.py create mode 100644 templates/cli/internal/typegen/testdata/python.coverage.strict.appwrite_types.py create mode 100644 templates/cli/internal/typegen/testdata/python.loose.appwrite_types.py create mode 100644 templates/cli/internal/typegen/testdata/python.strict.appwrite_types.py diff --git a/.github/workflows/validation.yml b/.github/workflows/validation.yml index 0a8e73ae3d..719c5c9328 100644 --- a/.github/workflows/validation.yml +++ b/.github/workflows/validation.yml @@ -398,6 +398,21 @@ jobs: ./appwrite --help | grep -q "GET STARTED" SHELL=bash ./appwrite completion bash >/dev/null + - name: Check generated Python types + if: matrix.sdk == 'cli' + working-directory: examples/cli/internal/typegen/testdata + run: | + environment="$RUNNER_TEMP/typegen-python" + python3 -m venv "$environment" + "$environment/bin/pip" install --quiet black==26.10.1 mypy==2.4.0 pydantic==2.14.1 + "$environment/bin/black" --check python.*.py + for baseline in python.*.py; do + directory="$environment/baselines/$baseline" + mkdir -p "$directory" + cp "$baseline" "$directory/appwrite_types.py" + (cd "$directory" && "$environment/bin/mypy" --strict appwrite_types.py && "$environment/bin/python" -W error -c 'import appwrite_types') + done + # The CLI ships as a native binary behind an npm launcher, so it has no # dist/ entry points to check -- what can break is the launcher itself # and the two scripts that assemble the platform packages. diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index ea2dbc4de4..66f38d0f8c 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -1787,6 +1787,36 @@ public function getFiles(): array 'destination' => 'internal/typegen/testdata/cs.strict.BooksZines.cs', 'template' => 'cli/internal/typegen/testdata/cs.strict.BooksZines.cs', ], + [ + 'scope' => 'copy', + 'destination' => 'internal/typegen/python.go', + 'template' => 'cli/internal/typegen/python.go', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/typegen/testdata/python.collections.json', + 'template' => 'cli/internal/typegen/testdata/python.collections.json', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/typegen/testdata/python.loose.appwrite_types.py', + 'template' => 'cli/internal/typegen/testdata/python.loose.appwrite_types.py', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/typegen/testdata/python.strict.appwrite_types.py', + 'template' => 'cli/internal/typegen/testdata/python.strict.appwrite_types.py', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/typegen/testdata/python.coverage.loose.appwrite_types.py', + 'template' => 'cli/internal/typegen/testdata/python.coverage.loose.appwrite_types.py', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/typegen/testdata/python.coverage.strict.appwrite_types.py', + 'template' => 'cli/internal/typegen/testdata/python.coverage.strict.appwrite_types.py', + ], [ 'scope' => 'default', 'destination' => 'internal/generator/generator.go', diff --git a/templates/cli/internal/cmd/typegen_commands_test.go b/templates/cli/internal/cmd/typegen_commands_test.go index 15ca642abe..cf67e3b3ca 100644 --- a/templates/cli/internal/cmd/typegen_commands_test.go +++ b/templates/cli/internal/cmd/typegen_commands_test.go @@ -1,6 +1,8 @@ package cmd import ( + "os" + "path/filepath" "sort" "strings" "testing" @@ -92,8 +94,8 @@ func TestTypesRequiresAnOutputDirectory(t *testing.T) { // A rejected `-l` value has to say what the accepted ones are: they are short // names, several of which are not the language's usual spelling, and the reject -// is also where DETECTION lands -- a Python or Ruby project has no emitter at -// all, so no spelling of it would have worked. +// is also where DETECTION lands -- a Ruby project has no emitter at all, so no +// spelling of it would have worked. func TestUnsupportedLanguageListsTheSupportedOnes(t *testing.T) { message := unsupportedLanguage("typescript").Error() @@ -119,23 +121,41 @@ func TestUnsupportedLanguageListsTheSupportedOnes(t *testing.T) { // directory holding a .csproj, so `appwrite types .` in a C# project is rejected // by a value the CLI chose itself. func TestDetectedLanguagesWithoutAnEmitterAreExplained(t *testing.T) { - for requested, want := range map[string]string{"dotnet": "cs", "javascript": "js"} { + for requested, want := range map[string]string{"dotnet": "cs", "javascript": "js", "py": "python"} { message := unsupportedLanguage(requested).Error() if !strings.Contains(message, "did you mean `"+want+"`") { t.Errorf("%q was not pointed at %q:\n %s", requested, want, message) } } - // Python and Ruby are detectable and genuinely unsupported, so there is - // nothing to suggest -- only the list. - for _, requested := range []string{"python", "ruby"} { - message := unsupportedLanguage(requested).Error() - if strings.Contains(message, "did you mean") { - t.Errorf("invented a spelling for %q, which has no emitter:\n %s", requested, message) - } - if !strings.Contains(message, "The supported languages are") { - t.Errorf("left %q with no way forward:\n %s", requested, message) - } + // Ruby is detectable and genuinely unsupported, so there is nothing to + // suggest -- only the list. + message := unsupportedLanguage("ruby").Error() + if strings.Contains(message, "did you mean") { + t.Errorf("invented a spelling for ruby, which has no emitter:\n %s", message) + } + if !strings.Contains(message, "The supported languages are") { + t.Errorf("left ruby with no way forward:\n %s", message) + } +} + +func TestTypesDetectsPythonProjects(t *testing.T) { + for _, marker := range []string{"pyproject.toml", "requirements.txt", "Pipfile", "setup.py"} { + t.Run(marker, func(t *testing.T) { + directory := t.TempDir() + if err := os.WriteFile(filepath.Join(directory, marker), nil, 0o600); err != nil { + t.Fatal(err) + } + t.Chdir(directory) + + name, emitter, err := resolveTypeLanguage(NewRootCommand(), "auto") + if err != nil { + t.Fatal(err) + } + if name != "python" || emitter.FileName(nil) != "appwrite_types.py" { + t.Errorf("resolved %q writing %q, want python writing appwrite_types.py", name, emitter.FileName(nil)) + } + }) } } diff --git a/templates/cli/internal/cmd/types.go b/templates/cli/internal/cmd/types.go index 8208be4cfa..d3f21b93e4 100644 --- a/templates/cli/internal/cmd/types.go +++ b/templates/cli/internal/cmd/types.go @@ -40,11 +40,12 @@ var typeLanguages = map[string]func(directory string) typegen.Language{ "java": func(string) typegen.Language { return typegen.Java{} }, "dart": func(string) typegen.Language { return typegen.Dart{} }, "cs": func(string) typegen.Language { return typegen.CSharp{} }, + "python": func(string) typegen.Language { return typegen.Python{} }, } // typeLanguageChoices is the accepted `-l` values, in the order the help lists // them. -var typeLanguageChoices = []string{"auto", "ts", "js", "php", "kotlin", "swift", "java", "dart", "cs"} +var typeLanguageChoices = []string{"auto", "ts", "js", "php", "kotlin", "swift", "java", "dart", "cs", "python"} // typeLanguageAliases maps a name a user is likely to reach for to the value // the CLI accepts. @@ -59,6 +60,8 @@ var typeLanguageAliases = map[string]string{ "javascript": "js", "node": "js", "nodejs": "js", + "py": "python", + "python3": "python", "typescript": "ts", } @@ -84,7 +87,7 @@ func typeLanguageNames() string { // a suggestion rather than only a list. // // Detection reaches this too, and there the list is the only thing that says -// what to do next: a Python or Ruby project resolves to a language the CLI has +// what to do next: a Ruby project resolves to a language the CLI has // no emitter for, so no spelling of it would have worked. func unsupportedLanguage(requested string) error { if suggestion, ok := typeLanguageAliases[strings.ToLower(requested)]; ok { diff --git a/templates/cli/internal/typegen/language.go b/templates/cli/internal/typegen/language.go index c4af2bba7e..b1c25e97a7 100644 --- a/templates/cli/internal/typegen/language.go +++ b/templates/cli/internal/typegen/language.go @@ -203,7 +203,7 @@ var languageMarkers = []struct { {"ts", []string{"tsconfig.json", "deno.json"}}, {"js", []string{"package.json"}}, {"php", []string{"composer.json"}}, - {"python", []string{"requirements.txt", "Pipfile", "pyproject.toml"}}, + {"python", []string{"requirements.txt", "Pipfile", "pyproject.toml", "setup.py"}}, {"ruby", []string{"Gemfile", "Rakefile"}}, {"kotlin", []string{"build.gradle.kts"}}, {"java", []string{"build.gradle", "pom.xml"}}, diff --git a/templates/cli/internal/typegen/language_test.go b/templates/cli/internal/typegen/language_test.go index e6e72b8c07..09d1dbe553 100644 --- a/templates/cli/internal/typegen/language_test.go +++ b/templates/cli/internal/typegen/language_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" ) @@ -21,7 +22,13 @@ const baselineInvocation = "types ./types" func loadCollections(t *testing.T) []Collection { t.Helper() - payload, err := os.ReadFile(filepath.Join("testdata", "collections.json")) + return loadFixture(t, "collections.json") +} + +func loadFixture(t *testing.T, name string) []Collection { + t.Helper() + + payload, err := os.ReadFile(filepath.Join("testdata", name)) if err != nil { t.Fatalf("reading fixture: %v", err) } @@ -52,7 +59,13 @@ func loadBaseline(t *testing.T, name string) string { func assertRendersBaseline(t *testing.T, language Language, current *Collection, strict bool, baseline string) { t.Helper() - rendered, err := language.Render(loadCollections(t), current, strict, baselineInvocation) + assertRendersFixture(t, language, loadCollections(t), current, strict, baseline) +} + +func assertRendersFixture(t *testing.T, language Language, collections []Collection, current *Collection, strict bool, baseline string) { + t.Helper() + + rendered, err := language.Render(collections, current, strict, baselineInvocation) if err != nil { t.Fatalf("render: %v", err) } @@ -167,6 +180,72 @@ func TestCSharpMatchesBaseline(t *testing.T) { assertMultiFileBaselines(t, CSharp{}, "cs") } +func TestPythonMatchesBaseline(t *testing.T) { + for _, fixture := range []struct{ prefix, file string }{ + {"python", "collections.json"}, + {"python.coverage", "python.collections.json"}, + } { + for _, mode := range []string{"loose", "strict"} { + t.Run(fixture.prefix+"."+mode, func(t *testing.T) { + baseline := fixture.prefix + "." + mode + "." + (Python{}).FileName(nil) + assertRendersFixture(t, Python{}, loadFixture(t, fixture.file), nil, mode == "strict", baseline) + }) + } + } +} + +func TestPythonFieldNamesAreUsableIdentifiers(t *testing.T) { + attributes := []Attribute{ + {Key: "fullName"}, {Key: "full_name"}, {Key: "class"}, {Key: "_hidden"}, + {Key: "9lives"}, {Key: "!!!"}, {Key: "model_config"}, {Key: "Items"}, {Key: "published.at"}, + } + classes := pythonClasses{order: []string{"Items"}} + + for strict, want := range map[bool][]string{ + false: {"fullName", "full_name", "class_", "hidden", "field_9lives", "field", "model_config_", "Items_", "published_at"}, + true: {"full_name", "full_name_1", "class_", "hidden", "field_9lives", "field", "model_config_", "items", "published_at"}, + } { + got := pythonFieldNames(attributes, strict, classes) + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Errorf("strict=%v:\n want %v\n got %v", strict, want, got) + } + } +} + +func TestPythonClassNamesNeverCollide(t *testing.T) { + classes := newPythonClasses([]Collection{ + {ID: "a", Name: "Orders Status"}, + {ID: "b", Name: "Orders", Attributes: []Attribute{{Key: "status", Format: AttributeTypeEnum}}}, + {ID: "c", Name: "orders"}, + {ID: "d", Name: "2024 sales"}, + }) + + want := map[string]string{"a": "OrdersStatus", "b": "Orders", "c": "Orders1", "d": "Model2024Sales"} + for id, name := range want { + if classes.models[id] != name { + t.Errorf("model %s = %q, want %q", id, classes.models[id], name) + } + } + if enum := classes.enums[pythonEnumKey("b", "status")]; enum != "OrdersStatus1" { + t.Errorf("enum = %q, want OrdersStatus1", enum) + } +} + +func TestPythonStringMatchesBlackQuoting(t *testing.T) { + for value, want := range map[string]string{ + "plain": `"plain"`, + `say "hi"`: `'say "hi"'`, + "it's": `"it's"`, + `it's "x"`: `'it\'s "x"'`, + `it's "x`: `"it's \"x"`, + "a\\b\nc\x01d": `"a\\b\nc\x01d"`, + } { + if got := pythonString(value); got != want { + t.Errorf("pythonString(%q) = %s, want %s", value, got, want) + } + } +} + // TestPHPArrayNeverGainsNullBranch pins the early return in PHP.Type: an // optional array attribute is `array`, not `array|null`, because the array // check precedes the nullable suffix. diff --git a/templates/cli/internal/typegen/python.go b/templates/cli/internal/typegen/python.go new file mode 100644 index 0000000000..edd957aa18 --- /dev/null +++ b/templates/cli/internal/typegen/python.go @@ -0,0 +1,492 @@ +package typegen + +import ( + "fmt" + "regexp" + "slices" + "strings" +) + +type Python struct{} + +const pythonLineLength = 88 + +const pythonIndent = " " + +var pythonIdentifier = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`) + +var pythonReserved = []string{ + "False", "None", "True", "and", "as", "assert", "async", "await", "break", + "class", "continue", "def", "del", "elif", "else", "except", "finally", "for", + "from", "global", "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", + "pass", "raise", "return", "try", "while", "with", "yield", + "construct", "copy", "dict", "from_orm", "json", "model_computed_fields", + "model_config", "model_construct", "model_copy", "model_dump", "model_dump_json", + "model_extra", "model_fields", "model_fields_set", "model_json_schema", + "model_parametrized_name", "model_post_init", "model_rebuild", "model_validate", + "model_validate_json", "model_validate_strings", "parse_file", "parse_obj", + "parse_raw", "schema", "schema_json", "update_forward_refs", "validate", + "annotations", "bool", "float", "int", "str", "BaseModel", "ConfigDict", + "Enum", "Field", "List", "Optional", "Union", +} + +type pythonClasses struct { + models map[string]string + enums map[string]string + order []string +} + +func pythonEnumKey(collectionID, attributeKey string) string { + return collectionID + "\x00" + attributeKey +} + +func pythonClassName(value string) string { + name := ToPascalCase(value) + if name == "" || leadingDigit.MatchString(name) { + name = "Model" + name + } + + return name +} + +func newPythonClasses(collections []Collection) pythonClasses { + classes := pythonClasses{models: map[string]string{}, enums: map[string]string{}} + used := map[string]bool{} + + claim := func(name string) string { + candidate := name + for suffix := 1; used[candidate] || slices.Contains(pythonReserved, candidate); suffix++ { + candidate = fmt.Sprintf("%s%d", name, suffix) + } + used[candidate] = true + classes.order = append(classes.order, candidate) + + return candidate + } + + for _, collection := range collections { + classes.models[collection.ID] = claim(pythonClassName(collection.Name)) + } + for _, collection := range collections { + for _, attribute := range collection.Attributes { + if attribute.Format != AttributeTypeEnum { + continue + } + + name := pythonClassName(collection.Name) + ToPascalCase(attribute.Key) + classes.enums[pythonEnumKey(collection.ID, attribute.Key)] = claim(name) + } + } + + return classes +} + +func pythonCollectionID(collections []Collection, collectionName string) string { + for _, collection := range collections { + if collection.Name == collectionName { + return collection.ID + } + } + + return collectionName +} + +func (p Python) Type(attribute Attribute, collections []Collection, collectionName string) (string, error) { + classes := newPythonClasses(collections) + + return p.annotation(attribute, collections, pythonCollectionID(collections, collectionName), classes) +} + +func (p Python) annotation(attribute Attribute, collections []Collection, collectionID string, classes pythonClasses) (string, error) { + var literal string + + switch attribute.Type { + case AttributeTypeString, AttributeTypeText, AttributeTypeVarchar, + AttributeTypeMediumText, AttributeTypeLongText, AttributeTypeDateTime: + literal = "str" + if attribute.Format == AttributeTypeEnum { + literal = classes.enums[pythonEnumKey(collectionID, attribute.Key)] + } + case AttributeTypeInteger, AttributeTypeBigInt: + literal = "int" + case AttributeTypeFloat: + literal = "float" + case AttributeTypeBoolean: + literal = "bool" + case AttributeTypeRelationship: + related, err := RelatedCollection(attribute, collections) + if err != nil { + return "", err + } + + literal = "Union[" + classes.models[related.ID] + ", str]" + if IsRelationshipList(attribute) { + literal = "List[" + literal + "]" + } + case AttributeTypePoint: + literal = "List[float]" + case AttributeTypeLineString: + literal = "List[List[float]]" + case AttributeTypePolygon: + literal = "List[List[List[float]]]" + default: + return "", &UnknownAttributeTypeError{Type: attribute.Type} + } + + if attribute.Array { + literal = "List[" + literal + "]" + } + + return literal, nil +} + +func (p Python) SingleFile() bool { return true } + +func (p Python) FileName(*Collection) string { return "appwrite_types.py" } + +func pythonFieldNames(attributes []Attribute, strict bool, classes pythonClasses) []string { + used := map[string]bool{} + names := make([]string, len(attributes)) + + for index, attribute := range attributes { + name := attribute.Key + if strict || !pythonIdentifier.MatchString(name) { + name = ToSnakeCase(strings.ReplaceAll(name, ".", "_")) + } + if name == "" || leadingDigit.MatchString(name) { + name = strings.TrimSuffix("field_"+name, "_") + } + if slices.Contains(pythonReserved, name) || slices.Contains(classes.order, name) { + name += "_" + } + + candidate := name + for suffix := 1; used[candidate]; suffix++ { + candidate = fmt.Sprintf("%s_%d", name, suffix) + } + used[candidate] = true + names[index] = candidate + } + + return names +} + +func pythonString(value string) string { + quote := `"` + if strings.Count(value, `"`) > strings.Count(value, `'`) { + quote = `'` + } + + var out strings.Builder + out.WriteString(quote) + for _, character := range value { + switch { + case character == '\\': + out.WriteString(`\\`) + case string(character) == quote: + out.WriteString(`\` + quote) + case character == '\n': + out.WriteString(`\n`) + case character == '\r': + out.WriteString(`\r`) + case character == '\t': + out.WriteString(`\t`) + case character < 0x20 || character == 0x7f: + fmt.Fprintf(&out, `\x%02x`, character) + default: + out.WriteRune(character) + } + } + out.WriteString(quote) + + return out.String() +} + +func pythonFits(line string) bool { + return len([]rune(line)) <= pythonLineLength +} + +func pythonBracketed(out *strings.Builder, indent, head, open string, elements []string, close, tail string) { + single := indent + head + open + strings.Join(elements, ", ") + close + tail + if pythonFits(single) { + out.WriteString(single + "\n") + + return + } + + inner := indent + pythonIndent + body := inner + strings.Join(elements, ", ") + if len(elements) == 1 || pythonFits(body) { + fmt.Fprintf(out, "%s%s%s\n%s\n%s%s%s\n", indent, head, open, body, indent, close, tail) + + return + } + + fmt.Fprintf(out, "%s%s%s\n", indent, head, open) + for _, element := range elements { + fmt.Fprintf(out, "%s%s,\n", inner, element) + } + fmt.Fprintf(out, "%s%s%s\n", indent, close, tail) +} + +func pythonField(out *strings.Builder, name, annotation string, required bool, alias string) { + if !required { + annotation = "Optional[" + annotation + "]" + } + + var arguments []string + if !required && alias != "" { + arguments = append(arguments, "default=None") + } + if alias != "" { + arguments = append(arguments, "alias="+pythonString(alias)) + } + + if len(arguments) == 0 { + tail := "" + if !required { + tail = " = None" + } + line := pythonIndent + name + ": " + annotation + " = (" + if !required && !pythonFits(line[:len(line)-1]+"None") && pythonFits(line) { + out.WriteString(line + "\n" + pythonIndent + pythonIndent + "None\n" + pythonIndent + ")\n") + + return + } + pythonAnnotation(out, name, annotation, tail, nil) + + return + } + + head := pythonIndent + name + ": " + annotation + " = " + if pythonFits(head+"Field(") || pythonFits(head+"Field("+strings.Join(arguments, ", ")+")") { + pythonBracketed(out, pythonIndent, name+": "+annotation+" = Field", "(", arguments, ")", "") + + return + } + + if pythonFits(head + "(") { + out.WriteString(head + "(\n") + pythonBracketed(out, pythonIndent+pythonIndent, "Field", "(", arguments, ")", "") + out.WriteString(pythonIndent + ")\n") + + return + } + + pythonAnnotation(out, name, annotation, "", arguments) +} + +func pythonTypeElements(annotation string) []string { + var elements []string + depth, start := 0, 0 + for index, character := range annotation { + switch character { + case '[': + depth++ + case ']': + depth-- + case ',': + if depth == 0 { + elements = append(elements, annotation[start:index]) + start = index + 2 + } + } + } + + return append(elements, annotation[start:]) +} + +func pythonType(out *strings.Builder, indent, annotation string) { + if pythonFits(indent + annotation) { + out.WriteString(indent + annotation + "\n") + + return + } + + if elements := pythonTypeElements(annotation); len(elements) > 1 { + for _, element := range elements { + pythonType(out, indent, element+",") + } + + return + } + + bracket := strings.Index(annotation, "[") + if bracket < 0 { + out.WriteString(indent + annotation + "\n") + + return + } + + suffix := annotation[strings.LastIndex(annotation, "]")+1:] + out.WriteString(indent + annotation[:bracket+1] + "\n") + pythonType(out, indent+pythonIndent, annotation[bracket+1:strings.LastIndex(annotation, "]")]) + out.WriteString(indent + "]" + suffix + "\n") +} + +func pythonArgumentsFit(arguments []string) bool { + for _, argument := range arguments { + if !pythonFits(pythonIndent + pythonIndent + argument + ",") { + return false + } + } + + return true +} + +func pythonAnnotation(out *strings.Builder, name, annotation, tail string, arguments []string) { + line := pythonIndent + name + ": " + annotation + tail + if arguments == nil && pythonFits(line) { + out.WriteString(line + "\n") + + return + } + + open, close, inner := "(", ")", annotation + bracket := strings.Index(annotation, "[") + wrappable := pythonFits(pythonIndent + pythonIndent + annotation) + switch { + case bracket >= 0 && (pythonFits(pythonIndent+name+": "+annotation[:bracket+1]) || !wrappable): + open, close, inner = annotation[:bracket+1], "]", annotation[bracket+1:len(annotation)-1] + case bracket >= 0: + if tail != "" { + tail = " = (None)" + } + case !wrappable && arguments == nil: + out.WriteString(line + "\n") + + return + case !wrappable && !pythonFits(pythonIndent+") = Field("+strings.Join(arguments, ", ")+")"): + pythonBracketed(out, pythonIndent, name+": "+annotation+" = Field", "(", arguments, ")", "") + + return + } + + out.WriteString(pythonIndent + name + ": " + open + "\n") + pythonType(out, pythonIndent+pythonIndent, inner) + if arguments == nil { + out.WriteString(pythonIndent + close + tail + "\n") + + return + } + + pythonBracketed(out, pythonIndent, close+" = Field", "(", arguments, ")", "") +} + +func pythonEnumMember(out *strings.Builder, key, value string) { + literal := pythonString(value) + line := pythonIndent + key + " = " + literal + if pythonFits(line) || !pythonFits(pythonIndent+pythonIndent+literal) { + out.WriteString(line + "\n") + + return + } + + pythonBracketed(out, pythonIndent, key+" = ", "(", []string{literal}, ")", "") +} + +func (p Python) Render(collections []Collection, _ *Collection, strict bool, invocation string) (string, error) { + classes := newPythonClasses(collections) + + type field struct { + name, annotation, alias string + required bool + } + models := make([][]field, len(collections)) + + usesList, usesOptional, usesUnion, usesField, usesEnum := false, false, false, false, false + for index, collection := range collections { + names := pythonFieldNames(collection.Attributes, strict, classes) + for position, attribute := range collection.Attributes { + annotation, err := p.annotation(attribute, collections, collection.ID, classes) + if err != nil { + return "", err + } + + alias := "" + if names[position] != attribute.Key { + alias = attribute.Key + } + + usesList = usesList || strings.Contains(annotation, "List[") + usesOptional = usesOptional || !attribute.Required + usesUnion = usesUnion || strings.Contains(annotation, "Union[") + usesField = usesField || alias != "" + usesEnum = usesEnum || attribute.Format == AttributeTypeEnum + + models[index] = append(models[index], field{ + name: names[position], + annotation: annotation, + alias: alias, + required: attribute.Required, + }) + } + } + + var out strings.Builder + + out.WriteString("# This file is auto-generated by the Appwrite CLI.\n") + fmt.Fprintf(&out, "# You can regenerate it by running `appwrite %s`.\n\n", invocation) + out.WriteString("from __future__ import annotations\n\n") + + if usesEnum { + out.WriteString("from enum import Enum\n") + } + var typing []string + if usesList { + typing = append(typing, "List") + } + if usesOptional { + typing = append(typing, "Optional") + } + if usesUnion { + typing = append(typing, "Union") + } + if len(typing) > 0 { + fmt.Fprintf(&out, "from typing import %s\n", strings.Join(typing, ", ")) + } + if usesEnum || len(typing) > 0 { + out.WriteString("\n") + } + + pydantic := "BaseModel, ConfigDict" + if usesField { + pydantic += ", Field" + } + fmt.Fprintf(&out, "from pydantic import %s\n", pydantic) + + for _, collection := range collections { + for _, attribute := range collection.Attributes { + if attribute.Format != AttributeTypeEnum { + continue + } + + out.WriteString("\n\n") + pythonBracketed(&out, "", "class "+classes.enums[pythonEnumKey(collection.ID, attribute.Key)], + "(", []string{"str", "Enum"}, ")", ":") + + members := generateEnumMembers(attribute.Elements) + if len(members) == 0 { + out.WriteString(pythonIndent + "pass\n") + } + for _, member := range members { + pythonEnumMember(&out, member.Key, member.Value) + } + } + } + + for index, collection := range collections { + out.WriteString("\n\n") + pythonBracketed(&out, "", "class "+classes.models[collection.ID], "(", []string{"BaseModel"}, ")", ":") + out.WriteString(pythonIndent + `model_config = ConfigDict(extra="allow", populate_by_name=True)` + "\n") + + if len(models[index]) > 0 { + out.WriteString("\n") + } + for _, field := range models[index] { + pythonField(&out, field.name, field.annotation, field.required, field.alias) + } + } + + return out.String(), nil +} diff --git a/templates/cli/internal/typegen/testdata/python.collections.json b/templates/cli/internal/typegen/testdata/python.collections.json new file mode 100644 index 0000000000..19bdef903c --- /dev/null +++ b/templates/cli/internal/typegen/testdata/python.collections.json @@ -0,0 +1,135 @@ +[ + { + "$id": "items", + "databaseId": "main", + "name": "Inventory Items", + "attributes": [ + { "key": "sku", "type": "varchar", "required": true, "size": 32 }, + { "key": "summary", "type": "mediumtext", "required": false }, + { "key": "body", "type": "longtext", "required": false, "default": null }, + { "key": "contact", "type": "string", "format": "email", "required": true, "size": 254 }, + { "key": "homepage", "type": "string", "format": "url", "required": false, "size": 2000 }, + { "key": "origin", "type": "string", "format": "ip", "required": false, "size": 39 }, + { "key": "stock", "type": "bigint", "required": true }, + { "key": "counts", "type": "integer", "required": true, "array": true }, + { "key": "prices", "type": "double", "required": false, "array": true }, + { "key": "flags", "type": "boolean", "required": false, "array": true }, + { "key": "class", "type": "string", "required": true, "size": 8 }, + { "key": "json", "type": "string", "required": false, "size": 8 }, + { "key": "model_config", "type": "string", "required": false, "size": 8 }, + { "key": "str", "type": "string", "required": false, "size": 8 }, + { "key": "_hidden", "type": "string", "required": false, "size": 8 }, + { "key": "fullName", "type": "string", "required": true, "size": 64 }, + { "key": "full_name", "type": "string", "required": false, "size": 64 }, + { "key": "9lives", "type": "integer", "required": false }, + { "key": "!!!", "type": "string", "required": false, "size": 8 }, + { + "key": "a rather long column key that needs wrapping too", + "type": "double", + "required": false, + "array": true + }, + { + "key": "a_very_long_polygon_column_name_that_overflows_the_line_limit", + "type": "polygon", + "required": false + }, + { + "key": "status", + "type": "string", + "format": "enum", + "required": false, + "size": 64, + "elements": [ + "in stock", + "out of \"stock\"", + "back\\slash", + "it's", + "line\nbreak", + "an enum element long enough that its member no longer fits on a line" + ] + }, + { + "key": "tiers", + "type": "string", + "format": "enum", + "required": true, + "array": true, + "size": 8, + "elements": ["gold", "silver"] + }, + { + "key": "warehouses", + "type": "relationship", + "required": false, + "relatedTable": "warehouses", + "relationType": "manyToMany", + "side": "parent" + }, + { + "key": "primary warehouse", + "type": "relationship", + "required": false, + "relatedTable": "warehouses", + "relationType": "manyToOne", + "side": "parent" + } + ] + }, + { + "$id": "warehouses", + "databaseId": "main", + "name": "Warehouses", + "attributes": [ + { "key": "name", "type": "string", "required": true, "size": 64 }, + { "key": "location", "type": "point", "required": true }, + { "key": "route", "type": "linestring", "required": false }, + { "key": "opened.at", "type": "datetime", "required": false }, + { + "key": "status", + "type": "string", + "format": "enum", + "required": true, + "size": 8, + "elements": ["open", "closed"] + }, + { + "key": "items", + "type": "relationship", + "required": false, + "relatedTable": "items", + "relationType": "manyToMany", + "side": "child" + } + ] + }, + { + "$id": "warehouse-status", + "databaseId": "main", + "name": "Warehouses Status", + "attributes": [ + { + "key": "warehouse", + "type": "relationship", + "required": true, + "relatedTable": "warehouses", + "relationType": "oneToOne", + "side": "parent" + } + ] + }, + { + "$id": "empty", + "databaseId": "main", + "name": "Empty", + "attributes": [] + }, + { + "$id": "long", + "databaseId": "main", + "name": "A table whose name is long enough that the class statement for its model cannot fit on one line", + "attributes": [ + { "key": "value", "type": "string", "required": true, "size": 8 } + ] + } +] diff --git a/templates/cli/internal/typegen/testdata/python.coverage.loose.appwrite_types.py b/templates/cli/internal/typegen/testdata/python.coverage.loose.appwrite_types.py new file mode 100644 index 0000000000..a7ad90d6e9 --- /dev/null +++ b/templates/cli/internal/typegen/testdata/python.coverage.loose.appwrite_types.py @@ -0,0 +1,95 @@ +# This file is auto-generated by the Appwrite CLI. +# You can regenerate it by running `appwrite types ./types`. + +from __future__ import annotations + +from enum import Enum +from typing import List, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field + + +class InventoryItemsStatus(str, Enum): + IN_STOCK = "in stock" + OUT_OF_STOCK = 'out of "stock"' + BACKSLASH = "back\\slash" + ITS = "it's" + LINE_BREAK = "line\nbreak" + AN_ENUM_ELEMENT_LONG_ENOUGH_THAT_ITS_MEMBER_NO_LONGER_FITS_ON_A_LINE = ( + "an enum element long enough that its member no longer fits on a line" + ) + + +class InventoryItemsTiers(str, Enum): + GOLD = "gold" + SILVER = "silver" + + +class WarehousesStatus1(str, Enum): + OPEN = "open" + CLOSED = "closed" + + +class InventoryItems(BaseModel): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + sku: str + summary: Optional[str] = None + body: Optional[str] = None + contact: str + homepage: Optional[str] = None + origin: Optional[str] = None + stock: int + counts: List[int] + prices: Optional[List[float]] = None + flags: Optional[List[bool]] = None + class_: str = Field(alias="class") + json_: Optional[str] = Field(default=None, alias="json") + model_config_: Optional[str] = Field(default=None, alias="model_config") + str_: Optional[str] = Field(default=None, alias="str") + hidden: Optional[str] = Field(default=None, alias="_hidden") + fullName: str + full_name: Optional[str] = None + field_9lives: Optional[int] = Field(default=None, alias="9lives") + field: Optional[str] = Field(default=None, alias="!!!") + a_rather_long_column_key_that_needs_wrapping_too: Optional[List[float]] = Field( + default=None, alias="a rather long column key that needs wrapping too" + ) + a_very_long_polygon_column_name_that_overflows_the_line_limit: Optional[ + List[List[List[float]]] + ] = None + status: Optional[InventoryItemsStatus] = None + tiers: List[InventoryItemsTiers] + warehouses: Optional[List[Union[Warehouses, str]]] = None + primary_warehouse: Optional[Union[Warehouses, str]] = Field( + default=None, alias="primary warehouse" + ) + + +class Warehouses(BaseModel): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + name: str + location: List[float] + route: Optional[List[List[float]]] = None + opened_at: Optional[str] = Field(default=None, alias="opened.at") + status: WarehousesStatus1 + items: Optional[List[Union[InventoryItems, str]]] = None + + +class WarehousesStatus(BaseModel): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + warehouse: Union[Warehouses, str] + + +class Empty(BaseModel): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + +class ATableWhoseNameIsLongEnoughThatTheClassStatementForItsModelCannotFitOnOneLine( + BaseModel +): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + value: str diff --git a/templates/cli/internal/typegen/testdata/python.coverage.strict.appwrite_types.py b/templates/cli/internal/typegen/testdata/python.coverage.strict.appwrite_types.py new file mode 100644 index 0000000000..28ae934405 --- /dev/null +++ b/templates/cli/internal/typegen/testdata/python.coverage.strict.appwrite_types.py @@ -0,0 +1,95 @@ +# This file is auto-generated by the Appwrite CLI. +# You can regenerate it by running `appwrite types ./types`. + +from __future__ import annotations + +from enum import Enum +from typing import List, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field + + +class InventoryItemsStatus(str, Enum): + IN_STOCK = "in stock" + OUT_OF_STOCK = 'out of "stock"' + BACKSLASH = "back\\slash" + ITS = "it's" + LINE_BREAK = "line\nbreak" + AN_ENUM_ELEMENT_LONG_ENOUGH_THAT_ITS_MEMBER_NO_LONGER_FITS_ON_A_LINE = ( + "an enum element long enough that its member no longer fits on a line" + ) + + +class InventoryItemsTiers(str, Enum): + GOLD = "gold" + SILVER = "silver" + + +class WarehousesStatus1(str, Enum): + OPEN = "open" + CLOSED = "closed" + + +class InventoryItems(BaseModel): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + sku: str + summary: Optional[str] = None + body: Optional[str] = None + contact: str + homepage: Optional[str] = None + origin: Optional[str] = None + stock: int + counts: List[int] + prices: Optional[List[float]] = None + flags: Optional[List[bool]] = None + class_: str = Field(alias="class") + json_: Optional[str] = Field(default=None, alias="json") + model_config_: Optional[str] = Field(default=None, alias="model_config") + str_: Optional[str] = Field(default=None, alias="str") + hidden: Optional[str] = Field(default=None, alias="_hidden") + full_name: str = Field(alias="fullName") + full_name_1: Optional[str] = Field(default=None, alias="full_name") + field_9lives: Optional[int] = Field(default=None, alias="9lives") + field: Optional[str] = Field(default=None, alias="!!!") + a_rather_long_column_key_that_needs_wrapping_too: Optional[List[float]] = Field( + default=None, alias="a rather long column key that needs wrapping too" + ) + a_very_long_polygon_column_name_that_overflows_the_line_limit: Optional[ + List[List[List[float]]] + ] = None + status: Optional[InventoryItemsStatus] = None + tiers: List[InventoryItemsTiers] + warehouses: Optional[List[Union[Warehouses, str]]] = None + primary_warehouse: Optional[Union[Warehouses, str]] = Field( + default=None, alias="primary warehouse" + ) + + +class Warehouses(BaseModel): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + name: str + location: List[float] + route: Optional[List[List[float]]] = None + opened_at: Optional[str] = Field(default=None, alias="opened.at") + status: WarehousesStatus1 + items: Optional[List[Union[InventoryItems, str]]] = None + + +class WarehousesStatus(BaseModel): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + warehouse: Union[Warehouses, str] + + +class Empty(BaseModel): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + +class ATableWhoseNameIsLongEnoughThatTheClassStatementForItsModelCannotFitOnOneLine( + BaseModel +): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + value: str diff --git a/templates/cli/internal/typegen/testdata/python.loose.appwrite_types.py b/templates/cli/internal/typegen/testdata/python.loose.appwrite_types.py new file mode 100644 index 0000000000..e749f7a7f1 --- /dev/null +++ b/templates/cli/internal/typegen/testdata/python.loose.appwrite_types.py @@ -0,0 +1,51 @@ +# This file is auto-generated by the Appwrite CLI. +# You can regenerate it by running `appwrite types ./types`. + +from __future__ import annotations + +from enum import Enum +from typing import List, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field + + +class AuthorsStatus(str, Enum): + ACTIVE = "active" + IN_ACTIVE = "in-active" + IN_ACTIVE_1 = "in active" + _9LIVES = "9lives" + IN_ACTIVE_2 = "in_active" + + +class BooksZinesKind(str, Enum): + HARDCOVER = "hard" + SOFTCOVER = 'soft"cover"' + _ = "" + + +class Authors(BaseModel): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str = Field(alias="$id") + full_name: str = Field(alias="full name") + bio: Optional[str] = None + age: Optional[int] = None + rating: float + verified: Optional[bool] = None + joined_at: str = Field(alias="joined-at") + tags: Optional[List[str]] = None + status: AuthorsStatus + books: Optional[List[Union[BooksZines, str]]] = None + home: Optional[List[float]] = None + route: List[List[float]] + region: List[List[List[float]]] + weird_key: str = Field(alias="weird key!") + + +class BooksZines(BaseModel): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + title: str + author: List[Union[Authors, str]] + rdflag: str = Field(alias="r&d") + kind: BooksZinesKind diff --git a/templates/cli/internal/typegen/testdata/python.strict.appwrite_types.py b/templates/cli/internal/typegen/testdata/python.strict.appwrite_types.py new file mode 100644 index 0000000000..e749f7a7f1 --- /dev/null +++ b/templates/cli/internal/typegen/testdata/python.strict.appwrite_types.py @@ -0,0 +1,51 @@ +# This file is auto-generated by the Appwrite CLI. +# You can regenerate it by running `appwrite types ./types`. + +from __future__ import annotations + +from enum import Enum +from typing import List, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field + + +class AuthorsStatus(str, Enum): + ACTIVE = "active" + IN_ACTIVE = "in-active" + IN_ACTIVE_1 = "in active" + _9LIVES = "9lives" + IN_ACTIVE_2 = "in_active" + + +class BooksZinesKind(str, Enum): + HARDCOVER = "hard" + SOFTCOVER = 'soft"cover"' + _ = "" + + +class Authors(BaseModel): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str = Field(alias="$id") + full_name: str = Field(alias="full name") + bio: Optional[str] = None + age: Optional[int] = None + rating: float + verified: Optional[bool] = None + joined_at: str = Field(alias="joined-at") + tags: Optional[List[str]] = None + status: AuthorsStatus + books: Optional[List[Union[BooksZines, str]]] = None + home: Optional[List[float]] = None + route: List[List[float]] + region: List[List[List[float]]] + weird_key: str = Field(alias="weird key!") + + +class BooksZines(BaseModel): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + title: str + author: List[Union[Authors, str]] + rdflag: str = Field(alias="r&d") + kind: BooksZinesKind From a111fcc53f7bae1a3767c9a5297be2f76c464258 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 16 Oct 2026 08:41:27 +0100 Subject: [PATCH 2/2] fix(cli): keep Python types distinct across databases and aliases Models and enums were keyed by table id alone, so two databases holding a table with the same id overwrote each other: the second table's class was emitted twice and the first was lost, and relationships could point at the other database's model. They are now keyed by database and table id, and a relationship resolves within its own database first. A strict-mode field name could also equal another column's raw key, as fullName becoming full_name next to a full_name column. With populate_by_name the field then also accepted that column's key, so a payload missing fullName but carrying full_name validated and filled both fields. Generated names now skip every raw column key other than their own. --- .../cli/internal/typegen/language_test.go | 41 +++++++++++++-- templates/cli/internal/typegen/python.go | 51 +++++++++++++------ .../typegen/testdata/python.collections.json | 23 +++++++++ .../python.coverage.loose.appwrite_types.py | 11 ++++ .../python.coverage.strict.appwrite_types.py | 15 +++++- 5 files changed, 119 insertions(+), 22 deletions(-) diff --git a/templates/cli/internal/typegen/language_test.go b/templates/cli/internal/typegen/language_test.go index 09d1dbe553..efa4be448f 100644 --- a/templates/cli/internal/typegen/language_test.go +++ b/templates/cli/internal/typegen/language_test.go @@ -203,7 +203,7 @@ func TestPythonFieldNamesAreUsableIdentifiers(t *testing.T) { for strict, want := range map[bool][]string{ false: {"fullName", "full_name", "class_", "hidden", "field_9lives", "field", "model_config_", "Items_", "published_at"}, - true: {"full_name", "full_name_1", "class_", "hidden", "field_9lives", "field", "model_config_", "items", "published_at"}, + true: {"full_name_1", "full_name", "class_", "hidden", "field_9lives", "field", "model_config_", "items", "published_at"}, } { got := pythonFieldNames(attributes, strict, classes) if strings.Join(got, " ") != strings.Join(want, " ") { @@ -222,15 +222,48 @@ func TestPythonClassNamesNeverCollide(t *testing.T) { want := map[string]string{"a": "OrdersStatus", "b": "Orders", "c": "Orders1", "d": "Model2024Sales"} for id, name := range want { - if classes.models[id] != name { - t.Errorf("model %s = %q, want %q", id, classes.models[id], name) + if model := classes.models[pythonModelKey(Collection{ID: id})]; model != name { + t.Errorf("model %s = %q, want %q", id, model, name) } } - if enum := classes.enums[pythonEnumKey("b", "status")]; enum != "OrdersStatus1" { + if enum := classes.enums[pythonEnumKey(Collection{ID: "b"}, "status")]; enum != "OrdersStatus1" { t.Errorf("enum = %q, want OrdersStatus1", enum) } } +func TestPythonModelsAreKeyedByDatabase(t *testing.T) { + collections := []Collection{ + {ID: "items", DatabaseID: "a", Name: "First", Attributes: []Attribute{ + {Key: "status", Type: AttributeTypeString, Format: AttributeTypeEnum, Required: true, Elements: []string{"open"}}, + {Key: "owner", Type: AttributeTypeRelationship, Required: true, RelatedTable: "owners", RelationType: "oneToOne", Side: "parent"}, + }}, + {ID: "items", DatabaseID: "b", Name: "Second", Attributes: []Attribute{ + {Key: "status", Type: AttributeTypeString, Format: AttributeTypeEnum, Required: true, Elements: []string{"closed"}}, + {Key: "owner", Type: AttributeTypeRelationship, Required: true, RelatedTable: "owners", RelationType: "oneToOne", Side: "parent"}, + }}, + {ID: "owners", DatabaseID: "a", Name: "First Owners"}, + {ID: "owners", DatabaseID: "b", Name: "Second Owners"}, + } + + rendered, err := (Python{}).Render(collections, nil, false, baselineInvocation) + if err != nil { + t.Fatalf("render: %v", err) + } + + for _, want := range []string{ + "class FirstStatus(str, Enum):\n OPEN = \"open\"\n", + "class SecondStatus(str, Enum):\n CLOSED = \"closed\"\n", + "class First(BaseModel):\n model_config = ConfigDict(extra=\"allow\", populate_by_name=True)\n\n status: FirstStatus\n owner: Union[FirstOwners, str]\n", + "class Second(BaseModel):\n model_config = ConfigDict(extra=\"allow\", populate_by_name=True)\n\n status: SecondStatus\n owner: Union[SecondOwners, str]\n", + "class FirstOwners(BaseModel):", + "class SecondOwners(BaseModel):", + } { + if strings.Count(rendered, want) != 1 { + t.Errorf("want exactly one %q in:\n%s", want, rendered) + } + } +} + func TestPythonStringMatchesBlackQuoting(t *testing.T) { for value, want := range map[string]string{ "plain": `"plain"`, diff --git a/templates/cli/internal/typegen/python.go b/templates/cli/internal/typegen/python.go index edd957aa18..ab3b19fa7e 100644 --- a/templates/cli/internal/typegen/python.go +++ b/templates/cli/internal/typegen/python.go @@ -36,8 +36,12 @@ type pythonClasses struct { order []string } -func pythonEnumKey(collectionID, attributeKey string) string { - return collectionID + "\x00" + attributeKey +func pythonModelKey(collection Collection) string { + return collection.DatabaseID + "\x00" + collection.ID +} + +func pythonEnumKey(collection Collection, attributeKey string) string { + return pythonModelKey(collection) + "\x00" + attributeKey } func pythonClassName(value string) string { @@ -65,7 +69,7 @@ func newPythonClasses(collections []Collection) pythonClasses { } for _, collection := range collections { - classes.models[collection.ID] = claim(pythonClassName(collection.Name)) + classes.models[pythonModelKey(collection)] = claim(pythonClassName(collection.Name)) } for _, collection := range collections { for _, attribute := range collection.Attributes { @@ -74,30 +78,41 @@ func newPythonClasses(collections []Collection) pythonClasses { } name := pythonClassName(collection.Name) + ToPascalCase(attribute.Key) - classes.enums[pythonEnumKey(collection.ID, attribute.Key)] = claim(name) + classes.enums[pythonEnumKey(collection, attribute.Key)] = claim(name) } } return classes } -func pythonCollectionID(collections []Collection, collectionName string) string { +func pythonCollection(collections []Collection, collectionName string) Collection { for _, collection := range collections { if collection.Name == collectionName { - return collection.ID + return collection } } - return collectionName + return Collection{ID: collectionName} +} + +func pythonRelated(attribute Attribute, collections []Collection, owner Collection) (Collection, error) { + id := RelatedCollectionID(attribute) + for _, collection := range collections { + if collection.ID == id && collection.DatabaseID == owner.DatabaseID { + return collection, nil + } + } + + return RelatedCollection(attribute, collections) } func (p Python) Type(attribute Attribute, collections []Collection, collectionName string) (string, error) { classes := newPythonClasses(collections) - return p.annotation(attribute, collections, pythonCollectionID(collections, collectionName), classes) + return p.annotation(attribute, collections, pythonCollection(collections, collectionName), classes) } -func (p Python) annotation(attribute Attribute, collections []Collection, collectionID string, classes pythonClasses) (string, error) { +func (p Python) annotation(attribute Attribute, collections []Collection, collection Collection, classes pythonClasses) (string, error) { var literal string switch attribute.Type { @@ -105,7 +120,7 @@ func (p Python) annotation(attribute Attribute, collections []Collection, collec AttributeTypeMediumText, AttributeTypeLongText, AttributeTypeDateTime: literal = "str" if attribute.Format == AttributeTypeEnum { - literal = classes.enums[pythonEnumKey(collectionID, attribute.Key)] + literal = classes.enums[pythonEnumKey(collection, attribute.Key)] } case AttributeTypeInteger, AttributeTypeBigInt: literal = "int" @@ -114,12 +129,12 @@ func (p Python) annotation(attribute Attribute, collections []Collection, collec case AttributeTypeBoolean: literal = "bool" case AttributeTypeRelationship: - related, err := RelatedCollection(attribute, collections) + related, err := pythonRelated(attribute, collections, collection) if err != nil { return "", err } - literal = "Union[" + classes.models[related.ID] + ", str]" + literal = "Union[" + classes.models[pythonModelKey(related)] + ", str]" if IsRelationshipList(attribute) { literal = "List[" + literal + "]" } @@ -146,6 +161,10 @@ func (p Python) FileName(*Collection) string { return "appwrite_types.py" } func pythonFieldNames(attributes []Attribute, strict bool, classes pythonClasses) []string { used := map[string]bool{} + keys := map[string]bool{} + for _, attribute := range attributes { + keys[attribute.Key] = true + } names := make([]string, len(attributes)) for index, attribute := range attributes { @@ -161,7 +180,7 @@ func pythonFieldNames(attributes []Attribute, strict bool, classes pythonClasses } candidate := name - for suffix := 1; used[candidate]; suffix++ { + for suffix := 1; used[candidate] || (candidate != attribute.Key && keys[candidate]); suffix++ { candidate = fmt.Sprintf("%s_%d", name, suffix) } used[candidate] = true @@ -398,7 +417,7 @@ func (p Python) Render(collections []Collection, _ *Collection, strict bool, inv for index, collection := range collections { names := pythonFieldNames(collection.Attributes, strict, classes) for position, attribute := range collection.Attributes { - annotation, err := p.annotation(attribute, collections, collection.ID, classes) + annotation, err := p.annotation(attribute, collections, collection, classes) if err != nil { return "", err } @@ -462,7 +481,7 @@ func (p Python) Render(collections []Collection, _ *Collection, strict bool, inv } out.WriteString("\n\n") - pythonBracketed(&out, "", "class "+classes.enums[pythonEnumKey(collection.ID, attribute.Key)], + pythonBracketed(&out, "", "class "+classes.enums[pythonEnumKey(collection, attribute.Key)], "(", []string{"str", "Enum"}, ")", ":") members := generateEnumMembers(attribute.Elements) @@ -477,7 +496,7 @@ func (p Python) Render(collections []Collection, _ *Collection, strict bool, inv for index, collection := range collections { out.WriteString("\n\n") - pythonBracketed(&out, "", "class "+classes.models[collection.ID], "(", []string{"BaseModel"}, ")", ":") + pythonBracketed(&out, "", "class "+classes.models[pythonModelKey(collection)], "(", []string{"BaseModel"}, ")", ":") out.WriteString(pythonIndent + `model_config = ConfigDict(extra="allow", populate_by_name=True)` + "\n") if len(models[index]) > 0 { diff --git a/templates/cli/internal/typegen/testdata/python.collections.json b/templates/cli/internal/typegen/testdata/python.collections.json index 19bdef903c..016c19a510 100644 --- a/templates/cli/internal/typegen/testdata/python.collections.json +++ b/templates/cli/internal/typegen/testdata/python.collections.json @@ -131,5 +131,28 @@ "attributes": [ { "key": "value", "type": "string", "required": true, "size": 8 } ] + }, + { + "$id": "warehouses", + "databaseId": "archive", + "name": "Archived Warehouses", + "attributes": [ + { + "key": "status", + "type": "string", + "format": "enum", + "required": true, + "size": 8, + "elements": ["sealed"] + }, + { + "key": "successor", + "type": "relationship", + "required": false, + "relatedTable": "warehouses", + "relationType": "oneToOne", + "side": "parent" + } + ] } ] diff --git a/templates/cli/internal/typegen/testdata/python.coverage.loose.appwrite_types.py b/templates/cli/internal/typegen/testdata/python.coverage.loose.appwrite_types.py index a7ad90d6e9..705af7a1b8 100644 --- a/templates/cli/internal/typegen/testdata/python.coverage.loose.appwrite_types.py +++ b/templates/cli/internal/typegen/testdata/python.coverage.loose.appwrite_types.py @@ -30,6 +30,10 @@ class WarehousesStatus1(str, Enum): CLOSED = "closed" +class ArchivedWarehousesStatus(str, Enum): + SEALED = "sealed" + + class InventoryItems(BaseModel): model_config = ConfigDict(extra="allow", populate_by_name=True) @@ -93,3 +97,10 @@ class ATableWhoseNameIsLongEnoughThatTheClassStatementForItsModelCannotFitOnOneL model_config = ConfigDict(extra="allow", populate_by_name=True) value: str + + +class ArchivedWarehouses(BaseModel): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + status: ArchivedWarehousesStatus + successor: Optional[Union[ArchivedWarehouses, str]] = None diff --git a/templates/cli/internal/typegen/testdata/python.coverage.strict.appwrite_types.py b/templates/cli/internal/typegen/testdata/python.coverage.strict.appwrite_types.py index 28ae934405..29ad63a6f8 100644 --- a/templates/cli/internal/typegen/testdata/python.coverage.strict.appwrite_types.py +++ b/templates/cli/internal/typegen/testdata/python.coverage.strict.appwrite_types.py @@ -30,6 +30,10 @@ class WarehousesStatus1(str, Enum): CLOSED = "closed" +class ArchivedWarehousesStatus(str, Enum): + SEALED = "sealed" + + class InventoryItems(BaseModel): model_config = ConfigDict(extra="allow", populate_by_name=True) @@ -48,8 +52,8 @@ class InventoryItems(BaseModel): model_config_: Optional[str] = Field(default=None, alias="model_config") str_: Optional[str] = Field(default=None, alias="str") hidden: Optional[str] = Field(default=None, alias="_hidden") - full_name: str = Field(alias="fullName") - full_name_1: Optional[str] = Field(default=None, alias="full_name") + full_name_1: str = Field(alias="fullName") + full_name: Optional[str] = None field_9lives: Optional[int] = Field(default=None, alias="9lives") field: Optional[str] = Field(default=None, alias="!!!") a_rather_long_column_key_that_needs_wrapping_too: Optional[List[float]] = Field( @@ -93,3 +97,10 @@ class ATableWhoseNameIsLongEnoughThatTheClassStatementForItsModelCannotFitOnOneL model_config = ConfigDict(extra="allow", populate_by_name=True) value: str + + +class ArchivedWarehouses(BaseModel): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + status: ArchivedWarehousesStatus + successor: Optional[Union[ArchivedWarehouses, str]] = None