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
6 changes: 5 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@ repos:
hooks:
- id: yamlfmt
args: [--mapping, '2', --sequence, '4', --offset, '2', --preserve-quotes, --implicit_start, --width, '1500']
exclude: tests/fixtures/schema-enum.yaml
exclude: |
(?x)(
^tests/fixtures/schema-enum.yaml|
^tests/fixtures/paths-parameter-querystring.yaml
)

- repo: https://github.com/astral-sh/uv-pre-commit
# uv version.
Expand Down
44 changes: 44 additions & 0 deletions docs/source/advanced.rst
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,50 @@ The main difference in the async use of the streaming is await & async for.
session.close()


Sequential Media Types
^^^^^^^^^^^^^^^^^^^^^^
`Sequential Media Types <https://spec.openapis.org/oas/latest#sequential-media-types>`_ as defined in OpenAPI 3.2 allow
streaming encoded objects using itemSchema.


:meth:`~aiopenapi3.request.RequestBase.sequence` is a Generator/contextmanager returning an headers and the Iterator
streaming the sequence.

* :class:`aiopenapi3.request.AsyncRequestBase.sequence`
* :class:`aiopenapi3.request.AsyncRequestBase.EventIterator`

.. rubric:: asyncio

.. code:: python

req = api.createRequest("json_seq")
async with req.sequence() as (headers, sequence):
async for event in sequence:
print(event)


* :class:`aiopenapi3.request.RequestBase.sequence`
* :class:`aiopenapi3.request.RequestBase.EventIterator`


.. rubric:: sync

.. code:: python

req = api.createRequest("json_seq")
with req.sequence() as (headers, sequence):
for event in sequence:
print(event)


aiopenapi3 supports sequential media for the following content types:

* application/json-seq e.g. '\x1E{…}\n via jsonseq
* application/jsonl e.g. '{…}\n' http.Response.iter_lines http.Response.aiter_lines
* application/x-ndjson e.g. '{…}\n' http.Response.iter_lines http.Response.aiter_lines
* text/event-stream e.g. '[{…},' via ijson


Non-JSON Content
^^^^^^^^^^^^^^^^
In case the content is not a model (application/octet-stream), the data can be read iteratively and written/processed.
Expand Down
2 changes: 2 additions & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ While aiopenapi3 supports some of the more exotic features of the Swagger/OpenAP
* Swagger 2.0
* OpenAPI 3.0
* OpenAPI 3.1
* OpenAPI 3.2

* multi file description documents
* recursive schemas
Expand All @@ -42,6 +43,7 @@ While aiopenapi3 supports some of the more exotic features of the Swagger/OpenAP
* :ref:`advanced:Forms`
* :ref:`advanced:mutualTLS` authentication
* :ref:`Request <advanced:Request Streaming>` and :ref:`Response <advanced:Response Streaming>` streaming to reduce memory usage
* :ref:`Sequential Media Types <advanced:Sequential Media Types>`
* Culling :ref:`extra:Large Description Documents`

some aspects of the specifications are implemented loose
Expand Down
9 changes: 9 additions & 0 deletions docs/source/use.rst
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,15 @@ scoping the access to the methods.
n.id == user.id
# True

In OpenAPI 3.2, Tags can form hierarchies via the parent attribute.
In this case, resolving the hierarchy is required.

.. code:: python

n = t._.external.partner.x()



Operation Parameters
--------------------

Expand Down
10 changes: 6 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "aiopenapi3"
description = "client and validator for OpenAPI3 3.0, OpenAPI 3.1, Swagger 2.0"
description = "client and validator for OpenAPI3 3.0, OpenAPI 3.1, OpenAPI 3.2, Swagger 2.0"
authors = [
{name = "Markus Kötter", email = "commonism@users.noreply.github.com"},
]
Expand All @@ -11,8 +11,10 @@ dependencies = [
"yarl",
"httpx",
"more-itertools",
'typing_extensions; python_version<"3.10"',
'typing_extensions; python_version<"3.12"',
"jmespath",
"ijson",
"jsonseq",
]
requires-python = ">=3.10"
readme = "README.md"
Expand Down Expand Up @@ -104,8 +106,8 @@ filterwarnings = [
"ignore:'flask.Markup' is deprecated and will be removed in Flask 2.4. Import 'markupsafe.Markup' instead.:DeprecationWarning",
"ignore:unclosed resource <TCPTransport:ResourceWarning",
"ignore:co_lnotab is deprecated, use co_lines instead:DeprecationWarning",
"ignore:'asyncio.AbstractEventLoopPolicy' is deprecated and slated for removal in Python 3.16:DeprecationWarning",
"ignore:'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16:DeprecationWarning"
# "ignore:'asyncio.AbstractEventLoopPolicy' is deprecated and slated for removal in Python 3.16:DeprecationWarning",
# "ignore:'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16:DeprecationWarning"
# "ignore::pydantic.warnings.UnsupportedFieldAttributeWarning",
]
asyncio_mode = "strict"
Expand Down
5 changes: 5 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,12 @@ idna==3.11
# email-validator
# httpx
# yarl
ijson==3.5.0
# via aiopenapi3
jmespath==1.1.0
# via aiopenapi3
jsonseq==1.0.0
# via aiopenapi3
more-itertools==10.8.0
# via aiopenapi3
multidict==6.7.1
Expand All @@ -43,6 +47,7 @@ pyyaml==6.0.3
# via aiopenapi3
typing-extensions==4.15.0
# via
# aiopenapi3
# anyio
# exceptiongroup
# multidict
Expand Down
6 changes: 4 additions & 2 deletions src/aiopenapi3/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from pydantic import BaseModel


from . import v20, v30, v31
from . import v20, v30, v31, v32

if TYPE_CHECKING:
pass
Expand Down Expand Up @@ -38,11 +38,12 @@
ParameterType = Union[v20.Parameter, v30.Parameter, v31.Parameter]
HeaderType = Union[v20.Header, v30.Header, v31.Header]
RequestType = Union[v20.Request, v30.Request]
AsyncRequestType = Union[v20.AsyncRequest, v30.AsyncRequest]
MediaTypeType = Union[v30.MediaType, v31.MediaType]
ExpectedType = Union[v20.Response, MediaTypeType]
ResponseHeadersType = dict[str, Union[str, BaseModel, list[BaseModel]]]
ResponseDataType = Union[BaseModel, bytes, str]

TagType = Union[v20.Tag, v30.Tag, v32.Tag]

YAMLLoaderType = Union[type[yaml.Loader], type[yaml.CLoader], type[yaml.SafeLoader], type[yaml.CSafeLoader]]

Expand All @@ -66,6 +67,7 @@
"MediaTypeType",
"ResponseHeadersType",
"ResponseDataType",
"TagType",
"RequestData",
"RequestParameters",
"ReferenceType",
Expand Down
2 changes: 1 addition & 1 deletion src/aiopenapi3/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from aiopenapi3 import OpenAPI
from ._types import SchemaType, JSON, PathItemType, ParameterType, ReferenceType, DiscriminatorType

HTTP_METHODS = frozenset(["get", "delete", "head", "post", "put", "patch", "trace"])
HTTP_METHODS = frozenset(["get", "delete", "head", "post", "put", "patch", "trace", "query"])


class ObjectBase(BaseModel):
Expand Down
26 changes: 19 additions & 7 deletions src/aiopenapi3/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from . import v20
from . import v30
from . import v31
from . import v32
from . import log
from .request import OperationIndex, HTTP_METHODS
from .errors import ReferenceResolutionError, HTTPClientError, HTTPServerError
Expand Down Expand Up @@ -215,6 +216,8 @@ def _parse_obj(cls, document: "JSON") -> "RootType":
return v30.Root.model_validate(document)
elif v[1] == 1:
return v31.Root.model_validate(document)
elif v[1] == 2:
return v32.Root.model_validate(document)
else:
raise ValueError(f"openapi version 3.{v[1]} not supported")
else:
Expand Down Expand Up @@ -307,8 +310,12 @@ def __init__(
Document Plugins get called via OpenAPI.load… - this is processed already
"""
self._root = self._parse_obj(document)
if isinstance(self._root, v32.Root) and self._root.self_:
docref = yarl.URL(str(self._root.self_))
else:
docref = self._base_url

self._documents[self._base_url] = self._root
self._documents[docref] = self._root

self._init_session_factory(session_factory)
self._init_references()
Expand All @@ -328,7 +335,7 @@ def _init_session_factory(self, session_factory):
):
if isinstance(self._root, v20.Root):
self._createRequest = v20.Request
elif isinstance(self._root, (v30.Root, v31.Root)):
elif isinstance(self._root, (v30.Root, v31.Root, v32.Root)):
self._createRequest = v30.Request
else:
raise ValueError(self._root)
Expand All @@ -337,7 +344,7 @@ def _init_session_factory(self, session_factory):
) or (type(session_factory) is type and issubclass(session_factory, httpx.AsyncClient)):
if isinstance(self._root, v20.Root):
self._createRequest = v20.AsyncRequest
elif isinstance(self._root, (v30.Root, v31.Root)):
elif isinstance(self._root, (v30.Root, v31.Root, v32.Root)):
self._createRequest = v30.AsyncRequest
else:
raise ValueError(self._root)
Expand Down Expand Up @@ -392,7 +399,7 @@ def _init_operationindex(self, use_operation_tags: bool) -> bool:
if isinstance(response.schema_, (v20.Schema,)):
response.schema_._get_identity("OP", f"{path}.{m}.{r}")

elif isinstance(self._root, (v30.Root, v31.Root)):
elif isinstance(self._root, (v30.Root, v31.Root, v32.Root)):
allschemas = [
x.components.schemas
for x in filter(has_components, self._documents.values())
Expand Down Expand Up @@ -428,6 +435,8 @@ def _init_operationindex(self, use_operation_tags: bool) -> bool:
self._root.paths = v30.Paths(paths={}, extensions={})
elif isinstance(self._root, v31.Root):
self._root.paths = v31.Paths(paths={}, extensions={})
elif isinstance(self._root, v32.Root):
self._root.paths = v32.Paths(paths={}, extensions={})
else:
raise ValueError(self._root)
else:
Expand Down Expand Up @@ -675,7 +684,7 @@ def url(self) -> yarl.URL:

r = yarl.URL.build(scheme=scheme, host=host, port=port, path=path)
return r
elif isinstance(self._root, (v30.Root, v31.Root)):
elif isinstance(self._root, (v30.Root, v31.Root, v32.Root)):
assert self._root.servers
server: "ServerType" = self._server_select(self._root.servers)
return self._base_url.join(yarl.URL(server.createUrl(self._server_variables)))
Expand Down Expand Up @@ -761,11 +770,14 @@ def createRequest(self, operationId: str | tuple[str, "HTTPMethodType"]) -> "Req
if pathitem.ref:
pathitem = pathitem.ref._target

operation = getattr(pathitem, method)
if method in HTTP_METHODS:
operation = getattr(pathitem, method)
else: # v32
operation = pathitem.additionalOperations.get(method, None)
assert operation is not None
if isinstance(self._root, v20.Root):
servers = None
elif isinstance(self._root, (v30.Root, v31.Root)):
elif isinstance(self._root, (v30.Root, v31.Root, v32.Root)):
servers = operation.servers or pathitem.servers or self.servers
else:
raise TypeError(self._root)
Expand Down
Loading
Loading