DefEssential 0.1.8__23-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.
Files changed (104) hide show
  1. DefEssential/Adapter/Implements/Gunicorn/Server.py +152 -0
  2. DefEssential/Adapter/Implements/Gunicorn/Transport.py +171 -0
  3. DefEssential/Adapter/Implements/Gunicorn/__init__.py +5 -0
  4. DefEssential/Adapter/Implements/Python/Server.py +273 -0
  5. DefEssential/Adapter/Implements/Python/__init__.py +5 -0
  6. DefEssential/Adapter/Implements/Uvicorn/HTTP.py +256 -0
  7. DefEssential/Adapter/Implements/Uvicorn/Portal.py +65 -0
  8. DefEssential/Adapter/Implements/Uvicorn/Server.py +137 -0
  9. DefEssential/Adapter/Implements/Uvicorn/WebSocket.py +222 -0
  10. DefEssential/Adapter/Implements/Uvicorn/__init__.py +5 -0
  11. DefEssential/Adapter/Implements/__init__.py +3 -0
  12. DefEssential/Adapter/RequestHandler.py +109 -0
  13. DefEssential/Adapter/Server.py +54 -0
  14. DefEssential/Adapter/WSGI.py +219 -0
  15. DefEssential/Adapter/__init__.py +5 -0
  16. DefEssential/Application.py +657 -0
  17. DefEssential/Body.py +50 -0
  18. DefEssential/Content.py +18 -0
  19. DefEssential/Credentials.py +245 -0
  20. DefEssential/CrossOriginResourceSharing.py +107 -0
  21. DefEssential/Descriptor/DataObject.py +409 -0
  22. DefEssential/Descriptor/Types.py +248 -0
  23. DefEssential/Descriptor/Value.py +188 -0
  24. DefEssential/Descriptor/__init__.py +172 -0
  25. DefEssential/Error.py +66 -0
  26. DefEssential/Extension.py +191 -0
  27. DefEssential/Extensions/File.py +95 -0
  28. DefEssential/Extensions/FileSystem.py +195 -0
  29. DefEssential/Extensions/Metrics.py +511 -0
  30. DefEssential/Extensions/Ping.py +93 -0
  31. DefEssential/Extensions/Specifications.py +342 -0
  32. DefEssential/Extensions/Status.html +207 -0
  33. DefEssential/Extensions/Status.py +110 -0
  34. DefEssential/Extensions/Swagger.html +77 -0
  35. DefEssential/Extensions/Swagger.py +117 -0
  36. DefEssential/Extensions/Utils/Routes.py +42 -0
  37. DefEssential/Extensions/Utils/__init__.py +43 -0
  38. DefEssential/Extensions/__init__.py +21 -0
  39. DefEssential/IO/Readers/ChunkedStream.py +106 -0
  40. DefEssential/IO/Readers/ContentReader.py +36 -0
  41. DefEssential/IO/Readers/ContentReaders.py +241 -0
  42. DefEssential/IO/Readers/__init__.py +25 -0
  43. DefEssential/IO/RequestReader.py +125 -0
  44. DefEssential/IO/ResponseWriter.py +242 -0
  45. DefEssential/IO/WebSocket/WebSocket.py +288 -0
  46. DefEssential/IO/WebSocket/WebSocketError.py +17 -0
  47. DefEssential/IO/WebSocket/WebSocketFrame.py +68 -0
  48. DefEssential/IO/WebSocket/WebSocketFrameReader.py +98 -0
  49. DefEssential/IO/WebSocket/WebSocketHandshake.py +94 -0
  50. DefEssential/IO/WebSocket/__init__.py +13 -0
  51. DefEssential/IO/Writers/ChunkedStreamWriter.py +19 -0
  52. DefEssential/IO/Writers/ServerSentEvents.py +41 -0
  53. DefEssential/IO/Writers/ServerSentEventsWriter.py +67 -0
  54. DefEssential/IO/Writers/StreamWriter.py +57 -0
  55. DefEssential/IO/Writers/__init__.py +11 -0
  56. DefEssential/IO/__init__.py +7 -0
  57. DefEssential/Request.py +110 -0
  58. DefEssential/RequestRunner.py +261 -0
  59. DefEssential/Response.py +89 -0
  60. DefEssential/ResponseSpecification.py +23 -0
  61. DefEssential/Responses/ResponseFile.py +108 -0
  62. DefEssential/Responses/__init__.py +63 -0
  63. DefEssential/Route.py +55 -0
  64. DefEssential/Router.py +48 -0
  65. DefEssential/Runners/RunExtension.py +116 -0
  66. DefEssential/Runners/RunFactory.py +26 -0
  67. DefEssential/Runners/RunOptions.py +77 -0
  68. DefEssential/Runners/RunRequest.py +198 -0
  69. DefEssential/Runners/__init__.py +5 -0
  70. DefEssential/Startup.py +104 -0
  71. DefEssential/Utils/FileSystemObject/Configuration.py +15 -0
  72. DefEssential/Utils/FileSystemObject/Connection.py +57 -0
  73. DefEssential/Utils/FileSystemObject/File.py +41 -0
  74. DefEssential/Utils/FileSystemObject/Implements/FileSystem/Configuration.py +15 -0
  75. DefEssential/Utils/FileSystemObject/Implements/FileSystem/Connection.py +115 -0
  76. DefEssential/Utils/FileSystemObject/Implements/FileSystem/File.py +77 -0
  77. DefEssential/Utils/FileSystemObject/Implements/FileSystem/__init__.py +11 -0
  78. DefEssential/Utils/FileSystemObject/Implements/__init__.py +3 -0
  79. DefEssential/Utils/FileSystemObject/__init__.py +11 -0
  80. DefEssential/Utils/Loader.py +81 -0
  81. DefEssential/Utils/__init__.py +3 -0
  82. DefEssential/Validator/Pattern.py +36 -0
  83. DefEssential/Validator/Patterns/Array.py +30 -0
  84. DefEssential/Validator/Patterns/Common.py +92 -0
  85. DefEssential/Validator/Patterns/Compare.py +180 -0
  86. DefEssential/Validator/Patterns/Condition.py +60 -0
  87. DefEssential/Validator/Patterns/DataObject.py +85 -0
  88. DefEssential/Validator/Patterns/DateTime.py +111 -0
  89. DefEssential/Validator/Patterns/Number.py +43 -0
  90. DefEssential/Validator/Patterns/Object.py +150 -0
  91. DefEssential/Validator/Patterns/Size.py +110 -0
  92. DefEssential/Validator/Patterns/String.py +85 -0
  93. DefEssential/Validator/Patterns/Type.py +350 -0
  94. DefEssential/Validator/Patterns/__init__.py +137 -0
  95. DefEssential/Validator/Validate.py +29 -0
  96. DefEssential/Validator/Validator.py +30 -0
  97. DefEssential/Validator/__init__.py +11 -0
  98. DefEssential/Validators.py +255 -0
  99. DefEssential/__init__.py +83 -0
  100. defessential-0.1.8.dist-info/METADATA +82 -0
  101. defessential-0.1.8.dist-info/RECORD +104 -0
  102. defessential-0.1.8.dist-info/WHEEL +6 -0
  103. defessential-0.1.8.dist-info/licenses/LICENSE +674 -0
  104. defessential-0.1.8.dist-info/top_level.txt +1 -0
@@ -0,0 +1,57 @@
1
+ from typing import (
2
+ Mapping,
3
+ Optional,
4
+ )
5
+
6
+ from ...Response import Response
7
+ from ..ResponseWriter import ResponseWriter
8
+
9
+ __all__ = (
10
+ 'StreamWriter',
11
+ )
12
+
13
+
14
+ class StreamWriter:
15
+ """Start a response and write bytes directly to the stream."""
16
+
17
+ chunked = False
18
+
19
+ def __init__(
20
+ self,
21
+ writer: ResponseWriter,
22
+ ):
23
+ self.writer = writer
24
+ return
25
+
26
+ def begin(
27
+ self,
28
+ headers: Optional[Mapping[str, str]] = None,
29
+ format: Optional[str] = None,
30
+ charset: Optional[str] = None,
31
+ status: int = 200,
32
+ ) -> None:
33
+ self.writer.begin(
34
+ Response(
35
+ status=status,
36
+ headers=headers,
37
+ format=format,
38
+ charset=charset,
39
+ ),
40
+ chunked=self.chunked,
41
+ )
42
+ return
43
+
44
+ def write(
45
+ self,
46
+ data: bytes,
47
+ ) -> None:
48
+ self.writer.write(data)
49
+ return
50
+
51
+ def flush(self) -> None:
52
+ self.writer.flush()
53
+ return
54
+
55
+ def end(self) -> None:
56
+ self.writer.end()
57
+ return
@@ -0,0 +1,11 @@
1
+ from .ChunkedStreamWriter import ChunkedStreamWriter
2
+ from .ServerSentEvents import ServerSentEvents
3
+ from .ServerSentEventsWriter import ServerSentEventsWriter
4
+ from .StreamWriter import StreamWriter
5
+
6
+ __all__ = (
7
+ 'ChunkedStreamWriter',
8
+ 'ServerSentEvents',
9
+ 'ServerSentEventsWriter',
10
+ 'StreamWriter',
11
+ )
@@ -0,0 +1,7 @@
1
+ from .RequestReader import RequestReader
2
+ from .ResponseWriter import ResponseWriter
3
+
4
+ __all__ = (
5
+ 'RequestReader',
6
+ 'ResponseWriter',
7
+ )
@@ -0,0 +1,110 @@
1
+ from email.message import Message
2
+ from io import BytesIO
3
+ from re import fullmatch
4
+ from typing import (
5
+ Any,
6
+ Mapping,
7
+ Optional,
8
+ )
9
+ from urllib.parse import (
10
+ parse_qs,
11
+ urlsplit,
12
+ )
13
+ from uuid import uuid4
14
+
15
+ from .IO.RequestReader import RequestReader
16
+ from .IO.ResponseWriter import ResponseWriter
17
+ from .Error import ClientError
18
+
19
+ __all__ = (
20
+ 'Request',
21
+ )
22
+
23
+
24
+ class Request:
25
+ """Represent an HTTP request independently of server environment dictionaries."""
26
+
27
+ def __init__(
28
+ self,
29
+ address: str,
30
+ port: int,
31
+ method: str,
32
+ uri: str,
33
+ parameters: Optional[Mapping[str, str]] = None,
34
+ headers: Optional[Mapping[str, str]] = None,
35
+ reader: Optional[RequestReader] = None,
36
+ writer: Optional[ResponseWriter] = None,
37
+ ):
38
+ self.id = uuid4().hex
39
+ self.address = address
40
+ self.port = port
41
+ self.method = method
42
+ self.uri = uri
43
+ parsed = urlsplit(uri)
44
+ self.path = parsed.path
45
+ self.querystring = parsed.query
46
+ self.params = dict(parameters) if parameters is not None else {}
47
+ self.args = {
48
+ key: values[0] if len(values) == 1 else values
49
+ for key, values in parse_qs(
50
+ parsed.query,
51
+ keep_blank_values=True,
52
+ ).items()
53
+ }
54
+ self.props = Message()
55
+ for key, value in headers.items() if headers is not None else ():
56
+ self.props[key] = value
57
+ self.reader = reader if reader is not None else RequestReader(BytesIO())
58
+ self.response_writer = writer
59
+ self.body: Any = None
60
+ self.session: Any = None
61
+ return
62
+
63
+ @property
64
+ def writer(self) -> ResponseWriter:
65
+ """Return the response writer provided by the execution layer."""
66
+ if self.response_writer is None:
67
+ raise RuntimeError('No response writer is attached to this request')
68
+ return self.response_writer
69
+
70
+ def header(
71
+ self,
72
+ key: str,
73
+ value: Optional[str] = None,
74
+ ) -> Optional[str]:
75
+ if value is not None:
76
+ del self.props[key]
77
+ self.props[key] = value
78
+ return None
79
+ return self.props.get(key)
80
+
81
+ def headers(self) -> list:
82
+ return list(self.props.items())
83
+
84
+ @property
85
+ def parameters(self) -> dict:
86
+ return self.params
87
+
88
+ @property
89
+ def qs(self) -> dict:
90
+ return self.args
91
+
92
+ @property
93
+ def size(self) -> Optional[int]:
94
+ value = self.header('Content-Length')
95
+ return int(value) if value is not None else None
96
+
97
+ @property
98
+ def format(self) -> Optional[str]:
99
+ value = self.header('Content-Type')
100
+ if value is None:
101
+ return None
102
+ format = value.split(';', 1)[0].strip().lower()
103
+ token = r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+"
104
+ if fullmatch(token + '/' + token, format) is None:
105
+ raise ClientError('Invalid Content-Type')
106
+ return format
107
+
108
+ @property
109
+ def charset(self) -> Optional[str]:
110
+ return self.props.get_content_charset()
@@ -0,0 +1,261 @@
1
+ from abc import (
2
+ ABC,
3
+ ABCMeta,
4
+ abstractmethod,
5
+ )
6
+ from dataclasses import dataclass
7
+ from re import fullmatch
8
+ from typing import (
9
+ Mapping,
10
+ Optional,
11
+ Sequence,
12
+ Type,
13
+ TypeVar,
14
+ Union,
15
+ )
16
+
17
+ from .Body import Body
18
+ from .Request import Request
19
+ from .Response import Response
20
+ from .ResponseSpecification import ResponseSpecification
21
+ from .Validators import (
22
+ Auth,
23
+ Headers,
24
+ Origin,
25
+ Parameters,
26
+ QueryString,
27
+ )
28
+
29
+ __all__ = (
30
+ 'RequestRunnerRequest',
31
+ 'RequestRunnerRequestResponse',
32
+ 'RequestRunnerRequestError',
33
+ 'RequestProperties',
34
+ 'RequestRunner',
35
+ )
36
+
37
+
38
+ class RequestRunnerRequest(ABC):
39
+ """Transform a prepared request immediately before the handler runs."""
40
+
41
+ @abstractmethod
42
+ def __call__(
43
+ self,
44
+ request: Request,
45
+ ) -> Request:
46
+ raise NotImplementedError
47
+
48
+
49
+ class RequestRunnerRequestResponse(ABC):
50
+ """Transform a returned response or a direct-output response before transmission."""
51
+
52
+ @abstractmethod
53
+ def __call__(
54
+ self,
55
+ request: Request,
56
+ response: Response,
57
+ ) -> Response:
58
+ raise NotImplementedError
59
+
60
+
61
+ class RequestRunnerRequestError(ABC):
62
+ """Observe execution errors from validation through response processing."""
63
+
64
+ @abstractmethod
65
+ def __call__(
66
+ self,
67
+ request: Request,
68
+ error: Exception,
69
+ ) -> None:
70
+ raise NotImplementedError
71
+
72
+
73
+ Hook = TypeVar('Hook')
74
+
75
+
76
+ def request_runner_hooks(
77
+ value: Union[Hook, Sequence[Hook], None],
78
+ contract: Type[Hook],
79
+ name: str,
80
+ ) -> tuple[Hook, ...]:
81
+ if value is None:
82
+ return ()
83
+ if isinstance(value, contract):
84
+ return (value,)
85
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
86
+ result = tuple(value)
87
+ if all(isinstance(hook, contract) for hook in result):
88
+ return result
89
+ raise TypeError(name + ' must be a ' + contract.__name__ + ' instance or a sequence of them')
90
+
91
+
92
+ def require_contract(
93
+ value: object,
94
+ contract: Type,
95
+ name: str,
96
+ *,
97
+ multiple: bool = False,
98
+ ) -> None:
99
+ if value is None:
100
+ return
101
+ values = (value,) if isinstance(value, contract) else value
102
+ if multiple:
103
+ if not isinstance(values, Sequence) or isinstance(values, (str, bytes)):
104
+ raise TypeError(name + ' must contain ' + contract.__name__ + ' instances')
105
+ if any(not isinstance(item, contract) for item in values):
106
+ raise TypeError(name + ' must contain ' + contract.__name__ + ' instances')
107
+ return
108
+ if not isinstance(value, contract):
109
+ raise TypeError(name + ' must be a ' + contract.__name__ + ' instance')
110
+ return
111
+
112
+
113
+ class RequestRunnerMeta(ABCMeta):
114
+ def __new__(
115
+ cls,
116
+ class_name,
117
+ bases,
118
+ namespace,
119
+ **kwargs,
120
+ ):
121
+ if 'name' in kwargs:
122
+ if 'request_runner_name' in kwargs:
123
+ raise TypeError('Specify only one request runner name')
124
+ kwargs['request_runner_name'] = kwargs.pop('name')
125
+ return super().__new__(
126
+ cls,
127
+ class_name,
128
+ bases,
129
+ namespace,
130
+ **kwargs,
131
+ )
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class RequestProperties:
136
+ """Store request handler settings for registration with an application."""
137
+
138
+ method: Optional[str]
139
+ url: Optional[str]
140
+ origin: Union[Origin, Sequence[Origin], None]
141
+ auth: Optional[Auth]
142
+ parameters: Optional[Parameters]
143
+ qs: Optional[QueryString]
144
+ headers: Optional[Headers]
145
+ body: Optional[Body]
146
+ response: Union[
147
+ ResponseSpecification,
148
+ Mapping[str, Union[str, Mapping[str, object]]],
149
+ Sequence[ResponseSpecification],
150
+ None,
151
+ ]
152
+ summary: Optional[str]
153
+ description: Optional[str]
154
+ tags: Union[str, Sequence[str], None]
155
+ on_request: tuple[RequestRunnerRequest, ...]
156
+ on_request_response: tuple[RequestRunnerRequestResponse, ...]
157
+ on_request_error: tuple[RequestRunnerRequestError, ...]
158
+ name: Optional[str] = None
159
+
160
+
161
+ class RequestRunner(
162
+ ABC,
163
+ metaclass=RequestRunnerMeta,
164
+ ):
165
+ """Configure a handler through class declarations and instantiate it per request."""
166
+
167
+ properties: RequestProperties
168
+
169
+ def __init_subclass__(
170
+ cls,
171
+ *,
172
+ method: Optional[str] = None,
173
+ url: Optional[str] = None,
174
+ request_runner_name: Optional[str] = None,
175
+ origin: Union[Origin, Sequence[Origin], None] = None,
176
+ auth: Optional[Auth] = None,
177
+ parameters: Optional[Parameters] = None,
178
+ qs: Optional[QueryString] = None,
179
+ headers: Optional[Headers] = None,
180
+ body: Optional[Body] = None,
181
+ response: Union[
182
+ ResponseSpecification,
183
+ Mapping[str, Union[str, Mapping[str, object]]],
184
+ Sequence[ResponseSpecification],
185
+ None,
186
+ ] = None,
187
+ summary: Optional[str] = None,
188
+ description: Optional[str] = None,
189
+ tags: Union[str, Sequence[str], None] = None,
190
+ on_request: Union[
191
+ RequestRunnerRequest,
192
+ Sequence[RequestRunnerRequest],
193
+ None,
194
+ ] = None,
195
+ on_request_response: Union[
196
+ RequestRunnerRequestResponse,
197
+ Sequence[RequestRunnerRequestResponse],
198
+ None,
199
+ ] = None,
200
+ on_request_error: Union[
201
+ RequestRunnerRequestError,
202
+ Sequence[RequestRunnerRequestError],
203
+ None,
204
+ ] = None,
205
+ ):
206
+ super().__init_subclass__()
207
+ if request_runner_name is not None and (
208
+ not isinstance(request_runner_name, str) or not request_runner_name.strip()
209
+ ):
210
+ raise ValueError('name must be a non-empty string')
211
+ if (method is None) != (url is None):
212
+ raise TypeError('method and url must be specified together')
213
+ if method is not None and fullmatch(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+", method) is None:
214
+ raise ValueError('Invalid HTTP method token')
215
+ if url is not None and (not url.startswith('/') or '?' in url or '#' in url):
216
+ raise ValueError('url must be a path starting with /')
217
+ require_contract(origin, Origin, 'origin', multiple=True)
218
+ require_contract(auth, Auth, 'auth')
219
+ require_contract(parameters, Parameters, 'parameters')
220
+ require_contract(qs, QueryString, 'qs')
221
+ require_contract(headers, Headers, 'headers')
222
+ require_contract(body, Body, 'body')
223
+ cls.properties = RequestProperties(
224
+ method=method,
225
+ url=url,
226
+ name=request_runner_name,
227
+ origin=origin,
228
+ auth=auth,
229
+ parameters=parameters,
230
+ qs=qs,
231
+ headers=headers,
232
+ body=body,
233
+ response=response,
234
+ summary=summary,
235
+ description=description,
236
+ tags=tags,
237
+ on_request=request_runner_hooks(
238
+ on_request,
239
+ RequestRunnerRequest,
240
+ 'on_request',
241
+ ),
242
+ on_request_response=request_runner_hooks(
243
+ on_request_response,
244
+ RequestRunnerRequestResponse,
245
+ 'on_request_response',
246
+ ),
247
+ on_request_error=request_runner_hooks(
248
+ on_request_error,
249
+ RequestRunnerRequestError,
250
+ 'on_request_error',
251
+ ),
252
+ )
253
+ return
254
+
255
+ @abstractmethod
256
+ def run(
257
+ self,
258
+ request: Request,
259
+ ) -> Optional[Response]:
260
+ """Return a Response, or write through request.writer and return None."""
261
+ raise NotImplementedError
@@ -0,0 +1,89 @@
1
+ from email.message import Message
2
+ from http import HTTPStatus
3
+ from logging import getLogger
4
+ from typing import (
5
+ Iterable,
6
+ Mapping,
7
+ Optional,
8
+ Union,
9
+ )
10
+
11
+ __all__ = (
12
+ 'Response',
13
+ )
14
+
15
+ logger = getLogger('DefEssential.RequestRunner')
16
+
17
+
18
+ def close_bodies(
19
+ *bodies: object,
20
+ preserve_error: bool = False,
21
+ ) -> None:
22
+ """Close every repeated body once without letting cleanup errors hide the original failure."""
23
+ closed: list[object] = []
24
+ error: Optional[Exception] = None
25
+ for body in bodies:
26
+ if any(body is previous for previous in closed):
27
+ continue
28
+ closed.append(body)
29
+ try:
30
+ close = getattr(body, 'close', None)
31
+ if close is not None:
32
+ close()
33
+ except Exception as caught:
34
+ if preserve_error or error is not None:
35
+ logger.exception('Response body cleanup failed')
36
+ else:
37
+ error = caught
38
+ if error is not None:
39
+ raise error
40
+ return
41
+
42
+
43
+ class Response:
44
+ """Represent an HTTP response with a byte body or sequential output."""
45
+
46
+ def __init__(
47
+ self,
48
+ status: int = 200,
49
+ message: Optional[str] = None,
50
+ headers: Optional[Mapping[str, str]] = None,
51
+ body: Union[bytes, Iterable[bytes]] = b'',
52
+ format: Optional[str] = None,
53
+ charset: Optional[str] = None,
54
+ ):
55
+ self.status = status
56
+ self.message = HTTPStatus(status).phrase if message is None else message
57
+ self.props = Message()
58
+ for key, value in headers.items() if headers is not None else ():
59
+ self.header(
60
+ key,
61
+ value,
62
+ )
63
+ if format is not None:
64
+ self.header(
65
+ 'Content-Type',
66
+ format + ('; charset=' + charset if charset is not None else ''),
67
+ )
68
+ self.body = body
69
+ return
70
+
71
+ def header(
72
+ self,
73
+ key: str,
74
+ value: Optional[str] = None,
75
+ ) -> Optional[str]:
76
+ if value is None:
77
+ return self.props.get(key)
78
+ if '\r' in key or '\n' in key or '\r' in value or '\n' in value:
79
+ raise ValueError('Response headers must not contain line breaks')
80
+ del self.props[key]
81
+ self.props[key] = value
82
+ return None
83
+
84
+ def headers(self) -> list:
85
+ return list(self.props.items())
86
+
87
+ def close(self) -> None:
88
+ close_bodies(self.body)
89
+ return
@@ -0,0 +1,23 @@
1
+ from dataclasses import dataclass
2
+ from typing import (
3
+ Mapping,
4
+ Optional,
5
+ Sequence,
6
+ Union,
7
+ )
8
+
9
+ from .Content import Content
10
+
11
+ __all__ = (
12
+ 'ResponseSpecification',
13
+ )
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ResponseSpecification:
18
+ """Declare an OpenAPI response independently of the actual Response."""
19
+
20
+ status: int
21
+ description: str
22
+ content: Union[Content, Sequence[Content], None] = None
23
+ headers: Optional[Mapping[str, object]] = None
@@ -0,0 +1,108 @@
1
+ from mimetypes import guess_type
2
+ from pathlib import Path
3
+ from typing import (
4
+ Iterator,
5
+ Optional,
6
+ )
7
+
8
+ from ..Error import ClientError
9
+ from ..Response import Response
10
+
11
+ __all__ = (
12
+ 'FileBody',
13
+ 'ResponseFile',
14
+ )
15
+
16
+
17
+ def file_error(error: OSError) -> ClientError:
18
+ if isinstance(
19
+ error,
20
+ (
21
+ FileNotFoundError,
22
+ NotADirectoryError,
23
+ IsADirectoryError,
24
+ ),
25
+ ):
26
+ return ClientError(
27
+ 'File not found',
28
+ status=404,
29
+ )
30
+ if isinstance(error, PermissionError):
31
+ return ClientError(
32
+ 'File access denied',
33
+ status=403,
34
+ )
35
+ raise error
36
+
37
+
38
+ class FileBody:
39
+ """Read an open file in fixed-size chunks and close it when the response ends."""
40
+
41
+ def __init__(
42
+ self,
43
+ file,
44
+ ):
45
+ self.file = file
46
+ self.closed = False
47
+ return
48
+
49
+ def __iter__(self) -> Iterator[bytes]:
50
+ try:
51
+ while True:
52
+ data = self.file.read(65536)
53
+ if not data:
54
+ break
55
+ yield data
56
+ finally:
57
+ self.close()
58
+ return
59
+
60
+ def close(self) -> None:
61
+ if not self.closed:
62
+ self.closed = True
63
+ self.file.close()
64
+ return
65
+
66
+
67
+ class ResponseFile(Response):
68
+ """Stream a local file or an open FileSystemObject file as a response."""
69
+
70
+ def __init__(
71
+ self,
72
+ file,
73
+ format: Optional[str] = None,
74
+ encoding: Optional[str] = None,
75
+ ):
76
+ if isinstance(
77
+ file,
78
+ (
79
+ str,
80
+ Path,
81
+ ),
82
+ ):
83
+ path = Path(file)
84
+ try:
85
+ file = path.open('rb')
86
+ except (
87
+ FileNotFoundError,
88
+ NotADirectoryError,
89
+ IsADirectoryError,
90
+ PermissionError,
91
+ ) as error:
92
+ raise file_error(error) from error
93
+ mime, compression = guess_type(str(path))
94
+ if format is None:
95
+ format = mime
96
+ if encoding is None:
97
+ encoding = compression
98
+ body = FileBody(file)
99
+ try:
100
+ super().__init__(
101
+ body=body,
102
+ format=format if format is not None else 'application/octet-stream',
103
+ headers={'Content-Encoding': encoding} if encoding is not None else None,
104
+ )
105
+ except Exception:
106
+ body.close()
107
+ raise
108
+ return