Repository: bentoml/BentoML
Pull request #3318 (2022-12-06 23:46:23)

feat: `from_sample` docstring
Add from_sample docstring for all IODescriptor

Signed-off-by: Aaron Pham <29749331+aarnphm@users.noreply.github.com>


codecov[bot]: # [Codecov](https://codecov.io/gh/bentoml/BentoML/pull/3318?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bentoml) Report
> Merging [#3318](https://codecov.io/gh/bentoml/BentoML/pull/3318?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bentoml) (6733f4f) into [main](https://codecov.io/gh/bentoml/BentoML/commit/210071a1fe4db4f25ddb0db41ac083c6af04a4c8?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bentoml) (210071a) will **decrease** coverage by `0.24%`.
> The diff coverage is `74.19%`.

[![Impacted file tree graph](https://codecov.io/gh/bentoml/BentoML/pull/3318/graphs/tree.svg?width=650&height=150&src=pr&token=GMzgXdpE5b&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bentoml)](https://codecov.io/gh/bentoml/BentoML/pull/3318?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bentoml)

```diff
@@            Coverage Diff             @@
##             main    #3318      +/-   ##
==========================================
- Coverage   33.74%   33.50%   -0.25%     
==========================================
  Files         108      108              
  Lines        9849     9867      +18     
  Branches     1696     1694       -2     
==========================================
- Hits         3324     3306      -18     
- Misses       6296     6330      +34     
- Partials      229      231       +2     
```

| [Impacted Files](https://codecov.io/gh/bentoml/BentoML/pull/3318?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bentoml) | Coverage Δ | |
|---|---|---|
| [src/bentoml/\_internal/io\_descriptors/image.py](https://codecov.io/gh/bentoml/BentoML/pull/3318/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bentoml#diff-c3JjL2JlbnRvbWwvX2ludGVybmFsL2lvX2Rlc2NyaXB0b3JzL2ltYWdlLnB5) | `41.81% <ø> (ø)` | |
| [src/bentoml/\_internal/io\_descriptors/json.py](https://codecov.io/gh/bentoml/BentoML/pull/3318/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bentoml#diff-c3JjL2JlbnRvbWwvX2ludGVybmFsL2lvX2Rlc2NyaXB0b3JzL2pzb24ucHk=) | `38.65% <ø> (ø)` | |
| [src/bentoml/\_internal/io\_descriptors/file.py](https://codecov.io/gh/bentoml/BentoML/pull/3318/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bentoml#diff-c3JjL2JlbnRvbWwvX2ludGVybmFsL2lvX2Rlc2NyaXB0b3JzL2ZpbGUucHk=) | `47.45% <12.50%> (-2.55%)` | :arrow_down: |
| [src/bentoml/\_internal/io\_descriptors/base.py](https://codecov.io/gh/bentoml/BentoML/pull/3318/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bentoml#diff-c3JjL2JlbnRvbWwvX2ludGVybmFsL2lvX2Rlc2NyaXB0b3JzL2Jhc2UucHk=) | `86.79% <95.65%> (-1.85%)` | :arrow_down: |
| [src/bentoml/\_internal/service/service.py](https://codecov.io/gh/bentoml/BentoML/pull/3318/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bentoml#diff-c3JjL2JlbnRvbWwvX2ludGVybmFsL3NlcnZpY2Uvc2VydmljZS5weQ==) | `65.83% <0.00%> (-6.67%)` | :arrow_down: |
| [src/bentoml/\_internal/bento/build\_config.py](https://codecov.io/gh/bentoml/BentoML/pull/3318/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bentoml#diff-c3JjL2JlbnRvbWwvX2ludGVybmFsL2JlbnRvL2J1aWxkX2NvbmZpZy5weQ==) | `52.77% <0.00%> (-2.70%)` | :arrow_down: |
| [src/bentoml/\_internal/utils/\_\_init\_\_.py](https://codecov.io/gh/bentoml/BentoML/pull/3318/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bentoml#diff-c3JjL2JlbnRvbWwvX2ludGVybmFsL3V0aWxzL19faW5pdF9fLnB5) | `43.79% <0.00%> (-1.94%)` | :arrow_down: |
| [src/bentoml/\_internal/container/base.py](https://codecov.io/gh/bentoml/BentoML/pull/3318/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bentoml#diff-c3JjL2JlbnRvbWwvX2ludGVybmFsL2NvbnRhaW5lci9iYXNlLnB5) | `61.32% <0.00%> (-1.89%)` | :arrow_down: |
| [src/bentoml/\_internal/container/\_\_init\_\_.py](https://codecov.io/gh/bentoml/BentoML/pull/3318/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bentoml#diff-c3JjL2JlbnRvbWwvX2ludGVybmFsL2NvbnRhaW5lci9fX2luaXRfXy5weQ==) | `76.04% <0.00%> (-1.05%)` | :arrow_down: |
| ... and [7 more](https://codecov.io/gh/bentoml/BentoML/pull/3318/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=bentoml) | |

aarnphm: cc @sauyon PTAL when you have bandwidth.

Patch:
diff --git a/docs/source/reference/api_io_descriptors.rst b/docs/source/reference/api_io_descriptors.rst
index 1645e3b7..3f8c1545 100644
--- a/docs/source/reference/api_io_descriptors.rst
+++ b/docs/source/reference/api_io_descriptors.rst
@@ -189,6 +189,7 @@ Texts
 :code:`bentoml.io.Text` is commonly used for NLP Applications:
 
 .. autoclass:: bentoml.io.Text
+.. automethod:: bentoml.io.Text.from_sample
 .. automethod:: bentoml.io.Text.from_proto
 .. automethod:: bentoml.io.Text.from_http_request
 .. automethod:: bentoml.io.Text.to_proto
@@ -237,6 +238,7 @@ To use the IO descriptor, install bentoml with extra ``io-image`` dependency:
                 - Pillow
 
 .. autoclass:: bentoml.io.Image
+.. automethod:: bentoml.io.Image.from_sample
 .. automethod:: bentoml.io.Image.from_proto
 .. automethod:: bentoml.io.Image.from_http_request
 .. automethod:: bentoml.io.Image.to_proto
@@ -246,6 +248,7 @@ Files
 -----
 
 .. autoclass:: bentoml.io.File
+.. automethod:: bentoml.io.File.from_sample
 .. automethod:: bentoml.io.File.from_proto
 .. automethod:: bentoml.io.File.from_http_request
 .. automethod:: bentoml.io.File.to_proto
@@ -255,7 +258,7 @@ Multipart Payloads
 ------------------
 
 .. note::
-    :code:`io.Multipart` makes it possible to compose a multipart payload from multiple
+    :code:`Multipart` makes it possible to compose a multipart payload from multiple
     other IO Descriptor instances. For example, you may create a Multipart input that
     contains a image file and additional metadata in JSON.
 
diff --git a/src/bentoml/_internal/client/grpc.py b/src/bentoml/_internal/client/grpc.py
index 3441429a..ad15d4c5 100644
--- a/src/bentoml/_internal/client/grpc.py
+++ b/src/bentoml/_internal/client/grpc.py
@@ -26,7 +26,6 @@ PROTOBUF_EXC_MESSAGE = "'protobuf' is required to use gRPC Client. Install with
 REFLECTION_EXC_MESSAGE = "'grpcio-reflection' is required to use gRPC Client. Install with 'pip install bentoml[grpc-reflection]'."
 
 if TYPE_CHECKING:
-
     import grpc
     from grpc import aio
     from grpc._channel import Channel as GrpcSyncChannel
@@ -52,6 +51,7 @@ else:
         exc_msg=PROTOBUF_EXC_MESSAGE,
     )
 
+
 # TODO: xDS support
 class GrpcClient(Client):
     def __init__(
@@ -298,7 +298,7 @@ class GrpcClient(Client):
         return await fn(
             **{
                 "api_name": api_fn[_bentoml_api],
-                _bentoml_api.input._proto_fields[0]: serialized_req,
+                _bentoml_api.input.proto_fields[0]: serialized_req,
             },
         )
 
diff --git a/src/bentoml/_internal/io_descriptors/base.py b/src/bentoml/_internal/io_descriptors/base.py
index 42af7f92..2dd777ee 100644
--- a/src/bentoml/_internal/io_descriptors/base.py
+++ b/src/bentoml/_internal/io_descriptors/base.py
@@ -3,16 +3,15 @@ from __future__ import annotations
 import typing as t
 from abc import ABC
 from abc import abstractmethod
-from typing import TYPE_CHECKING
+from functools import update_wrapper
 
 from ...exceptions import InvalidArgument
 
-if TYPE_CHECKING:
+if t.TYPE_CHECKING:
     from types import UnionType
 
     import pyarrow
     import pyspark.sql.types
-    from typing_extensions import Self
     from starlette.requests import Request
     from starlette.responses import Response
 
@@ -30,6 +29,7 @@ if TYPE_CHECKING:
         | dict[str, t.Type[t.Any] | UnionType | LazyType[t.Any]]
     )
     OpenAPIResponse = dict[str, str | dict[str, t.Any]]
+    F = t.Callable[..., t.Any]
 
 
 IO_DESCRIPTOR_REGISTRY: dict[str, type[IODescriptor[t.Any]]] = {}
@@ -76,20 +76,31 @@ class IODescriptor(ABC, _OpenAPIMeta, t.Generic[IOType]):
     endpoint IO descriptor types in BentoServer.
     """
 
-    __slots__ = ("_args", "_kwargs", "_proto_fields", "_mime_type", "descriptor_id")
+    if t.TYPE_CHECKING:
+        # Populated by subclasses. Makes pyright happy.
+        def __init__(self, **kwargs: t.Any) -> None:
+            ...
 
-    HTTP_METHODS = ["POST"]
+    __slots__ = (
+        "_mime_type",
+        "proto_fields",
+        "descriptor_id",
+        "from_sample",
+        "_sample",
+    )
 
-    descriptor_id: str | None
+    HTTP_METHODS = ("POST",)
 
     _mime_type: str
-    _rpc_content_type: str = "application/grpc"
-    _proto_fields: tuple[ProtoField]
-    _sample: IOType | None = None
-    _args: t.Sequence[t.Any]
-    _kwargs: dict[str, t.Any]
-
-    def __init_subclass__(cls, *, descriptor_id: str | None = None):
+    descriptor_id: str | None
+    proto_fields: tuple[ProtoField]
+
+    def __init_subclass__(
+        cls,
+        *,
+        descriptor_id: str | None = None,
+        proto_fields: tuple[ProtoField] | None = None,
+    ):
         if descriptor_id is not None:
             if descriptor_id in IO_DESCRIPTOR_REGISTRY:
                 raise ValueError(
@@ -97,11 +108,22 @@ class IODescriptor(ABC, _OpenAPIMeta, t.Generic[IOType]):
                 )
             IO_DESCRIPTOR_REGISTRY[descriptor_id] = cls
         cls.descriptor_id = descriptor_id
+        cls.proto_fields = proto_fields or t.cast("tuple[ProtoField]", ())
 
-    if TYPE_CHECKING:
+        cls._sample: IOType | None = None
 
-        def __init__(self, **kwargs: t.Any) -> None:
-            ...
+        def _() -> F:
+            def impl(
+                cl_: type[IODescriptor[t.Any]], sample: t.Any, **kwargs: t.Any
+            ) -> IODescriptor[t.Any]:
+                klass = cl_(**kwargs)
+                klass.sample = klass._from_sample(sample)
+                return klass
+
+            impl.__doc__ = cls._from_sample.__doc__
+            return update_wrapper(impl, cls._from_sample)
+
+        cls.from_sample = classmethod(_())
 
     def __repr__(self) -> str:
         return self.__class__.__qualname__
@@ -114,14 +136,18 @@ class IODescriptor(ABC, _OpenAPIMeta, t.Generic[IOType]):
     def sample(self, value: IOType) -> None:
         self._sample = value
 
-    @classmethod
-    def from_sample(cls, sample: IOType | t.Any, **kwargs: t.Any) -> Self:
-        klass = cls(**kwargs)
-        klass.sample = klass._from_sample(sample)
-        return klass
-
     @abstractmethod
     def _from_sample(self, sample: t.Any) -> IOType:
+        """
+        Creates a new instance of the IO Descriptor from given sample.
+
+        Args:
+            sample: The sample to create the instance from.
+            **kwargs: Additional keyword arguments to pass to the constructor.
+
+        Returns:
+            An instance of the IODescriptor.
+        """
         raise NotImplementedError
 
     @property
@@ -132,7 +158,7 @@ class IODescriptor(ABC, _OpenAPIMeta, t.Generic[IOType]):
         return None
 
     @classmethod
-    def from_spec(cls, spec: dict[str, t.Any]) -> Self:
+    def from_spec(cls, spec: dict[str, t.Any]) -> t.Self:
         raise NotImplementedError
 
     @abstractmethod
diff --git a/src/bentoml/_internal/io_descriptors/file.py b/src/bentoml/_internal/io_descriptors/file.py
index dc2eb049..c44030a0 100644
--- a/src/bentoml/_internal/io_descriptors/file.py
+++ b/src/bentoml/_internal/io_descriptors/file.py
@@ -4,7 +4,6 @@ import io
 import os
 import typing as t
 import logging
-from typing import TYPE_CHECKING
 from functools import lru_cache
 
 from starlette.requests import Request
@@ -27,7 +26,7 @@ from ..service.openapi.specification import MediaType
 
 logger = logging.getLogger(__name__)
 
-if TYPE_CHECKING:
+if t.TYPE_CHECKING:
     from bentoml.grpc.v1 import service_pb2 as pb
     from bentoml.grpc.v1alpha1 import service_pb2 as pb_v1alpha1
 
@@ -36,14 +35,15 @@ if TYPE_CHECKING:
 
     FileKind: t.TypeAlias = t.Literal["binaryio", "textio"]
 else:
-
     pb, _ = import_generated_stubs("v1")
     pb_v1alpha1, _ = import_generated_stubs("v1alpha1")
 
 FileType = t.Union[io.IOBase, t.IO[bytes], FileLike[bytes]]
 
 
-class File(IODescriptor[FileType], descriptor_id="bentoml.io.File"):
+class File(
+    IODescriptor[FileType], descriptor_id="bentoml.io.File", proto_fields=("file",)
+):
     """
     :obj:`File` defines API specification for the inputs/outputs of a Service, where either
     inputs will be converted to or outputs will be converted from file-like objects as
@@ -114,8 +114,6 @@ class File(IODescriptor[FileType], descriptor_id="bentoml.io.File"):
 
     """
 
-    _proto_fields = ("file",)
-
     def __new__(
         cls, kind: FileKind = "binaryio", mime_type: str | None = None, **kwargs: t.Any
     ) -> File:
@@ -128,6 +126,39 @@ class File(IODescriptor[FileType], descriptor_id="bentoml.io.File"):
         return res
 
     def _from_sample(self, sample: FileType | str) -> FileType:
+        """
+        Create a :class:`~bentoml._internal.io_descriptors.file.File` IO Descriptor from given inputs.
+
+        Args:
+            sample: Given File-like object, or a path to a file.
+            kind: The kind of file-like object to be used. Currently, the only accepted value is ``binaryio``.
+            mime_type: Optional MIME type for the descriptor. If not provided, ``from_sample``
+                       will try to infer the MIME type from the file extension.
+
+        Returns:
+            :class:`~bentoml._internal.io_descriptors.file.File`: IODescriptor from given users inputs.
+
+        Example:
+
+        .. code-block:: python
+           :caption: `service.py`
+
+           from __future__ import annotations
+
+           import bentoml
+           from typing import Any
+           from bentoml.io import File
+
+           input_spec = File.from_sample("/path/to/file.pdf")
+           @svc.api(input=input_spec, output=File())
+           async def predict(input: t.IO[t.Any]) -> t.IO[t.Any]:
+               return await runner.async_run(input)
+
+        Raises:
+            :class:`MissingDependencyException`: If ``filetype`` is not installed.
+            ValueError: If the MIME type cannot be inferred automatically.
+            :class:`BadInput`: Any other errors that may occur during the process of figure out the MIME type.
+        """
         try:
             import filetype
         except ModuleNotFoundError:
diff --git a/src/bentoml/_internal/io_descriptors/image.py b/src/bentoml/_internal/io_descriptors/image.py
index 3289a7ff..0068eae1 100644
--- a/src/bentoml/_internal/io_descriptors/image.py
+++ b/src/bentoml/_internal/io_descriptors/image.py
@@ -3,7 +3,6 @@ from __future__ import annotations
 import io
 import typing as t
 import functools
-from typing import TYPE_CHECKING
 from urllib.parse import quote
 
 from starlette.requests import Request
@@ -27,7 +26,7 @@ from ..service.openapi.specification import MediaType
 
 PIL_EXC_MSG = "'Pillow' is required to use the Image IO descriptor. Install with 'pip install bentoml[io-image]'."
 
-if TYPE_CHECKING:
+if t.TYPE_CHECKING:
     from types import UnionType
 
     import PIL
@@ -79,7 +78,9 @@ def initialize_pillow():
     READABLE_MIMES = {k for k, v in MIME_EXT_MAPPING.items() if v not in PIL_WRITE_ONLY_FORMATS}  # type: ignore (lazy constant)
 
 
-class Image(IODescriptor[ImageType], descriptor_id="bentoml.io.Image"):
+class Image(
+    IODescriptor[ImageType], descriptor_id="bentoml.io.Image", proto_fields=("file",)
+):
     """
     :obj:`Image` defines API specification for the inputs/outputs of a Service, where either
     inputs will be converted to or outputs will be converted from images as specified
@@ -166,8 +167,6 @@ class Image(IODescriptor[ImageType], descriptor_id="bentoml.io.Image"):
         :obj:`Image`: IO Descriptor that either a :code:`PIL.Image.Image` or a :code:`np.ndarray` representing an image.
     """
 
-    _proto_fields = ("file",)
-
     def __init__(
         self,
         pilmode: _Mode | None = DEFAULT_PIL_MODE,
@@ -210,6 +209,42 @@ class Image(IODescriptor[ImageType], descriptor_id="bentoml.io.Image"):
         self._format: str = MIME_EXT_MAPPING[self._mime_type]
 
     def _from_sample(self, sample: ImageType | str) -> ImageType:
+        """
+        Create a :class:`~bentoml._internal.io_descriptors.image.Image` IO Descriptor from given inputs.
+
+        Args:
+            sample: Given File-like object, or a path to a file.
+            pilmode: Optional color mode for PIL. Default to ``RGB``.
+            mime_type: The MIME type of the file type that this descriptor should return.
+                       If not specified, then ``from_sample`` will try to infer the MIME type
+                       from file extension.
+            allowed_mime_types: An optional list of MIME types to restrict input to.
+
+        Returns:
+            :class:`~bentoml._internal.io_descriptors.image.Image`: IODescriptor from given users inputs.
+
+        Example:
+
+        .. code-block:: python
+           :caption: `service.py`
+
+           from __future__ import annotations
+
+           import bentoml
+           from typing import Any
+           from bentoml.io import Image
+           import numpy as np
+
+           input_spec = Image.from_sample("/path/to/image.jpg")
+           @svc.api(input=input_spec, output=Image())
+           async def predict(input: t.IO[t.Any]) -> t.IO[t.Any]:
+               return await runner.async_run(input)
+
+        Raises:
+            :class:`InvalidArgument`: If the given sample is not a valid image type.
+            :class:`MissingDependencyException`: If ``filetype`` is not installed.
+            :class:`BadInput`: Given sample from file can't be parsed with Pillow.
+        """
         try:
             from filetype.match import image_match
         except ModuleNotFoundError:
diff --git a/src/bentoml/_internal/io_descriptors/json.py b/src/bentoml/_internal/io_descriptors/json.py
index 3615c7d4..01d3797b 100644
--- a/src/bentoml/_internal/io_descriptors/json.py
+++ b/src/bentoml/_internal/io_descriptors/json.py
@@ -4,7 +4,6 @@ import json
 import typing as t
 import logging
 import dataclasses
-from typing import TYPE_CHECKING
 
 import attr
 from starlette.requests import Request
@@ -25,7 +24,7 @@ from ..service.openapi.specification import MediaType
 
 EXC_MSG = "'pydantic' must be installed to use 'pydantic_model'. Install with 'pip install bentoml[io-json]'."
 
-if TYPE_CHECKING:
+if t.TYPE_CHECKING:
     from types import UnionType
 
     import pydantic
@@ -73,7 +72,9 @@ class DefaultJsonEncoder(json.JSONEncoder):
         return super().default(o)
 
 
-class JSON(IODescriptor[JSONType], descriptor_id="bentoml.io.JSON"):
+class JSON(
+    IODescriptor[JSONType], descriptor_id="bentoml.io.JSON", proto_fields=("json",)
+):
     """
     :obj:`JSON` defines API specification for the inputs/outputs of a Service, where either
     inputs will be converted to or outputs will be converted from a JSON representation
@@ -172,7 +173,6 @@ class JSON(IODescriptor[JSONType], descriptor_id="bentoml.io.JSON"):
         :obj:`JSON`: IO Descriptor that represents JSON format.
     """
 
-    _proto_fields = ("json",)
     # default mime type is application/json
     _mime_type = "application/json"
 
@@ -202,6 +202,54 @@ class JSON(IODescriptor[JSONType], descriptor_id="bentoml.io.JSON"):
             )
 
     def _from_sample(self, sample: JSONType) -> JSONType:
+        """
+        Create a :class:`~bentoml._internal.io_descriptors.json.JSON` IO Descriptor from given inputs.
+
+        Args:
+            sample: A JSON-like datatype, which can be either dict, str, list.
+                    ``sample`` will also accepting a Pydantic model.
+
+                    .. code-block:: python
+
+                        from pydantic import BaseModel
+
+                        class IrisFeatures(BaseModel):
+                            sepal_len: float
+                            sepal_width: float
+                            petal_len: float
+                            petal_width: float
+
+                        input_spec = JSON.from_sample(
+                            IrisFeatures(sepal_len=1.0, sepal_width=2.0, petal_len=3.0, petal_width=4.0)
+                        )
+
+                        @svc.api(input=input_spec, output=NumpyNdarray())
+                        async def predict(input: NDArray[np.int16]) -> NDArray[Any]:
+                            return await runner.async_run(input)
+            json_encoder: Optional JSON encoder.
+
+        Returns:
+            :class:`~bentoml._internal.io_descriptors.json.JSON`: IODescriptor from given users inputs.
+
+        Example:
+
+        .. code-block:: python
+           :caption: `service.py`
+
+           from __future__ import annotations
+
+           import bentoml
+           from typing import Any
+           from bentoml.io import JSON
+
+           input_spec = JSON.from_sample({"Hello": "World", "foo": "bar"})
+           @svc.api(input=input_spec, output=JSON())
+           async def predict(input: dict[str, Any]) -> dict[str, Any]:
+               return await runner.async_run(input)
+
+        Raises:
+            :class:`BadInput`: Given sample is not a valid JSON string, bytes, or supported nest types.
+        """
         if LazyType["pydantic.BaseModel"]("pydantic.BaseModel").isinstance(sample):
             self._pydantic_model = sample.__class__
         elif isinstance(sample, str):
@@ -333,7 +381,6 @@ class JSON(IODescriptor[JSONType], descriptor_id="bentoml.io.JSON"):
     async def to_http_response(
         self, obj: JSONType | pydantic.BaseModel, ctx: Context | None = None
     ):
-
         # This is to prevent cases where custom JSON encoder is used.
         if LazyType["pydantic.BaseModel"]("pydantic.BaseModel").isinstance(obj):
             obj = obj.dict()
diff --git a/src/bentoml/_internal/io_descriptors/multipart.py b/src/bentoml/_internal/io_descriptors/multipart.py
index 632e26da..e065f9bb 100644
--- a/src/bentoml/_internal/io_descriptors/multipart.py
+++ b/src/bentoml/_internal/io_descriptors/multipart.py
@@ -2,7 +2,6 @@ from __future__ import annotations
 
 import typing as t
 import asyncio
-from typing import TYPE_CHECKING
 
 from starlette.requests import Request
 from multipart.multipart import parse_options_header
@@ -19,7 +18,7 @@ from ..utils.formparser import concat_to_multipart_response
 from ..service.openapi.specification import Schema
 from ..service.openapi.specification import MediaType
 
-if TYPE_CHECKING:
+if t.TYPE_CHECKING:
     from types import UnionType
 
     from google.protobuf import message as _message
@@ -35,7 +34,11 @@ else:
     pb_v1alpha1, _ = import_generated_stubs("v1alpha1")
 
 
-class Multipart(IODescriptor[t.Dict[str, t.Any]], descriptor_id="bentoml.io.Multipart"):
+class Multipart(
+    IODescriptor[t.Dict[str, t.Any]],
+    descriptor_id="bentoml.io.Multipart",
+    proto_fields=("multipart",),
+):
     """
     :obj:`Multipart` defines API specification for the inputs/outputs of a Service, where inputs/outputs
     of a Service can receive/send a **multipart** request/responses as specified in your API function signature.
@@ -163,7 +166,6 @@ class Multipart(IODescriptor[t.Dict[str, t.Any]], descriptor_id="bentoml.io.Mult
         :obj:`Multipart`: IO Descriptor that represents a Multipart request/response.
     """
 
-    _proto_fields = ("multipart",)
     _mime_type = "multipart/form-data"
 
     def __init__(self, **inputs: IODescriptor[t.Any]):
@@ -352,7 +354,7 @@ class Multipart(IODescriptor[t.Dict[str, t.Any]], descriptor_id="bentoml.io.Mult
                     obj,
                     [
                         # TODO: support multiple proto_fields
-                        pb.Part(**{io_._proto_fields[0]: resp})
+                        pb.Part(**{io_.proto_fields[0]: resp})
                         for io_, resp in zip(self._inputs.values(), resps)
                     ],
                 )
diff --git a/src/bentoml/_internal/io_descriptors/numpy.py b/src/bentoml/_internal/io_descriptors/numpy.py
index d00afdeb..f5694b91 100644
--- a/src/bentoml/_internal/io_descriptors/numpy.py
+++ b/src/bentoml/_internal/io_descriptors/numpy.py
@@ -3,7 +3,6 @@ from __future__ import annotations
 import json
 import typing as t
 import logging
-from typing import TYPE_CHECKING
 from functools import lru_cache
 
 from starlette.requests import Request
@@ -23,7 +22,7 @@ from ..service.openapi import SUCCESS_DESCRIPTION
 from ..service.openapi.specification import Schema
 from ..service.openapi.specification import MediaType
 
-if TYPE_CHECKING:
+if t.TYPE_CHECKING:
     import numpy as np
     import pyarrow
     import pyspark.sql.types
@@ -170,7 +169,9 @@ def _is_matched_shape(left: tuple[int, ...], right: tuple[int, ...]) -> bool:
 
 # TODO: when updating docs, add examples with gRPCurl
 class NumpyNdarray(
-    IODescriptor["ext.NpNDArray"], descriptor_id="bentoml.io.NumpyNdarray"
+    IODescriptor["ext.NpNDArray"],
+    descriptor_id="bentoml.io.NumpyNdarray",
+    proto_fields=("ndarray",),
 ):
     """
     :obj:`NumpyNdarray` defines API specification for the inputs/outputs of a Service, where
@@ -260,7 +261,6 @@ class NumpyNdarray(
         :obj:`~bentoml._internal.io_descriptors.IODescriptor`: IO Descriptor that represents a :code:`np.ndarray`.
     """
 
-    _proto_fields = ("ndarray",)
     _mime_type = "application/json"
 
     def __init__(
@@ -435,10 +435,11 @@ class NumpyNdarray(
 
     def _from_sample(self, sample: ext.NpNDArray | t.Sequence[t.Any]) -> ext.NpNDArray:
         """
-        Create a :obj:`NumpyNdarray` IO Descriptor from given inputs.
+        Create a :class:`~bentoml._internal.io_descriptors.numpy.NumpyNdarray` IO Descriptor from given inputs.
 
         Args:
-            sample: Given sample ``np.ndarray`` data
+            sample: Given sample ``np.ndarray`` data. It also accepts a sequence-like data type that
+                    can be converted to ``np.ndarray``.
             enforce_dtype: Enforce a certain data type. :code:`dtype` must be specified at function
                            signature. If you don't want to enforce a specific dtype then change
                            :code:`enforce_dtype=False`.
@@ -447,7 +448,7 @@ class NumpyNdarray(
                            :code:`enforce_shape=False`.
 
         Returns:
-            :obj:`NumpyNdarray`: :code:`NumpyNdarray` IODescriptor from given users inputs.
+            :class:`~bentoml._internal.io_descriptors.numpy.NumpyNdarray`: IODescriptor from given users inputs.
 
         Example:
 
@@ -471,6 +472,11 @@ class NumpyNdarray(
            @svc.api(input=input_spec, output=NumpyNdarray())
            async def predict(input: NDArray[np.int16]) -> NDArray[Any]:
                return await runner.async_run(input)
+
+        Raises:
+            :class:`BentoMLException`: If given sample is a type ``numpy.generic``. This exception
+                                       will also be raised if we failed to create a ``np.ndarray``
+                                       from given sample.
         """
         if isinstance(sample, np.generic):
             raise BentoMLException(
diff --git a/src/bentoml/_internal/io_descriptors/pandas.py b/src/bentoml/_internal/io_descriptors/pandas.py
index 5b7e8c8a..1aeaf8f7 100644
--- a/src/bentoml/_internal/io_descriptors/pandas.py
+++ b/src/bentoml/_internal/io_descriptors/pandas.py
@@ -6,7 +6,6 @@ import typing as t
 import logging
 import functools
 from enum import Enum
-from typing import TYPE_CHECKING
 from concurrent.futures import ThreadPoolExecutor
 
 from starlette.requests import Request
@@ -28,7 +27,7 @@ from ..service.openapi.specification import MediaType
 
 EXC_MSG = "pandas' is required to use PandasDataFrame or PandasSeries. Install with 'pip install bentoml[io-pandas]'"
 
-if TYPE_CHECKING:
+if t.TYPE_CHECKING:
     import numpy as np
     import pandas as pd
     import pyarrow
@@ -200,7 +199,9 @@ def _validate_serialization_format(serialization_format: SerializationFormat):
 
 
 class PandasDataFrame(
-    IODescriptor["ext.PdDataFrame"], descriptor_id="bentoml.io.PandasDataFrame"
+    IODescriptor["ext.PdDataFrame"],
+    descriptor_id="bentoml.io.PandasDataFrame",
+    proto_fields=("dataframe",),
 ):
     """
     :obj:`PandasDataFrame` defines API specification for the inputs/outputs of a Service,
@@ -315,8 +316,6 @@ class PandasDataFrame(
         :obj:`PandasDataFrame`: IO Descriptor that represents a :code:`pd.DataFrame`.
     """
 
-    _proto_fields = ("dataframe",)
-
     def __init__(
         self,
         orient: ext.DataFrameOrient = "records",
@@ -343,7 +342,7 @@ class PandasDataFrame(
 
     def _from_sample(self, sample: ext.PdDataFrame) -> ext.PdDataFrame:
         """
-        Create a :obj:`PandasDataFrame` IO Descriptor from given inputs.
+        Create a :class:`~bentoml._internal.io_descriptors.pandas.PandasDataFrame` IO Descriptor from given inputs.
 
         Args:
             sample: Given sample ``pd.DataFrame`` data
@@ -374,7 +373,7 @@ class PandasDataFrame(
                             - :obj:`csv` - CSV text format (inferred from content-type ``"text/csv"``)
 
         Returns:
-            :obj:`PandasDataFrame`: :code:`PandasDataFrame` IODescriptor from given users inputs.
+            :class:`~bentoml._internal.io_descriptors.pandas.PandasDataFrame`: IODescriptor from given users inputs.
 
         Example:
 
@@ -782,7 +781,9 @@ class PandasDataFrame(
 
 
 class PandasSeries(
-    IODescriptor["ext.PdSeries"], descriptor_id="bentoml.io.PandasSeries"
+    IODescriptor["ext.PdSeries"],
+    descriptor_id="bentoml.io.PandasSeries",
+    proto_fields=("series",),
 ):
     """
     ``PandasSeries`` defines API specification for the inputs/outputs of a Service, where
@@ -878,7 +879,6 @@ class PandasSeries(
         :obj:`PandasSeries`: IO Descriptor that represents a :code:`pd.Series`.
     """
 
-    _proto_fields = ("series",)
     _mime_type = "application/json"
 
     def __init__(
@@ -897,7 +897,7 @@ class PandasSeries(
 
     def _from_sample(self, sample: ext.PdSeries | t.Sequence[t.Any]) -> ext.PdSeries:
         """
-        Create a :obj:`PandasSeries` IO Descriptor from given inputs.
+        Create a :class:`~bentoml._internal.io_descriptors.pandas.PandasSeries` IO Descriptor from given inputs.
 
         Args:
             sample_input: Given sample ``pd.DataFrame`` data
@@ -917,7 +917,7 @@ class PandasSeries(
                            ``enforce_shape=False``.
 
         Returns:
-            :obj:`PandasSeries`: :code:`PandasSeries` IODescriptor from given users inputs.
+            :class:`~bentoml._internal.io_descriptors.pandas.PandasSeries`: IODescriptor from given users inputs.
 
         Example:
 
diff --git a/src/bentoml/_internal/io_descriptors/text.py b/src/bentoml/_internal/io_descriptors/text.py
index ed912706..e9f6e7b5 100644
--- a/src/bentoml/_internal/io_descriptors/text.py
+++ b/src/bentoml/_internal/io_descriptors/text.py
@@ -1,21 +1,19 @@
 from __future__ import annotations
 
 import typing as t
-from typing import TYPE_CHECKING
 
 from starlette.requests import Request
 from starlette.responses import Response
 
-from bentoml.exceptions import BentoMLException
-
 from .base import IODescriptor
 from ..utils.http import set_cookies
+from ...exceptions import BentoMLException
 from ..service.openapi import SUCCESS_DESCRIPTION
 from ..utils.lazy_loader import LazyLoader
 from ..service.openapi.specification import Schema
 from ..service.openapi.specification import MediaType
 
-if TYPE_CHECKING:
+if t.TYPE_CHECKING:
     from google.protobuf import wrappers_pb2
     from typing_extensions import Self
 
@@ -27,7 +25,7 @@ else:
 MIME_TYPE = "text/plain"
 
 
-class Text(IODescriptor[str], descriptor_id="bentoml.io.Text"):
+class Text(IODescriptor[str], descriptor_id="bentoml.io.Text", proto_fields=("text",)):
     """
     :obj:`Text` defines API specification for the inputs/outputs of a Service. :obj:`Text`
     represents strings for all incoming requests/outcoming responses as specified in
@@ -90,7 +88,6 @@ class Text(IODescriptor[str], descriptor_id="bentoml.io.Text"):
         :obj:`Text`: IO Descriptor that represents strings type.
     """
 
-    _proto_fields = ("text",)
     _mime_type = MIME_TYPE
 
     def __init__(self, *args: t.Any, **kwargs: t.Any):
@@ -100,6 +97,23 @@ class Text(IODescriptor[str], descriptor_id="bentoml.io.Text"):
             ) from None
 
     def _from_sample(self, sample: str | bytes) -> str:
+        """
+        Create a :class:`~bentoml._internal.io_descriptors.text.Text` IO Descriptor from given inputs.
+
+        Args:
+            sample: Given sample text.
+
+        Returns:
+            :class:`Text`: IODescriptor from given users inputs.
+
+        Example:
+
+        .. code-block:: python
+           :caption: `service.py`
+
+           @svc.api(input=bentoml.io.Text.from_sample('Bento box is'), output=bentoml.io.Text())
+           def predict(inputs: str) -> str: ...
+        """
         if isinstance(sample, bytes):
             sample = sample.decode("utf-8")
         return sample
diff --git a/src/bentoml/_internal/server/grpc/servicer/v1/__init__.py b/src/bentoml/_internal/server/grpc/servicer/v1/__init__.py
index 1a85985c..f41c72f3 100644
--- a/src/bentoml/_internal/server/grpc/servicer/v1/__init__.py
+++ b/src/bentoml/_internal/server/grpc/servicer/v1/__init__.py
@@ -62,7 +62,7 @@ def create_bento_servicer(service: Service) -> services.BentoServiceServicer:
             response = pb.Response()
             output = None
 
-            # NOTE: since IODescriptor._proto_fields is a tuple, the order is preserved.
+            # NOTE: since IODescriptor.proto_fields is a tuple, the order is preserved.
             # This is important so that we know the order of fields to process.
             # We will use fields descriptor to determine how to process that request.
             try:
@@ -85,7 +85,7 @@ def create_bento_servicer(service: Service) -> services.BentoServiceServicer:
 
                 res = await api.output.to_proto(output)
                 # TODO(aarnphm): support multiple proto fields
-                response = pb.Response(**{api.output._proto_fields[0]: res})
+                response = pb.Response(**{api.output.proto_fields[0]: res})
             except BentoMLException as e:
                 log_exception(request, sys.exc_info())
                 if output is not None:
diff --git a/src/bentoml/_internal/server/grpc/servicer/v1alpha1/__init__.py b/src/bentoml/_internal/server/grpc/servicer/v1alpha1/__init__.py
index 5cf6d0f2..719e5201 100644
--- a/src/bentoml/_internal/server/grpc/servicer/v1alpha1/__init__.py
+++ b/src/bentoml/_internal/server/grpc/servicer/v1alpha1/__init__.py
@@ -58,7 +58,7 @@ def create_bento_servicer(service: Service) -> services.BentoServiceServicer:
             response = pb.Response()
             output = None
 
-            # NOTE: since IODescriptor._proto_fields is a tuple, the order is preserved.
+            # NOTE: since IODescriptor.proto_fields is a tuple, the order is preserved.
             # This is important so that we know the order of fields to process.
             # We will use fields descriptor to determine how to process that request.
             try:
@@ -84,7 +84,7 @@ def create_bento_servicer(service: Service) -> services.BentoServiceServicer:
                 else:
                     res = await api.output.to_proto(output)
                 # TODO(aarnphm): support multiple proto fields
-                response = pb.Response(**{api.output._proto_fields[0]: res})
+                response = pb.Response(**{api.output.proto_fields[0]: res})
             except BentoMLException as e:
                 log_exception(request, sys.exc_info())
                 if output is not None:
diff --git a/src/bentoml/grpc/utils/__init__.py b/src/bentoml/grpc/utils/__init__.py
index 1c885710..1fd17e64 100644
--- a/src/bentoml/grpc/utils/__init__.py
+++ b/src/bentoml/grpc/utils/__init__.py
@@ -51,7 +51,7 @@ def validate_proto_fields(
 ) -> str | ProtoField:
     if field is None:
         raise InvalidArgument('"field" cannot be empty.')
-    accepted_fields = io_._proto_fields + ("serialized_bytes",)
+    accepted_fields = io_.proto_fields + ("serialized_bytes",)
     if field not in accepted_fields:
         raise InvalidArgument(
             f"'{io_.__class__.__name__}' accepts one of the following fields: '{','.join(accepted_fields)}' got '{field}' instead.",


Test patch:
diff --git a/tests/unit/_internal/io/test_base.py b/tests/unit/_internal/io/test_base.py
index 6f33f879..c4cff815 100644
--- a/tests/unit/_internal/io/test_base.py
+++ b/tests/unit/_internal/io/test_base.py
@@ -8,11 +8,14 @@ import pytest
 from bentoml.io import IODescriptor
 
 if TYPE_CHECKING:
-
     from bentoml._internal.context import InferenceApiContext as Context
 
 
-class DummyDescriptor(IODescriptor[t.Any], descriptor_id="bentoml.io.Dummy"):
+class DummyDescriptor(
+    IODescriptor[t.Any],
+    descriptor_id="bentoml.io.Dummy",
+    proto_fields=("serialized_bytes",),
+):
     _mime_type = "application/vnd.bentoml.dummy"
 
     def __init__(self, **kwargs: t.Any):
@@ -55,7 +58,7 @@ class DummyDescriptor(IODescriptor[t.Any], descriptor_id="bentoml.io.Dummy"):
     async def to_proto(self, obj: t.Any) -> t.Any:
         return obj
 
-    def _from_sample(cls, sample: t.Any, **kwargs: t.Any):
+    def _from_sample(self, sample: t.Any):
         return sample