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 3584aa660a..2562556c3f 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -1807,6 +1807,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 cf4438c40a..90b3aeadf2 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" @@ -94,8 +96,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() @@ -121,23 +123,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 5bf0b7135c..aef91ed59d 100644 --- a/templates/cli/internal/cmd/types.go +++ b/templates/cli/internal/cmd/types.go @@ -41,11 +41,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. @@ -60,6 +61,8 @@ var typeLanguageAliases = map[string]string{ "javascript": "js", "node": "js", "nodejs": "js", + "py": "python", + "python3": "python", "typescript": "ts", } @@ -85,7 +88,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 84d7a2cb48..6a7799b8c6 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) } @@ -183,6 +196,105 @@ 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_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, " ") { + 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 model := classes.models[pythonModelKey(Collection{ID: id})]; model != name { + t.Errorf("model %s = %q, want %q", id, model, name) + } + } + 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"`, + `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..ab3b19fa7e --- /dev/null +++ b/templates/cli/internal/typegen/python.go @@ -0,0 +1,511 @@ +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 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 { + 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[pythonModelKey(collection)] = 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, attribute.Key)] = claim(name) + } + } + + return classes +} + +func pythonCollection(collections []Collection, collectionName string) Collection { + for _, collection := range collections { + if collection.Name == collectionName { + return collection + } + } + + 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, pythonCollection(collections, collectionName), classes) +} + +func (p Python) annotation(attribute Attribute, collections []Collection, collection Collection, 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(collection, attribute.Key)] + } + case AttributeTypeInteger, AttributeTypeBigInt: + literal = "int" + case AttributeTypeFloat: + literal = "float" + case AttributeTypeBoolean: + literal = "bool" + case AttributeTypeRelationship: + related, err := pythonRelated(attribute, collections, collection) + if err != nil { + return "", err + } + + literal = "Union[" + classes.models[pythonModelKey(related)] + ", 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{} + keys := map[string]bool{} + for _, attribute := range attributes { + keys[attribute.Key] = true + } + 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] || (candidate != attribute.Key && keys[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, 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, 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[pythonModelKey(collection)], "(", []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..016c19a510 --- /dev/null +++ b/templates/cli/internal/typegen/testdata/python.collections.json @@ -0,0 +1,158 @@ +[ + { + "$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 } + ] + }, + { + "$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 new file mode 100644 index 0000000000..705af7a1b8 --- /dev/null +++ b/templates/cli/internal/typegen/testdata/python.coverage.loose.appwrite_types.py @@ -0,0 +1,106 @@ +# 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 ArchivedWarehousesStatus(str, Enum): + SEALED = "sealed" + + +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 + + +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 new file mode 100644 index 0000000000..29ad63a6f8 --- /dev/null +++ b/templates/cli/internal/typegen/testdata/python.coverage.strict.appwrite_types.py @@ -0,0 +1,106 @@ +# 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 ArchivedWarehousesStatus(str, Enum): + SEALED = "sealed" + + +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_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( + 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 + + +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.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