Repository: replicate/cog

Issue:
Inputs in metadata should be a list, not a dict
Sorting is significant.
Inputs in metadata should be a list, not a dict
Sorting is significant.


Discussion:

Resolving patch:
diff --git a/python/cog/input.py b/python/cog/input.py
--- a/python/cog/input.py
+++ b/python/cog/input.py
@@ -17,6 +17,7 @@
 
 @dataclass
 class InputSpec:
+    name: str
     type: Type
     default: Any = UNSPECIFIED
     min: Optional[Number] = None
@@ -50,16 +51,26 @@ def input(name, type, default=UNSPECIFIED, min=None, max=None, options=None, hel
 
     def wrapper(f):
         if not hasattr(f, "_inputs"):
-            f._inputs = {}
+            f._inputs = []
 
-        if name in f._inputs:
+        if name in (i.name for i in f._inputs):
             raise ValueError(f"{name} is already defined as an argument")
 
         if type == Path and default is not UNSPECIFIED and default is not None:
             raise TypeError("Cannot use default with Path type")
 
-        f._inputs[name] = InputSpec(
-            type=type, default=default, min=min, max=max, options=options, help=help
+        # Insert at start of list because decorators are run bottom up
+        f._inputs.insert(
+            0,
+            InputSpec(
+                name=name,
+                type=type,
+                default=default,
+                min=min,
+                max=max,
+                options=options,
+                help=help,
+            ),
         )
 
         @functools.wraps(f)
@@ -97,18 +108,18 @@ def validate_and_convert_inputs(
     input_specs = predictor.predict._inputs
     inputs = {}
 
-    for name, input_spec in input_specs.items():
-        if name in raw_inputs:
-            val = raw_inputs[name]
+    for input_spec in input_specs:
+        if input_spec.name in raw_inputs:
+            val = raw_inputs[input_spec.name]
 
             if input_spec.type == Path:
                 if not isinstance(val, FileStorage):
                     raise InputValidationError(
-                        f"Could not convert file input {name} to {get_type_name(input_spec.type)}",
+                        f"Could not convert file input {input_spec.name} to {get_type_name(input_spec.type)}",
                     )
                 if val.filename is None:
                     raise InputValidationError(
-                        f"No filename is provided for file input {name}"
+                        f"No filename is provided for file input {input_spec.name}"
                     )
 
                 temp_dir = tempfile.mkdtemp()
@@ -123,25 +134,29 @@ def validate_and_convert_inputs(
                 try:
                     converted = int(val)
                 except ValueError:
-                    raise InputValidationError(f"Could not convert {name}={val} to int")
+                    raise InputValidationError(
+                        f"Could not convert {input_spec.name}={val} to int"
+                    )
 
             elif input_spec.type == float:
                 try:
                     converted = float(val)
                 except ValueError:
                     raise InputValidationError(
-                        f"Could not convert {name}={val} to float"
+                        f"Could not convert {input_spec.name}={val} to float"
                     )
 
             elif input_spec.type == bool:
                 if val.lower() not in ["true", "false"]:
-                    raise InputValidationError(f"{name}={val} is not a boolean")
+                    raise InputValidationError(
+                        f"{input_spec.name}={val} is not a boolean"
+                    )
                 converted = val.lower() == "true"
 
             elif input_spec.type == str:
                 if isinstance(val, FileStorage):
                     raise InputValidationError(
-                        f"Could not convert file input {name} to str"
+                        f"Could not convert file input {input_spec.name} to str"
                     )
                 converted = val
 
@@ -171,12 +186,14 @@ def validate_and_convert_inputs(
             if input_spec.default is not UNSPECIFIED:
                 converted = input_spec.default
             else:
-                raise InputValidationError(f"Missing expected argument: {name}")
-        inputs[name] = converted
+                raise InputValidationError(
+                    f"Missing expected argument: {input_spec.name}"
+                )
+        inputs[input_spec.name] = converted
 
-    expected_keys = set(input_specs.keys())
+    expected_names = set(s.name for s in input_specs)
     raw_keys = set(raw_inputs.keys())
-    extraneous_keys = raw_keys - expected_keys
+    extraneous_keys = raw_keys - expected_names
     if extraneous_keys:
         raise InputValidationError(
             f"Extraneous input keys: {', '.join(extraneous_keys)}"
diff --git a/python/cog/predictor.py b/python/cog/predictor.py
--- a/python/cog/predictor.py
+++ b/python/cog/predictor.py
@@ -32,11 +32,12 @@ def get_type_signature(self):
             UNSPECIFIED,
         )
 
-        inputs = {}
+        inputs = []
         if hasattr(self.predict, "_inputs"):
             input_specs = self.predict._inputs
-            for name, spec in input_specs.items():
+            for spec in input_specs:
                 arg: Dict[str, Any] = {
+                    "name": spec.name,
                     "type": get_type_name(spec.type),
                 }
                 if spec.help:
@@ -49,7 +50,7 @@ def get_type_signature(self):
                     arg["max"] = str(spec.max)  # TODO: don't string this
                 if spec.options is not None:
                     arg["options"] = [str(o) for o in spec.options]
-                inputs[name] = arg
+                inputs.append(arg)
         return {"inputs": inputs}
 
 
diff --git a/python/cog/server/ai_platform.py b/python/cog/server/ai_platform.py
--- a/python/cog/server/ai_platform.py
+++ b/python/cog/server/ai_platform.py
@@ -1,3 +1,4 @@
+from pathlib import Path
 import sys
 import traceback
 
@@ -67,27 +68,9 @@ def handle_request():
         def ping():
             return "PONG"
 
-        @app.route("/help")
-        def help():
-            args = {}
-            if hasattr(self.predictor.predict, "_inputs"):
-                input_specs = self.predictor.predict._inputs
-                for name, spec in input_specs.items():
-                    arg = {
-                        "type": get_type_name(spec.type),
-                    }
-                    if spec.help:
-                        arg["help"] = spec.help
-                    if spec.default is not UNSPECIFIED:
-                        arg["default"] = str(spec.default)  # TODO: don't string this
-                    if spec.min is not None:
-                        arg["min"] = str(spec.min)  # TODO: don't string this
-                    if spec.max is not None:
-                        arg["max"] = str(spec.max)  # TODO: don't string this
-                    args[name] = arg
-            return jsonify({"arguments": args})
-
-        return app
+        @app.route("/type-signature")
+        def type_signature():
+            return jsonify(self.predictor.get_type_signature())
 
     def start_server(self):
         app = self.make_app()


Test patch:
diff --git a/python/cog_test.py b/python/cog_test.py
--- a/python/cog_test.py
+++ b/python/cog_test.py
@@ -451,25 +451,29 @@ def predict(self, text, num1, num2, path):
     resp = client.get("/type-signature")
     assert resp.status_code == 200
     assert resp.json == {
-        "inputs": {
-            "text": {
+        "inputs": [
+            {
+                "name": "text",
                 "type": "str",
                 "help": "Some text",
             },
-            "num1": {
+            {
+                "name": "num1",
                 "type": "int",
                 "help": "First number",
             },
-            "num2": {
+            {
+                "name": "num2",
                 "type": "int",
                 "help": "Second number",
                 "default": "10",
             },
-            "path": {
+            {
+                "name": "path",
                 "type": "Path",
                 "help": "A file path",
             },
-        }
+        ]
     }
 
 
diff --git a/test-integration/test_integration/test_build.py b/test-integration/test_integration/test_build.py
--- a/test-integration/test_integration/test_build.py
+++ b/test-integration/test_integration/test_build.py
@@ -83,11 +83,11 @@ def test_build_with_model(project_dir):
         assert json.loads(
             image[0]["Config"]["Labels"]["org.cogmodel.type_signature"]
         ) == {
-            "inputs": {
-                "output_file": {"type": "bool", "default": "False"},
-                "path": {"type": "Path"},
-                "text": {"type": "str"},
-            }
+            "inputs": [
+                {"name": "text", "type": "str"},
+                {"name": "path", "type": "Path"},
+                {"name": "output_file", "type": "bool", "default": "False"},
+            ]
         }
     finally:
         subprocess.run(["docker", "rmi", image_name], check=False)