cross-web 0.2.3__py3-none-any.whl

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.
cross/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ from .exceptions import HTTPException
2
+ from .protocols import BaseRequestProtocol
3
+ from .request import AsyncHTTPRequest
4
+ from .request._aiohttp import AiohttpHTTPRequestAdapter
5
+ from .request._base import AsyncHTTPRequestAdapter, FormData, SyncHTTPRequestAdapter
6
+ from .request._chalice import ChaliceHTTPRequestAdapter
7
+ from .request._django import AsyncDjangoHTTPRequestAdapter, DjangoHTTPRequestAdapter
8
+ from .request._flask import AsyncFlaskHTTPRequestAdapter, FlaskHTTPRequestAdapter
9
+ from .request._litestar import LitestarRequestAdapter
10
+ from .request._quart import QuartHTTPRequestAdapter
11
+ from .request._sanic import SanicHTTPRequestAdapter
12
+ from .request._starlette import StarletteRequestAdapter
13
+ from .request._testing import TestingRequestAdapter
14
+ from .response import Cookie, Response
15
+
16
+ __all__ = [
17
+ "AiohttpHTTPRequestAdapter",
18
+ "AsyncDjangoHTTPRequestAdapter",
19
+ "AsyncFlaskHTTPRequestAdapter",
20
+ "AsyncHTTPRequest",
21
+ "AsyncHTTPRequestAdapter",
22
+ "BaseRequestProtocol",
23
+ "ChaliceHTTPRequestAdapter",
24
+ "Cookie",
25
+ "DjangoHTTPRequestAdapter",
26
+ "FlaskHTTPRequestAdapter",
27
+ "FormData",
28
+ "HTTPException",
29
+ "LitestarRequestAdapter",
30
+ "QuartHTTPRequestAdapter",
31
+ "Response",
32
+ "SanicHTTPRequestAdapter",
33
+ "StarletteRequestAdapter",
34
+ "SyncHTTPRequestAdapter",
35
+ "TestingRequestAdapter",
36
+ ]
cross/exceptions.py ADDED
@@ -0,0 +1,8 @@
1
+ class HTTPException(Exception):
2
+ def __init__(self, status_code: int, reason: str) -> None:
3
+ self.status_code = status_code
4
+ self.reason = reason
5
+ super().__init__(reason)
6
+
7
+
8
+ __all__ = ["HTTPException"]
cross/protocols.py ADDED
@@ -0,0 +1,20 @@
1
+ from collections.abc import Mapping
2
+ from typing import Optional, Protocol, Union
3
+
4
+ from .request._base import HTTPMethod
5
+
6
+
7
+ class BaseRequestProtocol(Protocol):
8
+ """Protocol defining the minimal interface for HTTP requests."""
9
+
10
+ @property
11
+ def query_params(self) -> Mapping[str, Optional[Union[str, list[str]]]]: ...
12
+
13
+ @property
14
+ def method(self) -> HTTPMethod: ...
15
+
16
+ @property
17
+ def headers(self) -> Mapping[str, str]: ...
18
+
19
+
20
+ __all__ = ["BaseRequestProtocol"]
cross/py.typed ADDED
File without changes
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Mapping, Optional, Any
4
+
5
+ from typing_extensions import Self
6
+
7
+ if TYPE_CHECKING:
8
+ from starlette.requests import Request as StarletteRequest
9
+
10
+ from ._base import AsyncHTTPRequestAdapter, FormData, HTTPMethod, QueryParams
11
+ from ._starlette import StarletteRequestAdapter
12
+ from ._testing import TestingRequestAdapter
13
+
14
+
15
+ class AsyncHTTPRequest:
16
+ def __init__(self, adapter: AsyncHTTPRequestAdapter) -> None:
17
+ self._adapter = adapter
18
+
19
+ @classmethod
20
+ def from_starlette(cls, request: StarletteRequest) -> Self:
21
+ adapter = StarletteRequestAdapter(request)
22
+
23
+ return cls(adapter)
24
+
25
+ @classmethod
26
+ def from_fastapi(cls, request: StarletteRequest) -> Self:
27
+ return cls.from_starlette(request)
28
+
29
+ @classmethod
30
+ def from_django(cls, request: Any) -> Self:
31
+ # Import here to avoid circular imports and optional Django dependency
32
+ from ._django import AsyncDjangoHTTPRequestAdapter
33
+
34
+ adapter = AsyncDjangoHTTPRequestAdapter(request)
35
+ return cls(adapter)
36
+
37
+ @classmethod
38
+ def from_flask(cls, request: Any) -> Self:
39
+ # Import here to avoid circular imports and optional Flask dependency
40
+ from ._flask import AsyncFlaskHTTPRequestAdapter
41
+
42
+ adapter = AsyncFlaskHTTPRequestAdapter(request)
43
+ return cls(adapter)
44
+
45
+ @classmethod
46
+ def from_sanic(cls, request: Any) -> Self:
47
+ # Import here to avoid circular imports and optional Sanic dependency
48
+ from ._sanic import SanicHTTPRequestAdapter
49
+
50
+ adapter = SanicHTTPRequestAdapter(request)
51
+ return cls(adapter)
52
+
53
+ @classmethod
54
+ def from_aiohttp(cls, request: Any) -> Self:
55
+ # Import here to avoid circular imports and optional aiohttp dependency
56
+ from ._aiohttp import AiohttpHTTPRequestAdapter
57
+
58
+ adapter = AiohttpHTTPRequestAdapter(request)
59
+ return cls(adapter)
60
+
61
+ @classmethod
62
+ def from_quart(cls, request: Any) -> Self:
63
+ # Import here to avoid circular imports and optional Quart dependency
64
+ from ._quart import QuartHTTPRequestAdapter
65
+
66
+ adapter = QuartHTTPRequestAdapter(request)
67
+ return cls(adapter)
68
+
69
+ @classmethod
70
+ def from_litestar(cls, request: Any) -> Self:
71
+ # Import here to avoid circular imports and optional Litestar dependency
72
+ from ._litestar import LitestarRequestAdapter
73
+
74
+ adapter = LitestarRequestAdapter(request)
75
+ return cls(adapter)
76
+
77
+ @classmethod
78
+ def from_form_data(cls, data: Mapping[str, str]) -> Self:
79
+ adapter = TestingRequestAdapter(
80
+ form_data=FormData(files={}, form=data),
81
+ content_type="application/x-www-form-urlencoded",
82
+ )
83
+ return cls(adapter)
84
+
85
+ @property
86
+ def method(self) -> HTTPMethod:
87
+ """The HTTP method of the request."""
88
+ return self._adapter.method
89
+
90
+ @property
91
+ def query_params(self) -> QueryParams:
92
+ """The query parameters of the request."""
93
+ return self._adapter.query_params
94
+
95
+ @property
96
+ def headers(self) -> Mapping[str, str]:
97
+ """The request headers (case-insensitive keys recommended)."""
98
+ return self._adapter.headers
99
+
100
+ @property
101
+ def content_type(self) -> Optional[str]:
102
+ """The 'Content-Type' header value, if present."""
103
+ return self._adapter.content_type
104
+
105
+ @property
106
+ def url(self) -> str:
107
+ """The URL of the request."""
108
+ return self._adapter.url
109
+
110
+ @property
111
+ def cookies(self) -> Mapping[str, str]:
112
+ """The request cookies."""
113
+ return self._adapter.cookies
114
+
115
+ async def get_body(self) -> bytes:
116
+ """Return the raw request body as bytes."""
117
+ return await self._adapter.get_body()
118
+
119
+ async def get_form_data(self) -> FormData:
120
+ """
121
+ Return parsed form data (multipart/form-data or application/x-www-form-urlencoded).
122
+ """
123
+ return await self._adapter.get_form_data()
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ from io import BytesIO
4
+ from typing import TYPE_CHECKING, Any, Mapping, Optional, cast
5
+
6
+ from ._base import AsyncHTTPRequestAdapter, FormData, HTTPMethod, QueryParams
7
+
8
+ if TYPE_CHECKING:
9
+ from aiohttp import web
10
+
11
+
12
+ class AiohttpHTTPRequestAdapter(AsyncHTTPRequestAdapter):
13
+ def __init__(
14
+ self,
15
+ request: web.Request,
16
+ body: Optional[bytes] = None,
17
+ form_data: Optional[FormData] = None,
18
+ ) -> None:
19
+ self.request = request
20
+ self._body = body
21
+ self._form_data = form_data
22
+
23
+ @classmethod
24
+ async def create(cls, request: web.Request) -> "AiohttpHTTPRequestAdapter":
25
+ """Create an adapter and pre-read the body to avoid PayloadAccessError"""
26
+ content_type = request.headers.get("content-type", "")
27
+ form_data = None
28
+ body = None
29
+
30
+ if content_type.startswith("multipart/form-data"):
31
+ # Pre-process multipart data
32
+ reader = await request.multipart()
33
+ data: dict[str, Any] = {}
34
+ files: dict[str, Any] = {}
35
+
36
+ while field := await reader.next():
37
+ from aiohttp.multipart import BodyPartReader
38
+
39
+ assert isinstance(field, BodyPartReader)
40
+ assert field.name
41
+
42
+ if field.filename:
43
+ files[field.name] = BytesIO(await field.read(decode=False))
44
+ else:
45
+ data[field.name] = await field.text()
46
+
47
+ form_data = FormData(files=files, form=data)
48
+ else:
49
+ # For non-multipart requests, read the body
50
+ body = await request.read()
51
+
52
+ return cls(request, body, form_data)
53
+
54
+ @property
55
+ def query_params(self) -> QueryParams:
56
+ return cast(QueryParams, self.request.query.copy()) # type: ignore[attr-defined]
57
+
58
+ async def get_body(self) -> bytes:
59
+ if self._form_data is not None:
60
+ return b""
61
+ if self._body is None:
62
+ self._body = await self.request.read()
63
+ return self._body
64
+
65
+ @property
66
+ def method(self) -> HTTPMethod:
67
+ return cast("HTTPMethod", self.request.method.upper())
68
+
69
+ @property
70
+ def headers(self) -> Mapping[str, str]:
71
+ return self.request.headers
72
+
73
+ async def get_form_data(self) -> FormData:
74
+ if self._form_data is not None:
75
+ return self._form_data
76
+
77
+ if self.content_type and self.content_type.startswith("multipart/form-data"):
78
+ # Process multipart data
79
+ reader = await self.request.multipart()
80
+ data: dict[str, Any] = {}
81
+ files: dict[str, Any] = {}
82
+
83
+ while field := await reader.next():
84
+ from aiohttp.multipart import BodyPartReader
85
+
86
+ assert isinstance(field, BodyPartReader)
87
+ assert field.name
88
+
89
+ if field.filename:
90
+ files[field.name] = BytesIO(await field.read(decode=False))
91
+ else:
92
+ data[field.name] = await field.text()
93
+
94
+ self._form_data = FormData(files=files, form=data)
95
+ return self._form_data
96
+ else:
97
+ # For URL-encoded form data
98
+ post_data = await self.request.post()
99
+ return FormData(files={}, form=dict(post_data))
100
+
101
+ @property
102
+ def content_type(self) -> Optional[str]:
103
+ return self.headers.get("content-type")
104
+
105
+ @property
106
+ def url(self) -> str:
107
+ return str(self.request.url)
108
+
109
+ @property
110
+ def cookies(self) -> Mapping[str, str]:
111
+ return self.request.cookies
cross/request/_base.py ADDED
@@ -0,0 +1,148 @@
1
+ import abc
2
+ from collections.abc import Mapping
3
+ from dataclasses import dataclass
4
+ from typing import Any, Literal, Optional, Union
5
+
6
+ HTTPMethod = Literal[
7
+ "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE"
8
+ ]
9
+
10
+ QueryParams = Mapping[str, Optional[str]]
11
+
12
+
13
+ @dataclass
14
+ class FormData:
15
+ files: Mapping[str, Any]
16
+ form: Mapping[str, Any]
17
+
18
+ def get(self, key: str) -> Any:
19
+ return self.form.get(key)
20
+
21
+
22
+ class SyncHTTPRequestAdapter(abc.ABC):
23
+ """
24
+ Abstract Base Class defining the interface for accessing HTTP request data
25
+ in a framework-agnostic way for synchronous operations.
26
+ """
27
+
28
+ @property
29
+ @abc.abstractmethod
30
+ def method(self) -> HTTPMethod:
31
+ """The HTTP method of the request (e.g., 'GET', 'POST')."""
32
+ raise NotImplementedError
33
+
34
+ @property
35
+ @abc.abstractmethod
36
+ def query_params(self) -> QueryParams:
37
+ """The query parameters of the request."""
38
+ raise NotImplementedError
39
+
40
+ @property
41
+ @abc.abstractmethod
42
+ def headers(self) -> Mapping[str, str]:
43
+ """The request headers. Header names should ideally be case-insensitive."""
44
+ # Note: Real implementations might need to handle case-insensitivity
45
+ raise NotImplementedError
46
+
47
+ @property
48
+ @abc.abstractmethod
49
+ def content_type(self) -> Optional[str]:
50
+ """The 'Content-Type' header value, if present."""
51
+ raise NotImplementedError
52
+
53
+ @property
54
+ @abc.abstractmethod
55
+ def body(self) -> Union[str, bytes]:
56
+ """Return the raw request body as bytes or string."""
57
+ raise NotImplementedError
58
+
59
+ @property
60
+ @abc.abstractmethod
61
+ def post_data(self) -> Mapping[str, Union[str, bytes]]:
62
+ """Return the parsed POST data as a mapping of field names to values."""
63
+ raise NotImplementedError
64
+
65
+ @property
66
+ @abc.abstractmethod
67
+ def files(self) -> Mapping[str, Any]:
68
+ """Return uploaded files from the request."""
69
+ raise NotImplementedError
70
+
71
+ @abc.abstractmethod
72
+ def get_form_data(self) -> FormData:
73
+ """
74
+ Return parsed form data (multipart/form-data or application/x-www-form-urlencoded).
75
+ Returns an empty FormData object if the content type is not form data
76
+ or if parsing fails.
77
+ """
78
+ raise NotImplementedError
79
+
80
+ @property
81
+ @abc.abstractmethod
82
+ def url(self) -> str:
83
+ """The URL of the request."""
84
+ raise NotImplementedError
85
+
86
+ @property
87
+ @abc.abstractmethod
88
+ def cookies(self) -> Mapping[str, str]:
89
+ """The request cookies."""
90
+ raise NotImplementedError
91
+
92
+
93
+ class AsyncHTTPRequestAdapter(abc.ABC):
94
+ """
95
+ Abstract Base Class defining the interface for accessing HTTP request data
96
+ in a framework-agnostic way.
97
+ """
98
+
99
+ @property
100
+ @abc.abstractmethod
101
+ def method(self) -> HTTPMethod:
102
+ """The HTTP method of the request (e.g., 'GET', 'POST')."""
103
+ raise NotImplementedError
104
+
105
+ @property
106
+ @abc.abstractmethod
107
+ def query_params(self) -> QueryParams:
108
+ """The query parameters of the request."""
109
+ raise NotImplementedError
110
+
111
+ @property
112
+ @abc.abstractmethod
113
+ def headers(self) -> Mapping[str, str]:
114
+ """The request headers. Header names should ideally be case-insensitive."""
115
+ # Note: Real implementations might need to handle case-insensitivity
116
+ raise NotImplementedError
117
+
118
+ @property
119
+ @abc.abstractmethod
120
+ def content_type(self) -> Optional[str]:
121
+ """The 'Content-Type' header value, if present."""
122
+ raise NotImplementedError
123
+
124
+ @abc.abstractmethod
125
+ async def get_body(self) -> bytes:
126
+ """Return the raw request body as bytes."""
127
+ raise NotImplementedError
128
+
129
+ @abc.abstractmethod
130
+ async def get_form_data(self) -> FormData:
131
+ """
132
+ Return parsed form data (multipart/form-data or application/x-www-form-urlencoded).
133
+ Returns an empty FormData object if the content type is not form data
134
+ or if parsing fails.
135
+ """
136
+ raise NotImplementedError
137
+
138
+ @property
139
+ @abc.abstractmethod
140
+ def url(self) -> str:
141
+ """The URL of the request."""
142
+ raise NotImplementedError
143
+
144
+ @property
145
+ @abc.abstractmethod
146
+ def cookies(self) -> Mapping[str, str]:
147
+ """The request cookies."""
148
+ raise NotImplementedError
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Mapping, Optional, Union, cast
4
+
5
+ from ._base import FormData, HTTPMethod, QueryParams, SyncHTTPRequestAdapter
6
+
7
+ if TYPE_CHECKING:
8
+ from chalice.app import Request
9
+
10
+
11
+ class ChaliceHTTPRequestAdapter(SyncHTTPRequestAdapter):
12
+ def __init__(self, request: Request) -> None:
13
+ self.request = request
14
+
15
+ @property
16
+ def query_params(self) -> QueryParams:
17
+ return self.request.query_params or {}
18
+
19
+ @property
20
+ def body(self) -> Union[str, bytes]:
21
+ return self.request.raw_body
22
+
23
+ @property
24
+ def method(self) -> HTTPMethod:
25
+ return cast("HTTPMethod", self.request.method.upper())
26
+
27
+ @property
28
+ def headers(self) -> Mapping[str, str]:
29
+ return self.request.headers
30
+
31
+ @property
32
+ def post_data(self) -> Mapping[str, Union[str, bytes]]:
33
+ # Chalice doesn't support traditional form data
34
+ raise NotImplementedError("Chalice does not support form data")
35
+
36
+ @property
37
+ def files(self) -> Mapping[str, Any]:
38
+ # Chalice doesn't support file uploads out of the box
39
+ raise NotImplementedError("Chalice does not support file uploads")
40
+
41
+ def get_form_data(self) -> FormData:
42
+ # Chalice doesn't support form data
43
+ raise NotImplementedError("Chalice does not support form data")
44
+
45
+ @property
46
+ def content_type(self) -> Optional[str]:
47
+ return self.request.headers.get("Content-Type", None)
48
+
49
+ @property
50
+ def url(self) -> str:
51
+ # Construct URL from context
52
+ context = self.request.context
53
+ stage = context.get("stage", "")
54
+ domain = context.get("domainName", "")
55
+ path = context.get("path", "")
56
+
57
+ # Build the URL
58
+ protocol = "https" # API Gateway typically uses HTTPS
59
+ if stage and stage != "prod":
60
+ url = f"{protocol}://{domain}/{stage}{path}"
61
+ else:
62
+ url = f"{protocol}://{domain}{path}"
63
+
64
+ # Add query string if present
65
+ if self.request.query_params:
66
+ from urllib.parse import urlencode
67
+
68
+ query_string = urlencode(self.request.query_params)
69
+ url = f"{url}?{query_string}"
70
+
71
+ return url
72
+
73
+ @property
74
+ def cookies(self) -> Mapping[str, str]:
75
+ # Chalice doesn't have direct cookie support
76
+ # Cookies would come in the Cookie header
77
+ cookie_header = self.request.headers.get("Cookie", "")
78
+ if not cookie_header:
79
+ return {}
80
+
81
+ cookies = {}
82
+ for cookie in cookie_header.split(";"):
83
+ cookie = cookie.strip()
84
+ if "=" in cookie:
85
+ name, value = cookie.split("=", 1)
86
+ cookies[name] = value
87
+
88
+ return cookies
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Mapping, Optional, Union, cast
4
+
5
+ from ._base import (
6
+ AsyncHTTPRequestAdapter,
7
+ FormData,
8
+ HTTPMethod,
9
+ QueryParams,
10
+ SyncHTTPRequestAdapter,
11
+ )
12
+
13
+ if TYPE_CHECKING:
14
+ from django.http import HttpRequest
15
+
16
+
17
+ class DjangoHTTPRequestAdapter(SyncHTTPRequestAdapter):
18
+ def __init__(self, request: HttpRequest) -> None:
19
+ self.request = request
20
+
21
+ @property
22
+ def query_params(self) -> QueryParams:
23
+ return cast(QueryParams, self.request.GET.dict())
24
+
25
+ @property
26
+ def body(self) -> Union[str, bytes]:
27
+ return cast(Union[str, bytes], self.request.body.decode())
28
+
29
+ @property
30
+ def method(self) -> HTTPMethod:
31
+ assert self.request.method is not None
32
+
33
+ return cast("HTTPMethod", self.request.method.upper())
34
+
35
+ @property
36
+ def headers(self) -> Mapping[str, str]:
37
+ return cast(Mapping[str, str], self.request.headers)
38
+
39
+ @property
40
+ def post_data(self) -> Mapping[str, Union[str, bytes]]:
41
+ return cast(Mapping[str, Union[str, bytes]], self.request.POST)
42
+
43
+ @property
44
+ def files(self) -> Mapping[str, Any]:
45
+ return cast(Mapping[str, Any], self.request.FILES)
46
+
47
+ def get_form_data(self) -> FormData:
48
+ return FormData(
49
+ files=cast(Mapping[str, Any], self.request.FILES),
50
+ form=cast(Mapping[str, Union[str, bytes]], self.request.POST),
51
+ )
52
+
53
+ @property
54
+ def content_type(self) -> Optional[str]:
55
+ return cast(Optional[str], self.request.content_type)
56
+
57
+ @property
58
+ def url(self) -> str:
59
+ return cast(str, self.request.build_absolute_uri())
60
+
61
+ @property
62
+ def cookies(self) -> Mapping[str, str]:
63
+ return cast(Mapping[str, str], self.request.COOKIES)
64
+
65
+
66
+ class AsyncDjangoHTTPRequestAdapter(AsyncHTTPRequestAdapter):
67
+ def __init__(self, request: HttpRequest) -> None:
68
+ self.request = request
69
+
70
+ @property
71
+ def query_params(self) -> QueryParams:
72
+ return cast(QueryParams, self.request.GET.dict())
73
+
74
+ @property
75
+ def method(self) -> HTTPMethod:
76
+ assert self.request.method is not None
77
+
78
+ return cast("HTTPMethod", self.request.method.upper())
79
+
80
+ @property
81
+ def headers(self) -> Mapping[str, str]:
82
+ return cast(Mapping[str, str], self.request.headers)
83
+
84
+ @property
85
+ def content_type(self) -> Optional[str]:
86
+ return self.headers.get("Content-type")
87
+
88
+ async def get_body(self) -> bytes:
89
+ return cast(bytes, self.request.body)
90
+
91
+ async def get_form_data(self) -> FormData:
92
+ return FormData(
93
+ files=cast(Mapping[str, Any], self.request.FILES),
94
+ form=cast(Mapping[str, Union[str, bytes]], self.request.POST),
95
+ )
96
+
97
+ @property
98
+ def url(self) -> str:
99
+ return cast(str, self.request.build_absolute_uri())
100
+
101
+ @property
102
+ def cookies(self) -> Mapping[str, str]:
103
+ return cast(Mapping[str, str], self.request.COOKIES)
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Mapping, Optional, Union, cast
4
+
5
+ from ._base import (
6
+ AsyncHTTPRequestAdapter,
7
+ FormData,
8
+ HTTPMethod,
9
+ QueryParams,
10
+ SyncHTTPRequestAdapter,
11
+ )
12
+
13
+ if TYPE_CHECKING:
14
+ from flask import Request
15
+
16
+
17
+ class FlaskHTTPRequestAdapter(SyncHTTPRequestAdapter):
18
+ def __init__(self, request: Request) -> None:
19
+ self.request = request
20
+
21
+ @property
22
+ def query_params(self) -> QueryParams:
23
+ return self.request.args.to_dict()
24
+
25
+ @property
26
+ def body(self) -> Union[str, bytes]:
27
+ return self.request.data.decode()
28
+
29
+ @property
30
+ def method(self) -> HTTPMethod:
31
+ return cast("HTTPMethod", self.request.method.upper())
32
+
33
+ @property
34
+ def headers(self) -> Mapping[str, str]:
35
+ return self.request.headers # type: ignore
36
+
37
+ @property
38
+ def post_data(self) -> Mapping[str, Union[str, bytes]]:
39
+ return self.request.form
40
+
41
+ @property
42
+ def files(self) -> Mapping[str, Any]:
43
+ return self.request.files
44
+
45
+ def get_form_data(self) -> FormData:
46
+ return FormData(
47
+ files=self.request.files,
48
+ form=self.request.form,
49
+ )
50
+
51
+ @property
52
+ def content_type(self) -> Optional[str]:
53
+ return self.request.content_type
54
+
55
+ @property
56
+ def url(self) -> str:
57
+ return self.request.url
58
+
59
+ @property
60
+ def cookies(self) -> Mapping[str, str]:
61
+ return self.request.cookies
62
+
63
+
64
+ class AsyncFlaskHTTPRequestAdapter(AsyncHTTPRequestAdapter):
65
+ def __init__(self, request: Request) -> None:
66
+ self.request = request
67
+
68
+ @property
69
+ def query_params(self) -> QueryParams:
70
+ return self.request.args.to_dict()
71
+
72
+ @property
73
+ def method(self) -> HTTPMethod:
74
+ return cast("HTTPMethod", self.request.method.upper())
75
+
76
+ @property
77
+ def content_type(self) -> Optional[str]:
78
+ return self.request.content_type
79
+
80
+ @property
81
+ def headers(self) -> Mapping[str, str]:
82
+ return self.request.headers # type: ignore
83
+
84
+ async def get_body(self) -> bytes:
85
+ return self.request.data
86
+
87
+ async def get_form_data(self) -> FormData:
88
+ return FormData(
89
+ files=self.request.files,
90
+ form=self.request.form,
91
+ )
92
+
93
+ @property
94
+ def url(self) -> str:
95
+ return self.request.url
96
+
97
+ @property
98
+ def cookies(self) -> Mapping[str, str]:
99
+ return self.request.cookies
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Mapping, Optional, cast
4
+
5
+ from ._base import AsyncHTTPRequestAdapter, FormData, HTTPMethod, QueryParams
6
+
7
+ if TYPE_CHECKING:
8
+ from litestar import Request
9
+
10
+
11
+ class LitestarRequestAdapter(AsyncHTTPRequestAdapter):
12
+ def __init__(self, request: Request[Any, Any, Any]) -> None:
13
+ self.request = request
14
+
15
+ @property
16
+ def query_params(self) -> QueryParams:
17
+ return self.request.query_params
18
+
19
+ @property
20
+ def method(self) -> HTTPMethod:
21
+ return cast("HTTPMethod", self.request.method.upper())
22
+
23
+ @property
24
+ def headers(self) -> Mapping[str, str]:
25
+ return self.request.headers
26
+
27
+ @property
28
+ def content_type(self) -> Optional[str]:
29
+ content_type, params = self.request.content_type
30
+
31
+ # combine content type and params
32
+ if params:
33
+ content_type += "; " + "; ".join(f"{k}={v}" for k, v in params.items())
34
+
35
+ return content_type
36
+
37
+ async def get_body(self) -> bytes:
38
+ return await self.request.body()
39
+
40
+ async def get_form_data(self) -> FormData:
41
+ multipart_data = await self.request.form()
42
+
43
+ return FormData(form=multipart_data, files=multipart_data)
44
+
45
+ @property
46
+ def url(self) -> str:
47
+ return str(self.request.url)
48
+
49
+ @property
50
+ def cookies(self) -> Mapping[str, str]:
51
+ return self.request.cookies
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Mapping, Optional, cast
4
+
5
+ from ._base import AsyncHTTPRequestAdapter, FormData, HTTPMethod, QueryParams
6
+
7
+ if TYPE_CHECKING:
8
+ from quart import Request
9
+
10
+
11
+ class QuartHTTPRequestAdapter(AsyncHTTPRequestAdapter):
12
+ def __init__(self, request: Request) -> None:
13
+ self.request = request
14
+
15
+ @property
16
+ def query_params(self) -> QueryParams:
17
+ return self.request.args.to_dict()
18
+
19
+ @property
20
+ def method(self) -> HTTPMethod:
21
+ return cast("HTTPMethod", self.request.method.upper())
22
+
23
+ @property
24
+ def content_type(self) -> Optional[str]:
25
+ return self.request.content_type
26
+
27
+ @property
28
+ def headers(self) -> Mapping[str, str]:
29
+ return self.request.headers # type: ignore
30
+
31
+ async def get_body(self) -> bytes:
32
+ return await self.request.data
33
+
34
+ async def get_form_data(self) -> FormData:
35
+ files = await self.request.files
36
+ form = await self.request.form
37
+ return FormData(files=files, form=form)
38
+
39
+ @property
40
+ def url(self) -> str:
41
+ return self.request.url
42
+
43
+ @property
44
+ def cookies(self) -> Mapping[str, str]:
45
+ return self.request.cookies
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Mapping, Optional, Union, cast
4
+
5
+ from ._base import AsyncHTTPRequestAdapter, FormData, HTTPMethod, QueryParams
6
+
7
+ if TYPE_CHECKING:
8
+ from sanic.request import File, Request
9
+
10
+
11
+ def convert_request_to_files_dict(request: Request) -> dict[str, Any]:
12
+ """Converts the request.files dictionary to a dictionary of sanic Request objects.
13
+
14
+ `request.files` has the following format, even if only a single file is uploaded:
15
+
16
+ ```python
17
+ {
18
+ "textFile": [
19
+ sanic.request.File(type="text/plain", body=b"strawberry", name="textFile.txt")
20
+ ]
21
+ }
22
+ ```
23
+
24
+ Note that the dictionary entries are lists.
25
+ """
26
+ request_files = cast("Optional[dict[str, list[File]]]", request.files)
27
+
28
+ if not request_files:
29
+ return {}
30
+
31
+ files_dict: dict[str, Union[File, list[File]]] = {}
32
+
33
+ for field_name, file_list in request_files.items():
34
+ assert len(file_list) == 1
35
+
36
+ files_dict[field_name] = file_list[0]
37
+
38
+ return files_dict
39
+
40
+
41
+ class SanicHTTPRequestAdapter(AsyncHTTPRequestAdapter):
42
+ def __init__(self, request: Request) -> None:
43
+ self.request = request
44
+
45
+ @property
46
+ def query_params(self) -> QueryParams:
47
+ # Just a heads up, Sanic's request.args uses urllib.parse.parse_qs
48
+ # to parse query string parameters. This returns a dictionary where
49
+ # the keys are the unique variable names and the values are lists
50
+ # of values for each variable name. To ensure consistency, we're
51
+ # enforcing the use of the first value in each list.
52
+ args = self.request.get_args(keep_blank_values=True)
53
+ return {k: args.get(k, None) for k in args}
54
+
55
+ @property
56
+ def method(self) -> HTTPMethod:
57
+ return cast("HTTPMethod", self.request.method.upper())
58
+
59
+ @property
60
+ def headers(self) -> Mapping[str, str]:
61
+ return self.request.headers
62
+
63
+ @property
64
+ def content_type(self) -> Optional[str]:
65
+ return self.request.content_type
66
+
67
+ async def get_body(self) -> bytes:
68
+ return self.request.body
69
+
70
+ async def get_form_data(self) -> FormData:
71
+ assert self.request.form is not None
72
+
73
+ files = convert_request_to_files_dict(self.request)
74
+
75
+ return FormData(form=self.request.form, files=files)
76
+
77
+ @property
78
+ def url(self) -> str:
79
+ return self.request.url
80
+
81
+ @property
82
+ def cookies(self) -> Mapping[str, str]:
83
+ return self.request.cookies
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Mapping, Optional, cast
4
+
5
+ from ._base import AsyncHTTPRequestAdapter, FormData, HTTPMethod, QueryParams
6
+
7
+ if TYPE_CHECKING:
8
+ from starlette.requests import Request
9
+
10
+
11
+ class StarletteRequestAdapter(AsyncHTTPRequestAdapter):
12
+ def __init__(self, request: Request) -> None:
13
+ self._request = request
14
+ # Starlette Headers are case-insensitive Mapping
15
+ self._headers: Optional[Mapping[str, str]] = None
16
+
17
+ @property
18
+ def method(self) -> HTTPMethod:
19
+ # Starlette method is already uppercase string
20
+ return cast(HTTPMethod, self._request.method)
21
+
22
+ @property
23
+ def query_params(self) -> QueryParams:
24
+ # Starlette QueryParams behaves like a MultiDict Mapping
25
+ return cast(QueryParams, self._request.query_params)
26
+
27
+ @property
28
+ def headers(self) -> Mapping[str, str]:
29
+ # Cache the immutable headers object for direct access
30
+ # Starlette Headers are case-insensitive
31
+ if self._headers is None:
32
+ self._headers = self._request.headers
33
+
34
+ return self._headers
35
+
36
+ @property
37
+ def content_type(self) -> Optional[str]:
38
+ # Access directly via headers property for consistency
39
+ return self.headers.get("content-type")
40
+
41
+ async def get_body(self) -> bytes:
42
+ return await self._request.body()
43
+
44
+ async def get_form_data(self) -> FormData:
45
+ multipart_data = await self._request.form()
46
+
47
+ return FormData(
48
+ files=multipart_data,
49
+ form=multipart_data,
50
+ )
51
+
52
+ @property
53
+ def url(self) -> str:
54
+ return str(self._request.url)
55
+
56
+ @property
57
+ def cookies(self) -> Mapping[str, str]:
58
+ return cast(Mapping[str, str], self._request.cookies)
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any, Mapping, Optional
5
+
6
+ from ._base import AsyncHTTPRequestAdapter, FormData, HTTPMethod, QueryParams
7
+
8
+
9
+ class TestingRequestAdapter(AsyncHTTPRequestAdapter):
10
+ __test__ = False
11
+
12
+ def __init__(
13
+ self,
14
+ *,
15
+ method: HTTPMethod = "POST",
16
+ query_params: QueryParams | None = None,
17
+ headers: Mapping[str, str] | None = None,
18
+ content_type: str | None = None,
19
+ url: str = "http://testserver/",
20
+ cookies: Mapping[str, str] | None = None,
21
+ form_data: FormData | None = None,
22
+ json: dict[str, Any] | None = None,
23
+ ) -> None:
24
+ self._method = method
25
+ self._query_params = query_params or {}
26
+ self._headers = headers or {}
27
+ self._content_type = content_type
28
+ self._url = url
29
+ self._cookies = cookies or {}
30
+ self._form_data = form_data or FormData(files={}, form={})
31
+ self._json = json
32
+
33
+ @property
34
+ def method(self) -> HTTPMethod:
35
+ return self._method
36
+
37
+ @property
38
+ def query_params(self) -> QueryParams:
39
+ return self._query_params
40
+
41
+ @property
42
+ def headers(self) -> Mapping[str, str]:
43
+ return self._headers
44
+
45
+ @property
46
+ def content_type(self) -> Optional[str]:
47
+ return self._content_type
48
+
49
+ async def get_body(self) -> bytes:
50
+ if self._json is not None:
51
+ return json.dumps(self._json).encode("utf-8")
52
+
53
+ return b""
54
+
55
+ async def get_form_data(self) -> FormData:
56
+ return self._form_data
57
+
58
+ @property
59
+ def url(self) -> str:
60
+ return self._url
61
+
62
+ @property
63
+ def cookies(self) -> Mapping[str, str]:
64
+ return self._cookies
cross/response.py ADDED
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import TYPE_CHECKING, Literal, Mapping, List, Union, cast
6
+ from typing_extensions import Self
7
+ from urllib.parse import urlencode
8
+
9
+ if TYPE_CHECKING:
10
+ from fastapi import Response as FastAPIResponse
11
+
12
+ JsonType = Union[
13
+ str, int, float, bool, None, Mapping[str, "JsonType"], List["JsonType"]
14
+ ]
15
+
16
+
17
+ @dataclass
18
+ class Cookie:
19
+ name: str
20
+ value: str
21
+ secure: bool
22
+ path: str | None = None
23
+ domain: str | None = None
24
+ max_age: int | None = None
25
+ httponly: bool = True
26
+ samesite: Literal["lax", "strict", "none"] = "lax"
27
+
28
+
29
+ @dataclass
30
+ class Response:
31
+ status_code: int
32
+ body: str | None = None
33
+ cookies: list[Cookie] | None = None
34
+ headers: Mapping[str, str] | None = None
35
+
36
+ @classmethod
37
+ def redirect(
38
+ cls,
39
+ url: str,
40
+ query_params: Mapping[str, str | list[str]] | None = None,
41
+ headers: Mapping[str, str] | None = None,
42
+ cookies: list[Cookie] | None = None,
43
+ ) -> Self:
44
+ headers = headers or {}
45
+
46
+ if query_params:
47
+ url = url + "?" + urlencode(query_params)
48
+
49
+ return cls(
50
+ status_code=302,
51
+ headers={"Location": url, **headers},
52
+ cookies=cookies,
53
+ )
54
+
55
+ def json(self) -> JsonType:
56
+ if self.body is None:
57
+ return None
58
+
59
+ return cast(JsonType, json.loads(self.body))
60
+
61
+ def to_fastapi(self) -> FastAPIResponse:
62
+ from fastapi import Response as FastAPIResponse
63
+
64
+ response = FastAPIResponse(
65
+ status_code=self.status_code,
66
+ headers=self.headers,
67
+ content=self.body,
68
+ )
69
+
70
+ for cookie in self.cookies or []:
71
+ response.set_cookie(
72
+ cookie.name,
73
+ cookie.value,
74
+ secure=cookie.secure,
75
+ path=cookie.path,
76
+ domain=cookie.domain,
77
+ max_age=cookie.max_age,
78
+ httponly=cookie.httponly,
79
+ samesite=cookie.samesite,
80
+ )
81
+
82
+ return response
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: cross-web
3
+ Version: 0.2.3
4
+ Summary: A library for working with web frameworks
5
+ Author-email: Patrick Arminio <patrick.arminio@gmail.com>
6
+ License-File: LICENSE
7
+ Classifier: Development Status :: 5 - Production/Stable
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Topic :: Software Development :: Libraries
11
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
12
+ Requires-Python: >=3.9
13
+ Requires-Dist: typing-extensions>=4.14.0
14
+ Description-Content-Type: text/markdown
15
+
16
+ # Cross
17
+
18
+ **Write once, run everywhere** - A universal web framework adapter for Python that lets you write code once and use it across multiple web frameworks.
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ uv add cross-web
24
+ ```
25
+
26
+ ## Overview
27
+
28
+ Cross provides a unified interface for common web framework operations, allowing you to write framework-agnostic code that can be easily adapted to work with FastAPI, Flask, Django, and other popular Python web frameworks.
29
+
30
+ ## Features
31
+
32
+ This project is in early development!
@@ -0,0 +1,20 @@
1
+ cross/__init__.py,sha256=Lqg9wnEAyU1Kk67dF7dlU_ZmyQvMJt5PisTFCeXBG2Y,1336
2
+ cross/exceptions.py,sha256=uVWXNel6yjRHIEazdtthgFBKCF36mskOU3kmhJR1eXU,226
3
+ cross/protocols.py,sha256=NEItrqqj1LKKUjBOzpNTn3vLCDUWsV9e_yaDlN9rq1I,478
4
+ cross/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ cross/response.py,sha256=8kiJjv6OoL2lKKZaay5MBx8BxDxW6poyA1rA6Gca_48,2096
6
+ cross/request/__init__.py,sha256=pgyFbIFYAP3RpZkS947AvUJ8d-2ev4WWhj_9r2E6_d8,3965
7
+ cross/request/_aiohttp.py,sha256=sPRuQnInX64LviaLAKXEsyNQ-sYLVGdPliLIWi4J3sc,3573
8
+ cross/request/_base.py,sha256=kllXikX7S6ZFQWcNs2KYhAbKibEbTsAEtRlp0smoXhg,4330
9
+ cross/request/_chalice.py,sha256=SN62ryIFYOdHO6nJx6lE_4MuluAq_1L5PnQsi-Uwb1c,2730
10
+ cross/request/_django.py,sha256=CMlRlC7m4n_pfclUMuo7r6-TPLki9soF-tu41ac-o6w,2961
11
+ cross/request/_flask.py,sha256=c80Xovg31XSsKO5o9og_RgM4kli1I1NXVe7P8MGYy1k,2433
12
+ cross/request/_litestar.py,sha256=mSPMaqAuR2J-vBDgeB6QN5pFZ1jfuDrWOZnFbkH90FQ,1404
13
+ cross/request/_quart.py,sha256=9ICIpoALCk7Op5ggyIuU7CdHqlLaaowanH5ZNnSdDkQ,1211
14
+ cross/request/_sanic.py,sha256=QfVpXwdj0RF20xkZysq1LuLV6V0r_j0I10ukEtVsiRc,2485
15
+ cross/request/_starlette.py,sha256=bxL4TYkWh-OAjZSht_benXr40TNLDtVj7UaqGBfaXf0,1775
16
+ cross/request/_testing.py,sha256=ja10a1pQSYBzYAN10ce9e5aEpyD9-HBLTWH76hqWK5g,1712
17
+ cross_web-0.2.3.dist-info/METADATA,sha256=uOEg3q9a78xO_XLG4f8ni32dRUB0-LN4JllC3vXSczA,1064
18
+ cross_web-0.2.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
19
+ cross_web-0.2.3.dist-info/licenses/LICENSE,sha256=a-9NtzEY0MYsJLrhMKR7HQsqmfTjYx_A1Mw6dwXer18,1073
20
+ cross_web-0.2.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Patrick Arminio
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.
22
+