pydantic-open-inference 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 1&1 Mail&Media
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,193 @@
1
+ Metadata-Version: 2.4
2
+ Name: pydantic-open-inference
3
+ Version: 1.0.0
4
+ Summary: Call an Open Inference server using pydantic models defining your model inputs and outputs.
5
+ Keywords: pydantic,open inference,kserve,triton,machine learning,ml,inference
6
+ Author: Anton Vikström
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Natural Language :: English
10
+ Classifier: Programming Language :: Python
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Typing :: Typed
20
+ Classifier: Intended Audience :: Science/Research
21
+ Classifier: Intended Audience :: Developers
22
+ Classifier: Intended Audience :: System Administrators
23
+ Classifier: Intended Audience :: Other Audience
24
+ Classifier: Topic :: Software Development :: Libraries
25
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
+ Classifier: Topic :: Scientific/Engineering
27
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
28
+ Classifier: Framework :: Pydantic
29
+ Classifier: Framework :: Pydantic :: 2
30
+ Classifier: Development Status :: 5 - Production/Stable
31
+ Requires-Dist: httpx==0.28.*
32
+ Requires-Dist: more-itertools==10.* ; python_full_version < '3.13'
33
+ Requires-Dist: pydantic==2.*
34
+ Requires-Python: >=3.10
35
+ Project-URL: Repository, https://github.com/mam-dev/pydantic-open-inference
36
+ Description-Content-Type: text/markdown
37
+
38
+ # pydantic-open-inference
39
+
40
+ [![image](https://img.shields.io/pypi/v/pydantic-open-inference
41
+ )](https://pypi.python.org/pypi/pydantic-open-inference)
42
+ [![image](https://img.shields.io/github/license/mam-dev/pydantic-open-inference
43
+ )](https://github.com/mam-dev/pydantic-open-inference/blob/main/LICENSE)
44
+ [![image](https://img.shields.io/pypi/pyversions/pydantic-open-inference)](https://pypi.python.org/pypi/pydantic-open-inference)
45
+ [![Actions status](https://github.com/mam-dev/pydantic-open-inference/actions/workflows/ci.yaml/badge.svg)](https://github.com/mam-dev/pydantic-open-inference/actions)
46
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
47
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
48
+ [![Mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://www.mypy-lang.org)
49
+
50
+ ## What is it?
51
+ Provides a client-side python interface for calling [open inference servers](https://kserve.github.io/website/docs/concepts/architecture/data-plane/v2-protocol).
52
+ Define [pydantic](https://docs.pydantic.dev/latest/) models for the model inputs and outputs, and it gets automatically
53
+ converted to and from the open inference protocol, removing the need for boilerplate
54
+ coding handling raw tensors and their shapes.
55
+
56
+ ## Features
57
+ * ✅ Simple, type-safe API toward an open inference server using user-defined pydantic models
58
+ * ✅ Automatic shape inference from pydantic models
59
+ * ✅ Automatic datatype mapping (`int`→INT64, `float`→FP32, `str`→BYTES)
60
+ * ✅ Datatype override for fine-grained control of model inputs
61
+ * ✅ Automatically request only a subset of model outputs by omitting fields in your pydantic model
62
+ * ✅ Take advantage of the full power of pydantic to validate and type-coerce your model calls
63
+ * ✅ Multi-input/output models - each input/output maps to a pydantic field
64
+ * ✅ Custom timeout configuration - configurable per model
65
+ * ✅ Support calling inference for versioned and unversioned models
66
+ * ✅ Error handling with custom exceptions - handle, e.g., specific error codes from the inference server differently
67
+ * ✅ Fully typed - use, e.g., [mypy](https://mypy-lang.org/) and its [pydantic plugin](https://docs.pydantic.dev/latest/integrations/mypy/) to type check your code
68
+
69
+ ## Installation
70
+ Installation using [pip](https://github.com/pypa/pip):
71
+ ```bash
72
+ pip install pydantic-open-inference
73
+ ```
74
+
75
+ Adding it to your project dependencies using [uv](https://docs.astral.sh/uv/):
76
+ ```bash
77
+ uv add pydantic-open-inference
78
+ ```
79
+
80
+ ## How to use it
81
+ Assume we have an inference server serving an Open Inference V2 REST API at `http://localhost:8080`
82
+ and that the server contains a model named `example_model`, which has the following model **inputs**
83
+
84
+ | name | shape | datatype | description | example data |
85
+ |------|-------|----------|--------------------------|--------------------|
86
+ | text | [1] | BYTES | A single string of text. | `["This is a text"]` |
87
+
88
+ and the following model **outputs**:
89
+
90
+ | name | shape | datatype | description | example data |
91
+ |----------|---------|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------|
92
+ | entities | [-1, 4] | BYTES | <div style="width:200px">A list of entities extracted from the text. Each entity is made up of (in order) a label (either "order-id" or "invoice-id"), a confidence score, a start index for the entity in the text, and an end index.</div> | `[["order-id", "0.9", "23", "30"],["invoice-id","0.8","34","46"]]` |
93
+
94
+ We then define a pydantic model for the inputs, inheriting from `InputsBaseModel`.
95
+ Each model input name needs to correspond to a field in the pydantic model, and the type of the
96
+ field needs to make sense given the shape and datatype of the input. In this case, there
97
+ is just a single input named "text" that is supposed to hold a single text, so we simply define
98
+ ```python
99
+ from pydantic_open_inference import InputsBaseModel
100
+
101
+ class ExampleModelInputs(InputsBaseModel):
102
+ text: str
103
+
104
+ ```
105
+
106
+ In the same way, we define a pydantic model for the outputs, inheriting from `OutputsBaseModel`.
107
+ There is one model outputs named "entities", so that will be the only field we define.
108
+ There can be any number of entities (corresponding to the -1 in the shape), so we define it to
109
+ be a `list`. A list of what? Here we have some choices. We could simply define `entities: list[list[str]]`
110
+ and get each entity as a list of strings, e.g., `["order-id", "0.98", "12", "24"]`, but this is not
111
+ very useful to us, so we instead define, e.g., a _named tuple_ with ordered, named, and typed fields:
112
+ ```python
113
+ from typing import NamedTuple
114
+ from enum import StrEnum
115
+ from pydantic_open_inference import OutputsBaseModel
116
+
117
+ class Label(StrEnum):
118
+ ORDER_ID = "order-id"
119
+ INVOICE_ID = "invoice-id"
120
+
121
+ class Entity(NamedTuple):
122
+ label: Label
123
+ score: float
124
+ start: int
125
+ end: int
126
+
127
+ class ExampleModelOutputs(OutputsBaseModel):
128
+ entities: list[Entity]
129
+ ```
130
+ Note that we could also have used `typing.Literal` instead of `enum.StrEnum`, and even added further validation,
131
+ e.g., that the score is between 0.0 and 1.0, and that the start index is not negative etc. Pydantic offers many
132
+ possibilities, but for now, let us stick with this.
133
+
134
+ We now create a `RemoteModel` instance. The `RemoteModel` class represents a model in the inference server,
135
+ in this case our "example_model". Instantiation requires the URL of the inference server, the name of the
136
+ model, and the pydantic models we defined for the input and output:
137
+ ```python
138
+ from pydantic_open_inference import RemoteModel
139
+
140
+ INFERENCE_SERVER_URL = "http://localhost:8080"
141
+
142
+ example_model = RemoteModel(
143
+ server_url=INFERENCE_SERVER_URL,
144
+ model_name="example_model",
145
+ inputs_model=ExampleModelInputs,
146
+ outputs_model=ExampleModelOutputs,
147
+ )
148
+ ```
149
+
150
+ In order to call the inference server, we simply call the `RemoteModel.infer` method, giving
151
+ it an instance of `ExampleModelInputs`, and get back an instance of `ExampleModelOutputs`:
152
+ ```python
153
+ example_input = ExampleModelInputs(text="Your order id is 123456.")
154
+ example_output = example_model.infer(example_input)
155
+ # ExampleModelOutputs(
156
+ # entities=[
157
+ # Entity(label=Label.ORDER_ID, score=0.95, start=17, end=23),
158
+ # ]
159
+ # )
160
+ ```
161
+ We have thus not only made a perfectly type-safe and validated call to the inference server,
162
+ we have also gotten the response back in a useful format! We never had to bother with
163
+ handling the shape of the data, parsing it in row-major order etc, as all of that is handled under the hood!
164
+
165
+ The only time you need to think about "shape" is when defining the fields of the pydantic models.
166
+ Use types like, e.g., `list`, `tuple`, and `typing.NamedTuple`, to type the pydantic-model fields
167
+ so that they map sensibly to their corresponding model input/output. Each input/output name must
168
+ match to a pydantic field and the shape must correspond to the structure of the field, e.g.,
169
+ `list[tuple[int, int]]` for shape [-1, 2], or `tuple[tuple[float, float], tuple[float, float]]`
170
+ for shape [2, 2], etc.
171
+
172
+ The "datatype" of the model _inputs_ is automatically set based on the innermost type of the
173
+ corresponding field in the `InputsBaseModel` subclass, according to:
174
+
175
+ | python type | datatype |
176
+ |-------------|----------|
177
+ | bool | BOOL |
178
+ | int | INT64 |
179
+ | float | FP32 |
180
+ | str | BYTES |
181
+
182
+ However, you can always _override_ the datatype of the model inputs to whatever you want using
183
+ `pydantic_open_inference.DatatypeOverride`; see its docstring for more information.
184
+ The datatypes of the model _outputs_ are disregarded; all that matters is
185
+ that the data we got back from the model can be mapped to the defined `OutputsBaseModel` subclass.
186
+
187
+ When an inference call to a model is made, it automatically requests only the specific outputs
188
+ for which you have defined fields in your `OutputsBaseModel` subclass. So if you are not
189
+ interested in an output, simply do not define a pydantic field for it.
190
+
191
+ ## Examples
192
+
193
+ See the [examples directory](examples) for more examples.
@@ -0,0 +1,156 @@
1
+ # pydantic-open-inference
2
+
3
+ [![image](https://img.shields.io/pypi/v/pydantic-open-inference
4
+ )](https://pypi.python.org/pypi/pydantic-open-inference)
5
+ [![image](https://img.shields.io/github/license/mam-dev/pydantic-open-inference
6
+ )](https://github.com/mam-dev/pydantic-open-inference/blob/main/LICENSE)
7
+ [![image](https://img.shields.io/pypi/pyversions/pydantic-open-inference)](https://pypi.python.org/pypi/pydantic-open-inference)
8
+ [![Actions status](https://github.com/mam-dev/pydantic-open-inference/actions/workflows/ci.yaml/badge.svg)](https://github.com/mam-dev/pydantic-open-inference/actions)
9
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
10
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
11
+ [![Mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://www.mypy-lang.org)
12
+
13
+ ## What is it?
14
+ Provides a client-side python interface for calling [open inference servers](https://kserve.github.io/website/docs/concepts/architecture/data-plane/v2-protocol).
15
+ Define [pydantic](https://docs.pydantic.dev/latest/) models for the model inputs and outputs, and it gets automatically
16
+ converted to and from the open inference protocol, removing the need for boilerplate
17
+ coding handling raw tensors and their shapes.
18
+
19
+ ## Features
20
+ * ✅ Simple, type-safe API toward an open inference server using user-defined pydantic models
21
+ * ✅ Automatic shape inference from pydantic models
22
+ * ✅ Automatic datatype mapping (`int`→INT64, `float`→FP32, `str`→BYTES)
23
+ * ✅ Datatype override for fine-grained control of model inputs
24
+ * ✅ Automatically request only a subset of model outputs by omitting fields in your pydantic model
25
+ * ✅ Take advantage of the full power of pydantic to validate and type-coerce your model calls
26
+ * ✅ Multi-input/output models - each input/output maps to a pydantic field
27
+ * ✅ Custom timeout configuration - configurable per model
28
+ * ✅ Support calling inference for versioned and unversioned models
29
+ * ✅ Error handling with custom exceptions - handle, e.g., specific error codes from the inference server differently
30
+ * ✅ Fully typed - use, e.g., [mypy](https://mypy-lang.org/) and its [pydantic plugin](https://docs.pydantic.dev/latest/integrations/mypy/) to type check your code
31
+
32
+ ## Installation
33
+ Installation using [pip](https://github.com/pypa/pip):
34
+ ```bash
35
+ pip install pydantic-open-inference
36
+ ```
37
+
38
+ Adding it to your project dependencies using [uv](https://docs.astral.sh/uv/):
39
+ ```bash
40
+ uv add pydantic-open-inference
41
+ ```
42
+
43
+ ## How to use it
44
+ Assume we have an inference server serving an Open Inference V2 REST API at `http://localhost:8080`
45
+ and that the server contains a model named `example_model`, which has the following model **inputs**
46
+
47
+ | name | shape | datatype | description | example data |
48
+ |------|-------|----------|--------------------------|--------------------|
49
+ | text | [1] | BYTES | A single string of text. | `["This is a text"]` |
50
+
51
+ and the following model **outputs**:
52
+
53
+ | name | shape | datatype | description | example data |
54
+ |----------|---------|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------|
55
+ | entities | [-1, 4] | BYTES | <div style="width:200px">A list of entities extracted from the text. Each entity is made up of (in order) a label (either "order-id" or "invoice-id"), a confidence score, a start index for the entity in the text, and an end index.</div> | `[["order-id", "0.9", "23", "30"],["invoice-id","0.8","34","46"]]` |
56
+
57
+ We then define a pydantic model for the inputs, inheriting from `InputsBaseModel`.
58
+ Each model input name needs to correspond to a field in the pydantic model, and the type of the
59
+ field needs to make sense given the shape and datatype of the input. In this case, there
60
+ is just a single input named "text" that is supposed to hold a single text, so we simply define
61
+ ```python
62
+ from pydantic_open_inference import InputsBaseModel
63
+
64
+ class ExampleModelInputs(InputsBaseModel):
65
+ text: str
66
+
67
+ ```
68
+
69
+ In the same way, we define a pydantic model for the outputs, inheriting from `OutputsBaseModel`.
70
+ There is one model outputs named "entities", so that will be the only field we define.
71
+ There can be any number of entities (corresponding to the -1 in the shape), so we define it to
72
+ be a `list`. A list of what? Here we have some choices. We could simply define `entities: list[list[str]]`
73
+ and get each entity as a list of strings, e.g., `["order-id", "0.98", "12", "24"]`, but this is not
74
+ very useful to us, so we instead define, e.g., a _named tuple_ with ordered, named, and typed fields:
75
+ ```python
76
+ from typing import NamedTuple
77
+ from enum import StrEnum
78
+ from pydantic_open_inference import OutputsBaseModel
79
+
80
+ class Label(StrEnum):
81
+ ORDER_ID = "order-id"
82
+ INVOICE_ID = "invoice-id"
83
+
84
+ class Entity(NamedTuple):
85
+ label: Label
86
+ score: float
87
+ start: int
88
+ end: int
89
+
90
+ class ExampleModelOutputs(OutputsBaseModel):
91
+ entities: list[Entity]
92
+ ```
93
+ Note that we could also have used `typing.Literal` instead of `enum.StrEnum`, and even added further validation,
94
+ e.g., that the score is between 0.0 and 1.0, and that the start index is not negative etc. Pydantic offers many
95
+ possibilities, but for now, let us stick with this.
96
+
97
+ We now create a `RemoteModel` instance. The `RemoteModel` class represents a model in the inference server,
98
+ in this case our "example_model". Instantiation requires the URL of the inference server, the name of the
99
+ model, and the pydantic models we defined for the input and output:
100
+ ```python
101
+ from pydantic_open_inference import RemoteModel
102
+
103
+ INFERENCE_SERVER_URL = "http://localhost:8080"
104
+
105
+ example_model = RemoteModel(
106
+ server_url=INFERENCE_SERVER_URL,
107
+ model_name="example_model",
108
+ inputs_model=ExampleModelInputs,
109
+ outputs_model=ExampleModelOutputs,
110
+ )
111
+ ```
112
+
113
+ In order to call the inference server, we simply call the `RemoteModel.infer` method, giving
114
+ it an instance of `ExampleModelInputs`, and get back an instance of `ExampleModelOutputs`:
115
+ ```python
116
+ example_input = ExampleModelInputs(text="Your order id is 123456.")
117
+ example_output = example_model.infer(example_input)
118
+ # ExampleModelOutputs(
119
+ # entities=[
120
+ # Entity(label=Label.ORDER_ID, score=0.95, start=17, end=23),
121
+ # ]
122
+ # )
123
+ ```
124
+ We have thus not only made a perfectly type-safe and validated call to the inference server,
125
+ we have also gotten the response back in a useful format! We never had to bother with
126
+ handling the shape of the data, parsing it in row-major order etc, as all of that is handled under the hood!
127
+
128
+ The only time you need to think about "shape" is when defining the fields of the pydantic models.
129
+ Use types like, e.g., `list`, `tuple`, and `typing.NamedTuple`, to type the pydantic-model fields
130
+ so that they map sensibly to their corresponding model input/output. Each input/output name must
131
+ match to a pydantic field and the shape must correspond to the structure of the field, e.g.,
132
+ `list[tuple[int, int]]` for shape [-1, 2], or `tuple[tuple[float, float], tuple[float, float]]`
133
+ for shape [2, 2], etc.
134
+
135
+ The "datatype" of the model _inputs_ is automatically set based on the innermost type of the
136
+ corresponding field in the `InputsBaseModel` subclass, according to:
137
+
138
+ | python type | datatype |
139
+ |-------------|----------|
140
+ | bool | BOOL |
141
+ | int | INT64 |
142
+ | float | FP32 |
143
+ | str | BYTES |
144
+
145
+ However, you can always _override_ the datatype of the model inputs to whatever you want using
146
+ `pydantic_open_inference.DatatypeOverride`; see its docstring for more information.
147
+ The datatypes of the model _outputs_ are disregarded; all that matters is
148
+ that the data we got back from the model can be mapped to the defined `OutputsBaseModel` subclass.
149
+
150
+ When an inference call to a model is made, it automatically requests only the specific outputs
151
+ for which you have defined fields in your `OutputsBaseModel` subclass. So if you are not
152
+ interested in an output, simply do not define a pydantic field for it.
153
+
154
+ ## Examples
155
+
156
+ See the [examples directory](examples) for more examples.
@@ -0,0 +1,197 @@
1
+ [project]
2
+ name = "pydantic-open-inference"
3
+ version = "1.0.0"
4
+ description = "Call an Open Inference server using pydantic models defining your model inputs and outputs."
5
+ authors = [{ name = "Anton Vikström" }]
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ readme = "README.md"
9
+ requires-python = ">=3.10"
10
+ keywords = [
11
+ "pydantic",
12
+ "open inference",
13
+ "kserve",
14
+ "triton",
15
+ "machine learning",
16
+ "ml",
17
+ "inference",
18
+ ]
19
+ classifiers = [
20
+ "Natural Language :: English",
21
+ "Programming Language :: Python",
22
+ "Programming Language :: Python :: 3 :: Only",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Programming Language :: Python :: 3.14",
29
+ "Operating System :: OS Independent",
30
+ "Typing :: Typed",
31
+ "Intended Audience :: Science/Research",
32
+ "Intended Audience :: Developers",
33
+ "Intended Audience :: System Administrators",
34
+ "Intended Audience :: Other Audience",
35
+ "Topic :: Software Development :: Libraries",
36
+ "Topic :: Software Development :: Libraries :: Python Modules",
37
+ "Topic :: Scientific/Engineering",
38
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
39
+ "Framework :: Pydantic",
40
+ "Framework :: Pydantic :: 2",
41
+ "Development Status :: 5 - Production/Stable",
42
+ ]
43
+ dependencies = [
44
+ "httpx==0.28.*",
45
+ "more-itertools==10.* ; python_full_version < '3.13'",
46
+ "pydantic==2.*",
47
+ ]
48
+
49
+ [dependency-groups]
50
+ dev = [
51
+ "coverage>=7.0.0",
52
+ "mypy>=1.18.2",
53
+ "pytest>=9.0.0",
54
+ "pytest-cov>=7.0.0",
55
+ "pytest-integration>=0.2.3",
56
+ "pytest-random-order>=1.2.0",
57
+ "ruff>=0.14.4",
58
+ "typing-extensions>=4.15.0 ; python_full_version < '3.12'",
59
+ ]
60
+
61
+ [build-system]
62
+ requires = ["uv_build>=0.9.8,<0.10.0"]
63
+ build-backend = "uv_build"
64
+
65
+ [project.urls]
66
+ Repository = "https://github.com/mam-dev/pydantic-open-inference"
67
+
68
+ [tool.uv]
69
+ cache-keys = [{ file = "pyproject.toml" }, { git = true }]
70
+
71
+ [[tool.uv.index]]
72
+ url = "https://pypi.python.org/simple"
73
+ default = true
74
+
75
+ [tool.coverage.run]
76
+ branch = true
77
+ source = ["src", "examples"]
78
+
79
+ [tool.coverage.report]
80
+ show_missing = true
81
+ fail_under = 100
82
+
83
+ [tool.pytest.ini_options]
84
+ addopts = ["--cov", "--random-order"]
85
+ testpaths = ["tests"]
86
+
87
+ [tool.mypy]
88
+ files = ["src", "tests", "examples"]
89
+ plugins = ["pydantic.mypy"]
90
+ pretty = true
91
+ warn_no_return = true
92
+ warn_return_any = true
93
+ warn_unused_configs = true
94
+ warn_unused_ignores = true
95
+ warn_redundant_casts = true
96
+ warn_unreachable = true
97
+ check_untyped_defs = true
98
+ disallow_any_generics = true
99
+ disallow_subclassing_any = true
100
+ disallow_untyped_calls = true
101
+ disallow_untyped_defs = true
102
+ disallow_incomplete_defs = true
103
+ disallow_untyped_decorators = true
104
+ no_implicit_optional = true
105
+ no_implicit_reexport = true
106
+ strict_equality = true
107
+ extra_checks = true
108
+ enable_error_code = """
109
+ explicit-override,\
110
+ deprecated,\
111
+ truthy-iterable,\
112
+ ignore-without-code,\
113
+ possibly-undefined,\
114
+ redundant-expr,\
115
+ truthy-bool"""
116
+
117
+ [tool.pydantic-mypy]
118
+ init_forbid_extra = true
119
+ init_typed = true
120
+ warn_required_dynamic_aliases = true
121
+ warn_untyped_fields = true
122
+
123
+ [tool.ruff]
124
+ line-length = 120
125
+ extend-include = ["tests/**/.py", "examples/**/*.py"]
126
+
127
+ [tool.ruff.lint]
128
+ select = [
129
+ "E", # pycodestyle errors
130
+ "W", # pycodestyle warnings
131
+ "F", # pyflakes
132
+ "UP", # pyupgrade
133
+ "S", # flake8-bandit
134
+ "D", # pydocstyle
135
+ "PT", # flake8-pytest-style
136
+ "I", # isort
137
+ "RUF", # Ruff-specific rules
138
+ "PTH", # flake8-use-pathlib
139
+ "ERA", # eradicate
140
+ "PL", # pylint
141
+ "FBT", # flake8-boolean-trap
142
+ "B", # flake8-bugbear
143
+ "A", # flake8-builtins
144
+ "ISC", # flake8-implicit-str-concat
145
+ "INP", # flake8-no-pep420
146
+ "SLF", # flake8-self
147
+ "SIM", # flake8-simplify
148
+ "TID", # flake8-tidy-imports
149
+ "ARG", # flake8-unused-arguments
150
+ "TRY", # tryceratops
151
+ "FLY", # flynt
152
+ "RSE", # flake8-raise
153
+ "RET", # flake8-return
154
+ "FIX", # flake8-fixme
155
+ "Q", # flake8-quotes
156
+ "C4", # flake8-comprehensions
157
+ "DTZ", # flake8-datetimez
158
+ "T10", # flake8-debugger
159
+ "T20", # flake8-print
160
+ "TCH", # flake8-type-checking
161
+ "ASYNC", # flake8-async
162
+ "BLE", # flake8-blind-except
163
+ "LOG", # flake8-logging
164
+ "SLOT", # flake8-slots
165
+ "NPY", # Numpy-specific rules
166
+ "PD", # pandas-vet
167
+ "PERF", # Perflint
168
+ ]
169
+
170
+ [tool.ruff.lint.per-file-ignores]
171
+ "tests/**/*.py" = [
172
+ "D101", # undocumented public class
173
+ "D103", # undocumented public function
174
+ "FBT001", # boolean positional arg
175
+ "SLF001", # private member access
176
+ "PLR2004", # magic-value compare
177
+ "PLR0913", # too many args
178
+ "ARG001", # unused arg
179
+ "S101", # use of 'assert'
180
+ ]
181
+ "examples/**/*.py" = [
182
+ "T201", # print statement
183
+ "INP001", # implicit namespace
184
+ "ERA001", # commented-out code
185
+ ]
186
+
187
+ [tool.ruff.lint.pydocstyle]
188
+ convention = "google"
189
+
190
+ [tool.ruff.lint.flake8-pytest-style]
191
+ fixture-parentheses = false
192
+ mark-parentheses = false
193
+ parametrize-names-type = "csv"
194
+ parametrize-values-type = "list"
195
+
196
+ [tool.ruff.lint.flake8-tidy-imports]
197
+ ban-relative-imports = "parents"
@@ -0,0 +1,14 @@
1
+ """Package for wrapping calls to an Open Inference server REST API."""
2
+
3
+ from ._client import BadStatusCodeFromServerError
4
+ from ._remote_model import InputsBaseModel, OutputsBaseModel, RemoteModel
5
+ from ._utils import DatatypeOverride, PydanticOpenInferenceError
6
+
7
+ __all__ = (
8
+ "BadStatusCodeFromServerError",
9
+ "DatatypeOverride",
10
+ "InputsBaseModel",
11
+ "OutputsBaseModel",
12
+ "PydanticOpenInferenceError",
13
+ "RemoteModel",
14
+ )
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import atexit
4
+ import json
5
+ from typing import cast
6
+
7
+ import httpx
8
+
9
+ from ._utils import (
10
+ OpenInferenceAPIInput,
11
+ OpenInferenceAPIOutput,
12
+ OpenInferenceAPIRequestedOutput,
13
+ PydanticOpenInferenceError,
14
+ Singleton,
15
+ )
16
+
17
+
18
+ class OpenInferenceHTTPClientAPIError(PydanticOpenInferenceError):
19
+ """Exceptions from the OpenInferenceHTTPClientAPI."""
20
+
21
+
22
+ class BadStatusCodeFromServerError(OpenInferenceHTTPClientAPIError):
23
+ """A bad status code was returned from the open inference server."""
24
+
25
+ def __init__(self, status_code: int) -> None:
26
+ self._status_code = status_code
27
+ message = f"Inference server responded with HTTP {status_code}"
28
+ super().__init__(message)
29
+
30
+ @property
31
+ def status_code(self) -> int:
32
+ return self._status_code
33
+
34
+
35
+ class OpenInferenceHTTPClientAPI(metaclass=Singleton):
36
+ def __init__(self, base_url: str) -> None:
37
+ if not base_url.endswith("/"):
38
+ base_url += "/"
39
+ self._client = httpx.Client(
40
+ base_url=base_url,
41
+ headers={"Content-Type": "application/json"},
42
+ )
43
+ atexit.register(self._client.close)
44
+
45
+ def infer(
46
+ self,
47
+ model_name: str,
48
+ inputs: list[OpenInferenceAPIInput],
49
+ outputs: list[OpenInferenceAPIRequestedOutput] | None = None,
50
+ model_version: str | None = None,
51
+ timeout_seconds: float | None = None,
52
+ ) -> list[OpenInferenceAPIOutput]:
53
+ try:
54
+ payload: dict[str, list[OpenInferenceAPIInput] | list[OpenInferenceAPIRequestedOutput]] = {"inputs": inputs}
55
+ if outputs is not None:
56
+ payload["outputs"] = outputs
57
+ if model_version is None:
58
+ url = f"v2/models/{model_name}/infer"
59
+ else:
60
+ url = f"v2/models/{model_name}/versions/{model_version}/infer"
61
+ response = self._client.post(
62
+ url,
63
+ json=payload,
64
+ timeout=timeout_seconds,
65
+ ).raise_for_status()
66
+ return cast("list[OpenInferenceAPIOutput]", response.json().get("outputs", []))
67
+ except httpx.HTTPStatusError as error:
68
+ raise BadStatusCodeFromServerError(
69
+ status_code=error.response.status_code,
70
+ ) from error
71
+ except (httpx.HTTPError, json.JSONDecodeError) as error:
72
+ raise OpenInferenceHTTPClientAPIError(error) from error
@@ -0,0 +1,256 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar
4
+
5
+ if TYPE_CHECKING:
6
+ import sys
7
+
8
+ if sys.version_info < (3, 11):
9
+ from typing_extensions import Self
10
+ else:
11
+ from typing import Self
12
+
13
+ import pydantic
14
+
15
+ from ._client import OpenInferenceHTTPClientAPI
16
+ from ._utils import (
17
+ OpenInferenceAPIInput,
18
+ OpenInferenceAPIOutput,
19
+ OpenInferenceAPIRequestedOutput,
20
+ get_data,
21
+ get_datatype,
22
+ get_shape,
23
+ is_listlike,
24
+ unflatten_data,
25
+ )
26
+
27
+
28
+ class InputsBaseModel(pydantic.BaseModel):
29
+ """Base class for model inputs.
30
+
31
+ Subclass this class to define the model inputs, e.g.,
32
+
33
+ class MyModelInput(InputsBaseModel):
34
+ values: list[int]
35
+
36
+ The field names MUST correspond to the names of the model inputs.
37
+
38
+ The structure of a field must be mappable to the shape of the
39
+ corresponding input, e.g.,
40
+ - "list[tuple[int, int]]" for a shape of [-1, 2],
41
+ - "tuple[float, float]" for a shape of [2], or
42
+ - "list[tuple[tuple[int, int], tuple[int, int]]]" for a shape of [-1, 2, 2].
43
+ The actual shape in the input that is sent will have the actual size, not -1.
44
+
45
+ NOTE: You can define an input field that is neither a list nor a tuple, just
46
+ a simple datatype like int, float, str; this will correspond to a shape of [1].
47
+
48
+ You can use typing.Namedtuple instead of a tuple to get named fields.
49
+
50
+ Example:
51
+ class Point(typing.Namedtuple):
52
+ x: float
53
+ y: float
54
+
55
+ class ExampleInput(InputsBaseModel):
56
+ points: list[Point] # name: "points", shape: [-1, 2]
57
+ matrix: tuple[tuple[int, int, int], tuple[int, int, int]] # name: "matrix", shape: [2, 3]
58
+ text: str # name: "text", shape: [1]
59
+
60
+ The datatype of an input is automatically set depending on the field type, e.g.,
61
+ for a field that is "list[int]" (or "list[tuple[int, int]]" etc) the datatype
62
+ will be "INT64". The actual mapping is
63
+
64
+ bool: "BOOL",
65
+ int: "INT64",
66
+ float: "FP32",
67
+ str: "BYTES",
68
+
69
+ You can override these defaults using DatatypeOverride, e.g.,
70
+
71
+ class ExampleInput(InputsBaseModel):
72
+ points: list[Point] # datatype: "INT64"
73
+ more_points: Annotated[list[Point], DatatypeOverride("INT32")] # datatype: "INT32"
74
+
75
+ See DatatypeOverride docs for more information.
76
+
77
+ NOTE: Because there is just one datatype per input, it means that fields with a mix of types
78
+ like "tuple[int, float, str]" should be avoided.
79
+
80
+ """
81
+
82
+ model_config = pydantic.ConfigDict(extra="forbid", frozen=True, coerce_numbers_to_str=True)
83
+
84
+ def to_inputs(self) -> list[OpenInferenceAPIInput]:
85
+ return [
86
+ {
87
+ "name": field_name,
88
+ "shape": get_shape(field_value),
89
+ "datatype": get_datatype(field_value, type(self).model_fields[field_name]),
90
+ "data": get_data(field_value),
91
+ }
92
+ for field_name, field_value in self.model_dump(mode="json", warnings=False).items()
93
+ ]
94
+
95
+
96
+ class OutputsBaseModel(pydantic.BaseModel):
97
+ """Base class for model outputs.
98
+
99
+ Subclass this class to define the model inputs, e.g.,
100
+
101
+ class MyModelIOutput(OutputsBaseModel):
102
+ values: list[float]
103
+
104
+ The field names MUST correspond to the names of the model outputs.
105
+
106
+ The structure of a field must be mappable to the shape of the
107
+ corresponding output, e.g.,
108
+ - "list[tuple[int, int]]" for a shape of [-1, 2],
109
+ - "tuple[float, float]" for a shape of [2], or
110
+ - "list[tuple[tuple[int, int], tuple[int, int]]]" for a shape of [-1, 2, 2].
111
+
112
+ NOTE: You can define an output field that is neither a list nor a tuple, just
113
+ a simple datatype like int, float, str; this will correspond to a shape of [1].
114
+
115
+ You can use typing.Namedtuple instead of a tuple to get named fields.
116
+
117
+ Example:
118
+ class Point(typing.Namedtuple):
119
+ x: float
120
+ y: float
121
+
122
+ class ExampleInput(OutputsBaseModel):
123
+ points: list[Point] # name: "points", shape: [-1, 2]
124
+ matrix: tuple[tuple[int, int, int], tuple[int, int, int]] # name: "matrix", shape: [2, 3]
125
+ text: str # name: "text", shape: [1]
126
+
127
+ You can take advantage of pydantic's type coercion by defining the field types
128
+ as you please, including mixed values.
129
+
130
+ Example:
131
+ Model outputs of the form
132
+ [
133
+ {
134
+ "name": "labels",
135
+ "datatype": "BYTES",
136
+ "shape": [3, 2],
137
+ "data": [
138
+ "SPAM",
139
+ "0.8",
140
+ "HAM",
141
+ "0.3",
142
+ "SPAM",
143
+ "0.9"
144
+ ]
145
+ }
146
+ ]
147
+
148
+ can be, using this OutputsBaseModel:
149
+
150
+ class LabelAndProbability(typing.Namedtuple):
151
+ label: typing.Literal["SPAM", "HAM"]
152
+ probability: float
153
+
154
+ class ExampleInput(OutputsBaseModel):
155
+ labels: list[LabelAndProbability]
156
+
157
+ coerced to
158
+
159
+ ExampleInput(
160
+ labels=[
161
+ LabelAndProbability(label="SPAM", probability=0.8),
162
+ LabelAndProbability(label="HAM", probability=0.3),
163
+ LabelAndProbability(label="SPAM", probability=0.9),
164
+ ]
165
+ )
166
+
167
+ i.e., we get named fields of the desired type.
168
+
169
+ """
170
+
171
+ model_config = pydantic.ConfigDict(extra="forbid", frozen=True, coerce_numbers_to_str=True)
172
+
173
+ @classmethod
174
+ def from_outputs(cls: type[Self], outputs: list[OpenInferenceAPIOutput]) -> Self:
175
+ values: dict[str, Any] = {}
176
+ for output in outputs:
177
+ value = unflatten_data(output["shape"], output["data"])
178
+ if output["shape"] == [1] and not is_listlike(cls.model_fields[output["name"]].annotation):
179
+ values[output["name"]] = value[0]
180
+ else:
181
+ values[output["name"]] = value
182
+ return cls(**values)
183
+
184
+ @classmethod
185
+ def get_requested_outputs(cls) -> list[OpenInferenceAPIRequestedOutput]:
186
+ return [{"name": field_name} for field_name in cls.model_fields]
187
+
188
+
189
+ InputsModelT = TypeVar("InputsModelT", bound=InputsBaseModel)
190
+ OutputsModelT = TypeVar("OutputsModelT", bound=OutputsBaseModel)
191
+
192
+
193
+ class RemoteModel(Generic[InputsModelT, OutputsModelT]):
194
+ """Interface toward a remote model called via the Open Inference protocol.
195
+
196
+ Define the server, model, inputs, and outputs, then call
197
+ the "infer" method to call the remote model, automatically
198
+ handling conversion between the inputs/outputs models and
199
+ the Open Inference protocol.
200
+
201
+ This class is designed to be thread-safe.
202
+
203
+ """
204
+
205
+ def __init__( # noqa: PLR0913
206
+ self,
207
+ *,
208
+ model_name: str,
209
+ inputs_model: type[InputsModelT],
210
+ outputs_model: type[OutputsModelT],
211
+ server_url: str,
212
+ request_timeout_seconds: float | None = None,
213
+ model_version: str | None = None,
214
+ ) -> None:
215
+ """Initialize a RemoteModel.
216
+
217
+ Args:
218
+ model_name: The name of the model in the inference server.
219
+ inputs_model: InputsBaseModel subclass defining the model inputs.
220
+ outputs_model: OutputsBaseModel subclass defining the model outputs.
221
+ server_url: The URL of the inference server (not including the
222
+ API version "v2/..." and onward.)
223
+ request_timeout_seconds: Optional timeout (seconds) for requests to the inference server.
224
+ model_version: Optional model version.
225
+
226
+ """
227
+ self._model_name = model_name
228
+ self._model_version = model_version
229
+ self._inputs_model = inputs_model
230
+ self._outputs_model = outputs_model
231
+ self._client_API = OpenInferenceHTTPClientAPI(base_url=server_url)
232
+ self._request_timeout_seconds = request_timeout_seconds
233
+
234
+ def infer(self, inputs: InputsModelT) -> OutputsModelT:
235
+ """Use the remote model for inference.
236
+
237
+ Args:
238
+ inputs: The model inputs in the form of the InputsBaseModel
239
+ subclass defined for this RemoteModel instance.
240
+
241
+ Returns:
242
+ outputs: The model outputs in the form of the OutputsBaseModel
243
+ subclass defined for this RemoteModel instance.
244
+
245
+ """
246
+ if not isinstance(inputs, self._inputs_model):
247
+ raise TypeError(f"Bad inputs type: {type(inputs)}") # noqa: TRY003
248
+ return self._outputs_model.from_outputs(
249
+ self._client_API.infer(
250
+ model_name=self._model_name,
251
+ model_version=self._model_version,
252
+ inputs=inputs.to_inputs(),
253
+ outputs=self._outputs_model.get_requested_outputs(),
254
+ timeout_seconds=self._request_timeout_seconds,
255
+ )
256
+ )
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ import itertools
4
+ import sys
5
+ import types
6
+ from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, TypedDict
7
+
8
+ if sys.version_info < (3, 11):
9
+ from typing_extensions import NotRequired # pragma: no cover
10
+ else:
11
+ from typing import NotRequired # pragma: no cover
12
+
13
+ if sys.version_info < (3, 12):
14
+ from typing_extensions import override # pragma: no cover
15
+ else:
16
+ from typing import override # pragma: no cover
17
+
18
+ if sys.version_info < (3, 13):
19
+ from more_itertools import batched # pragma: no cover
20
+ else:
21
+ from itertools import batched # pragma: no cover
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Mapping
24
+
25
+ from pydantic.fields import FieldInfo
26
+
27
+ Shape = list[int]
28
+ Datatype = Literal[
29
+ "BOOL",
30
+ "UINT8",
31
+ "UINT16",
32
+ "UINT32",
33
+ "UINT64",
34
+ "INT8",
35
+ "INT16",
36
+ "INT32",
37
+ "INT64",
38
+ "FP16",
39
+ "FP32",
40
+ "FP64",
41
+ "BYTES",
42
+ ]
43
+ Data = list[Any]
44
+ _TYPE_TO_DATATYPE_MAP: Final[Mapping[type, Datatype]] = {
45
+ bool: "BOOL",
46
+ int: "INT64",
47
+ float: "FP32",
48
+ str: "BYTES",
49
+ }
50
+
51
+
52
+ class PydanticOpenInferenceError(Exception):
53
+ """Package base exception."""
54
+
55
+
56
+ class ShapeDataMismatchError(PydanticOpenInferenceError):
57
+ """Exception raised when shape and data do not match."""
58
+
59
+
60
+ class DatatypeOverride:
61
+ """Use with typing.Annotated to override the default datatype in inputs.
62
+
63
+ In this example InputsBaseModel, the datatype of
64
+ "values" would be "INT64":
65
+
66
+ class IntInputsBaseModel(InputsBaseModel):
67
+ values: list[int]
68
+
69
+ Using DatatypeOverride, we can instead force the datatype
70
+ to be anything we want, e.g., "INT16":
71
+
72
+ class IntInputsBaseModel(InputsBaseModel):
73
+ values: Annotated[list[int], DatatypeOverride("INT16")]
74
+
75
+ Note that this simply sets the datatype as given. There
76
+ are no additional checks (for sign, size, etc).
77
+
78
+ """
79
+
80
+ __slots__ = ("_datatype",)
81
+
82
+ def __init__(self, datatype: Datatype) -> None:
83
+ self._datatype = datatype
84
+
85
+ @property
86
+ def datatype(self) -> Datatype:
87
+ return self._datatype
88
+
89
+
90
+ def is_flat(data: Data) -> bool:
91
+ return not data or not any(isinstance(x, (list, tuple)) for x in data)
92
+
93
+
94
+ def parse_row_major_order(shape: Shape, data: list[Any]) -> list[Any]:
95
+ if len(shape) == 1:
96
+ if shape[0] != len(data):
97
+ raise ShapeDataMismatchError
98
+ return data
99
+ new_shape: Shape = list(shape[:-1])
100
+ return parse_row_major_order(new_shape, list(batched(data, n=shape[-1], strict=True)))
101
+
102
+
103
+ def is_listlike(annotation: type[Any] | None) -> bool:
104
+ if annotation is None:
105
+ return False
106
+ if isinstance(annotation, types.GenericAlias):
107
+ annotation = annotation.__origin__ # type: ignore[unreachable]
108
+ return any(t in (list, tuple) for t in itertools.chain((annotation,), annotation.__bases__))
109
+
110
+
111
+ def unflatten_data(shape: Shape, data: Data) -> Data:
112
+ if is_flat(data):
113
+ return parse_row_major_order(shape, data)
114
+ return data
115
+
116
+
117
+ def get_shape(value: Any) -> Shape:
118
+ shape: Shape = []
119
+ while isinstance(value, (list, tuple)):
120
+ shape.append(len(value))
121
+ value = value[0]
122
+ return shape or [1]
123
+
124
+
125
+ def get_datatype(value: Any, field_info: FieldInfo) -> Datatype:
126
+ overrides: list[DatatypeOverride] = [x for x in field_info.metadata if isinstance(x, DatatypeOverride)]
127
+ if overrides:
128
+ return overrides[0].datatype
129
+ while isinstance(value, (list, tuple)):
130
+ value = value[0]
131
+ return _TYPE_TO_DATATYPE_MAP[type(value)]
132
+
133
+
134
+ def get_data(value: Any) -> Data:
135
+ if not isinstance(value, (tuple, list)):
136
+ return [value]
137
+ return list(value)
138
+
139
+
140
+ class _OpenInferenceAPIPut(TypedDict):
141
+ name: str
142
+ shape: Shape
143
+ datatype: Datatype
144
+ data: Data
145
+
146
+
147
+ class OpenInferenceAPIInput(_OpenInferenceAPIPut):
148
+ parameters: NotRequired[dict[str, Any]]
149
+
150
+
151
+ class OpenInferenceAPIRequestedOutput(TypedDict):
152
+ name: str
153
+ parameters: NotRequired[dict[str, Any]]
154
+
155
+
156
+ class OpenInferenceAPIOutput(_OpenInferenceAPIPut):
157
+ parameters: NotRequired[dict[str, Any]]
158
+
159
+
160
+ class Singleton(type):
161
+ _instances: ClassVar[dict[tuple[type, tuple[Any, ...], str], Singleton]] = {}
162
+
163
+ @override
164
+ def __call__(cls, *args, **kwargs): # type: ignore[no-untyped-def]
165
+ key = (cls, args, str(kwargs))
166
+ if key not in cls._instances:
167
+ cls._instances[key] = super().__call__(*args, **kwargs)
168
+ return cls._instances[key]