Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions src/SDK/Language/CLI.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
46 changes: 33 additions & 13 deletions templates/cli/internal/cmd/typegen_commands_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package cmd

import (
"os"
"path/filepath"
"sort"
"strings"
"testing"
Expand Down Expand Up @@ -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()

Expand All @@ -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))
}
})
}
}

Expand Down
7 changes: 5 additions & 2 deletions templates/cli/internal/cmd/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -60,6 +61,8 @@ var typeLanguageAliases = map[string]string{
"javascript": "js",
"node": "js",
"nodejs": "js",
"py": "python",
"python3": "python",
"typescript": "ts",
}

Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion templates/cli/internal/typegen/language.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}},
Expand Down
116 changes: 114 additions & 2 deletions templates/cli/internal/typegen/language_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)

Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading