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
52 changes: 52 additions & 0 deletions integration-tests/tests/pep604_file_or_none_input.txtar
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Test PEP 604 File | None input annotation.
#
# When a predictor declares `file: File | None`, coglet should recognise
# the File type through the PEP 604 union and coerce URL/file inputs
# into cog.File objects, just like it does for Optional[File].

cog build -t $TEST_IMAGE

# --- Test via cog predict (CLI) ---

# Providing a file input should work.
cog predict $TEST_IMAGE -i file=@input.txt
stdout 'type=File content=hello from file'

# Omitting the optional input should yield None.
cog predict $TEST_IMAGE
stdout 'type=NoneType content=none'

# --- Test via cog serve (HTTP API) ---

cog serve

# POST with a data URL should coerce to File.
curl POST /predictions '{"input":{"file":"data:text/plain;base64,aGVsbG8gZnJvbSBkYXRhIHVybA=="}}'
stdout '"status":"succeeded"'
stdout 'type=File content=hello from data url'

# POST with no file input — should get None.
curl POST /predictions '{"input":{}}'
stdout '"status":"succeeded"'
stdout 'type=NoneType content=none'

-- cog.yaml --
build:
python_version: "3.12"
predict: "predict.py:Predictor"

-- predict.py --
from cog import BasePredictor, File, Input


class Predictor(BasePredictor):
def predict(self, file: File | None = Input(default=None)) -> str:
if file is None:
return "type=NoneType content=none"
content = file.read()
if isinstance(content, bytes):
content = content.decode("utf-8")
return f"type=File content={content}"

-- input.txt --
hello from file
58 changes: 58 additions & 0 deletions integration-tests/tests/pep604_list_file_or_none_input.txtar
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Test PEP 604 list[File] | None input annotation.
#
# When a predictor declares `files: list[File] | None`, coglet should
# recognise the File type inside the list through the PEP 604 union
# and coerce inputs into cog.File objects.

cog build -t $TEST_IMAGE

# --- Test via cog predict (CLI) ---

# Providing multiple file inputs should work.
cog predict $TEST_IMAGE -i files=@file1.txt -i files=@file2.txt
stdout 'count=2 content=one,two'

# Omitting the optional input should yield None.
cog predict $TEST_IMAGE
stdout 'type=NoneType'

# --- Test via cog serve (HTTP API) ---

cog serve

# POST with data URLs should coerce to list[File].
curl POST /predictions '{"input":{"files":["data:text/plain;base64,b25l","data:text/plain;base64,dHdv"]}}'
stdout '"status":"succeeded"'
stdout 'count=2'
stdout 'content=one,two'

# POST with no files input — should get None.
curl POST /predictions '{"input":{}}'
stdout '"status":"succeeded"'
stdout 'type=NoneType'

-- cog.yaml --
build:
python_version: "3.12"
predict: "predict.py:Predictor"

-- predict.py --
from cog import BasePredictor, File, Input


class Predictor(BasePredictor):
def predict(self, files: list[File] | None = Input(default=None)) -> str:
if files is None:
return "type=NoneType"
contents = []
for f in files:
c = f.read()
if isinstance(c, bytes):
c = c.decode("utf-8")
contents.append(c.strip())
return f"count={len(files)} content={','.join(contents)}"

-- file1.txt --
one
-- file2.txt --
two
56 changes: 56 additions & 0 deletions integration-tests/tests/pep604_list_path_or_none_input.txtar
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Test PEP 604 list[Path] | None input annotation.
#
# When a predictor declares `paths: list[Path] | None`, coglet should
# recognise the Path type inside the list through the PEP 604 union
# and coerce inputs into cog.Path objects.

cog build -t $TEST_IMAGE

# --- Test via cog predict (CLI) ---

# Providing multiple path inputs should work.
cog predict $TEST_IMAGE -i paths=@file1.txt -i paths=@file2.txt
stdout 'count=2 content=one,two'

# Omitting the optional input should yield None.
cog predict $TEST_IMAGE
stdout 'type=NoneType'

# --- Test via cog serve (HTTP API) ---

cog serve

# POST with data URLs should coerce to list[Path].
curl POST /predictions '{"input":{"paths":["data:text/plain;base64,b25l","data:text/plain;base64,dHdv"]}}'
stdout '"status":"succeeded"'
stdout 'count=2'
stdout 'content=one,two'

# POST with no paths input — should get None.
curl POST /predictions '{"input":{}}'
stdout '"status":"succeeded"'
stdout 'type=NoneType'

-- cog.yaml --
build:
python_version: "3.12"
predict: "predict.py:Predictor"

-- predict.py --
from cog import BasePredictor, Path, Input


class Predictor(BasePredictor):
def predict(self, paths: list[Path] | None = Input(default=None)) -> str:
if paths is None:
return "type=NoneType"
contents = []
for p in paths:
with open(p) as f:
contents.append(f.read().strip())
return f"count={len(paths)} content={','.join(contents)}"

-- file1.txt --
one
-- file2.txt --
two
51 changes: 51 additions & 0 deletions integration-tests/tests/pep604_path_or_none_input.txtar
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Test PEP 604 Path | None input annotation.
#
# When a predictor declares `path: Path | None`, coglet should recognise
# the Path type through the PEP 604 union and coerce URL/file inputs
# into cog.Path objects, just like it does for Optional[Path].

cog build -t $TEST_IMAGE

# --- Test via cog predict (CLI) ---

# Providing a file input should work.
cog predict $TEST_IMAGE -i path=@input.txt
stdout 'type=Path content=hello from file'

# Omitting the optional input should yield None.
cog predict $TEST_IMAGE
stdout 'type=NoneType content=none'

# --- Test via cog serve (HTTP API) ---

cog serve

# POST with a data URL should coerce to Path.
curl POST /predictions '{"input":{"path":"data:text/plain;base64,aGVsbG8gZnJvbSBkYXRhIHVybA=="}}'
stdout '"status":"succeeded"'
stdout 'type=Path content=hello from data url'

# POST with no path input — should get None.
curl POST /predictions '{"input":{}}'
stdout '"status":"succeeded"'
stdout 'type=NoneType content=none'

-- cog.yaml --
build:
python_version: "3.12"
predict: "predict.py:Predictor"

-- predict.py --
from cog import BasePredictor, Path, Input


class Predictor(BasePredictor):
def predict(self, path: Path | None = Input(default=None)) -> str:
if path is None:
return "type=NoneType content=none"
with open(path) as f:
content = f.read()
return f"type=Path content={content}"

-- input.txt --
hello from file
48 changes: 48 additions & 0 deletions integration-tests/tests/pep604_string_url_not_coerced.txtar
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Regression test: PEP 604 str | None input with a URL must NOT be coerced.
#
# This complements string_input_url_not_coerced.txtar by verifying that the
# PEP 604 union handling in coglet does not accidentally coerce str | None
# inputs into File/Path objects when the value looks like a URL.
# The fix in #2878 adds types.UnionType detection — this test ensures it
# only applies to File/Path unions, not str unions.

cog build -t $TEST_IMAGE

# --- Test via cog predict (CLI) ---

# A URL passed to a str | None input should be returned verbatim.
cog predict $TEST_IMAGE -i url=https://api.example.com/v1/resource
stdout 'type=str value=https://api.example.com/v1/resource'

# None default should also work.
cog predict $TEST_IMAGE
stdout 'type=NoneType value=none'

# --- Test via cog serve (HTTP API) ---

cog serve

# POST a URL string to a str | None input — should stay a string.
curl POST /predictions '{"input":{"url":"https://api.example.com/v1/resource"}}'
stdout '"status":"succeeded"'
stdout 'type=str value=https://api.example.com/v1/resource'

# POST with empty input — should get None.
curl POST /predictions '{"input":{}}'
stdout '"status":"succeeded"'
stdout 'type=NoneType value=none'

-- cog.yaml --
build:
python_version: "3.12"
predict: "predict.py:Predictor"

-- predict.py --
from cog import BasePredictor, Input


class Predictor(BasePredictor):
def predict(self, url: str | None = Input(default=None)) -> str:
if url is None:
return "type=NoneType value=none"
return f"type={type(url).__name__} value={url}"
2 changes: 1 addition & 1 deletion pkg/schema/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ func buildInputSchema(info *PredictorInfo) (map[string]any, []enumSchema) {
}

// Nullable
if field.FieldType.Repetition == Optional {
if field.FieldType.Repetition == Optional || field.FieldType.Repetition == OptionalRepeated {
prop.Set("nullable", true)
}

Expand Down
31 changes: 31 additions & 0 deletions pkg/schema/openapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,37 @@ func TestInputRepeatedType(t *testing.T) {
assert.Equal(t, "string", items["type"])
}

func TestInputOptionalRepeatedType(t *testing.T) {
none := DefaultValue{Kind: DefaultNone}
inputs := NewOrderedMap[string, InputField]()
inputs.Set("files", InputField{
Name: "files",
Order: 0,
FieldType: FieldType{Primitive: TypePath, Repetition: OptionalRepeated},
Default: &none,
})

info := &PredictorInfo{
Inputs: inputs,
Output: SchemaPrim(TypeString),
Mode: ModePredict,
}

spec := parseSpec(t, info)
props := getPath(spec, "components", "schemas", "Input", "properties").(map[string]any)
filesField := props["files"].(map[string]any)
// Should be array type
assert.Equal(t, "array", filesField["type"])
items := filesField["items"].(map[string]any)
assert.Equal(t, "string", items["type"])
assert.Equal(t, "uri", items["format"])
// Should be nullable
assert.Equal(t, true, filesField["nullable"])
// Should NOT be required
inputSchema := getPath(spec, "components", "schemas", "Input").(map[string]any)
assert.Nil(t, inputSchema["required"])
}

// ---------------------------------------------------------------------------
// Tests: Choices / Enums
// ---------------------------------------------------------------------------
Expand Down
54 changes: 54 additions & 0 deletions pkg/schema/python/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1364,6 +1364,60 @@ class Predictor(BasePredictor):
require.Equal(t, schema.Repeated, files.FieldType.Repetition)
}

// ---------------------------------------------------------------------------
// Optional list inputs (list[X] | None)
// ---------------------------------------------------------------------------

func TestOptionalListPipeNone(t *testing.T) {
source := `
from cog import BasePredictor, Input, Path

class Predictor(BasePredictor):
def predict(self, files: list[Path] | None = Input(default=None)) -> str:
pass
`
info := parse(t, source, "Predictor")
files, ok := info.Inputs.Get("files")
require.True(t, ok)
require.Equal(t, schema.TypePath, files.FieldType.Primitive)
require.Equal(t, schema.OptionalRepeated, files.FieldType.Repetition)
require.NotNil(t, files.Default)
require.Equal(t, schema.DefaultNone, files.Default.Kind)
}

func TestOptionalListTypingOptional(t *testing.T) {
source := `
from typing import Optional, List
from cog import BasePredictor, Input

class Predictor(BasePredictor):
def predict(self, tags: Optional[List[str]] = Input(default=None)) -> str:
pass
`
info := parse(t, source, "Predictor")
tags, ok := info.Inputs.Get("tags")
require.True(t, ok)
require.Equal(t, schema.TypeString, tags.FieldType.Primitive)
require.Equal(t, schema.OptionalRepeated, tags.FieldType.Repetition)
require.NotNil(t, tags.Default)
require.Equal(t, schema.DefaultNone, tags.Default.Kind)
}

func TestOptionalListFileInput(t *testing.T) {
source := `
from cog import BasePredictor, File, Input

class Predictor(BasePredictor):
def predict(self, files: list[File] | None = Input(default=None)) -> str:
pass
`
info := parse(t, source, "Predictor")
files, ok := info.Inputs.Get("files")
require.True(t, ok)
require.Equal(t, schema.TypeFile, files.FieldType.Primitive)
require.Equal(t, schema.OptionalRepeated, files.FieldType.Repetition)
}

// ---------------------------------------------------------------------------
// Recursive / nested output types
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading