Browse Source

output stream async

Bastien Sevajol 5 years ago
parent
commit
c497377ad3
7 changed files with 193 additions and 23 deletions
  1. 51 15
      aiopoc.py
  2. 1 0
      hapic/__init__.py
  3. 1 0
      hapic/async.py
  4. 48 0
      hapic/decorator.py
  5. 6 0
      hapic/description.py
  6. 56 8
      hapic/ext/aiohttp/context.py
  7. 30 0
      hapic/hapic.py

+ 51 - 15
aiopoc.py View File

1
+import asyncio
2
+import json
3
+
1
 from sh import tail
4
 from sh import tail
2
 from asyncio import sleep
5
 from asyncio import sleep
3
 from aiohttp import web
6
 from aiohttp import web
7
+import marshmallow
8
+
9
+from hapic import async as hapic
10
+from hapic.ext.aiohttp.context import AiohttpContext
11
+
12
+
13
+class OutputStreamItemSchema(marshmallow.Schema):
14
+    i = marshmallow.fields.Integer(required=True)
15
+
16
+
17
+# Python 3.6 async generator: http://rickyhan.com/jekyll/update/2018/01/27/python36.html
18
+# Python 3.5 solution: https://stackoverflow.com/questions/37549846/how-to-use-yield-inside-async-function
19
+
4
 
20
 
21
+class LinesAsyncGenerator:
22
+    def __init__(self):
23
+        self.iterable = tail("-f", "aiopocdata.txt", _iter=True)
5
 
24
 
6
-async def handle(request):
7
-    response = web.StreamResponse(
8
-        status=200,
9
-        reason='OK',
10
-        headers={
11
-            'Content-Type': 'text/plain; charset=utf-8',
12
-        },
13
-    )
25
+    async def __aiter__(self):
26
+        return self
14
 
27
 
15
-    await response.prepare(request)
16
-    response.enable_chunked_encoding()
28
+    async def __anext__(self):
29
+        line = next(self.iterable)
17
 
30
 
18
-    for line in tail("-f", "aiopocdata.txt", _iter=True):
19
-        await response.write(line.encode('utf-8'))
20
-        await sleep(0.1)
31
+        if 'STOP' in line:
32
+            raise StopAsyncIteration
21
 
33
 
22
-    return response
34
+        await asyncio.sleep(0.025)
35
+        return json.loads(line)
36
+
37
+
38
+@hapic.with_api_doc()
39
+@hapic.output_stream(item_schema=OutputStreamItemSchema())
40
+def handle(request):
41
+    # response = web.StreamResponse(
42
+    #     status=200,
43
+    #     reason='OK',
44
+    #     headers={
45
+    #         'Content-Type': 'text/plain; charset=utf-8',
46
+    #     },
47
+    # )
48
+    #
49
+    # await response.prepare(request)
50
+    # response.enable_chunked_encoding()
51
+
52
+    # for line in tail("-f", "aiopocdata.txt", _iter=True):
53
+        # await response.write(line.encode('utf-8'))
54
+        # await sleep(0.1)
55
+
56
+    # return response
57
+
58
+    return LinesAsyncGenerator()
23
 
59
 
24
 
60
 
25
 app = web.Application()
61
 app = web.Application()
27
     web.get('/', handle)
63
     web.get('/', handle)
28
 ])
64
 ])
29
 
65
 
30
-
66
+hapic.set_context(AiohttpContext(app))
31
 web.run_app(app)
67
 web.run_app(app)

+ 1 - 0
hapic/__init__.py View File

20
 reset_context = _hapic_default.reset_context
20
 reset_context = _hapic_default.reset_context
21
 add_documentation_view = _hapic_default.add_documentation_view
21
 add_documentation_view = _hapic_default.add_documentation_view
22
 handle_exception = _hapic_default.handle_exception
22
 handle_exception = _hapic_default.handle_exception
23
+output_stream = _hapic_default.output_stream

+ 1 - 0
hapic/async.py View File

18
 reset_context = _hapic_default.reset_context
18
 reset_context = _hapic_default.reset_context
19
 add_documentation_view = _hapic_default.add_documentation_view
19
 add_documentation_view = _hapic_default.add_documentation_view
20
 handle_exception = _hapic_default.handle_exception
20
 handle_exception = _hapic_default.handle_exception
21
+output_stream = _hapic_default.output_stream

+ 48 - 0
hapic/decorator.py View File

313
     pass
313
     pass
314
 
314
 
315
 
315
 
316
+# TODO BS 2018-07-23: This class is an async version of
317
+# OutputBodyControllerWrapper (ControllerWrapper.get_wrapper rewrite)
318
+# to permit async compatibility.
319
+# Please re-think about code refact. TAG: REFACT_ASYNC
316
 class AsyncOutputBodyControllerWrapper(OutputControllerWrapper):
320
 class AsyncOutputBodyControllerWrapper(OutputControllerWrapper):
317
     def get_wrapper(
321
     def get_wrapper(
318
         self,
322
         self,
332
         return functools.update_wrapper(wrapper, func)
336
         return functools.update_wrapper(wrapper, func)
333
 
337
 
334
 
338
 
339
+class AsyncOutputStreamControllerWrapper(OutputControllerWrapper):
340
+    def get_wrapper(
341
+        self,
342
+        func: 'typing.Callable[..., typing.Any]',
343
+    ) -> 'typing.Callable[..., typing.Any]':
344
+        # async def wrapper(*args, **kwargs) -> typing.Any:
345
+        async def wrapper(*args, **kwargs) -> typing.Any:
346
+            # Note: Design of before_wrapped_func can be to update kwargs
347
+            # by reference here
348
+            replacement_response = self.before_wrapped_func(args, kwargs)
349
+            if replacement_response:
350
+                return replacement_response
351
+
352
+            stream_response = await self.context.get_stream_response_object(
353
+                args,
354
+                kwargs,
355
+            )
356
+            async for stream_item in self._execute_wrapped_function(
357
+                func,
358
+                args,
359
+                kwargs,
360
+            ):
361
+                serialized_item = self._get_serialized_item(stream_item)
362
+                await self.context.feed_stream_response(
363
+                    stream_response,
364
+                    serialized_item,
365
+                )
366
+
367
+            return stream_response
368
+
369
+        return functools.update_wrapper(wrapper, func)
370
+
371
+    def _get_serialized_item(
372
+        self,
373
+        item_object: typing.Any,
374
+    ) -> dict:
375
+        try:
376
+            return self.processor.process(item_object)
377
+        except ProcessException:
378
+            # TODO BS 2018-07-25: Must interrupt stream response: but how
379
+            # inform about error ?
380
+            raise NotImplementedError('todo')
381
+
382
+
335
 class OutputHeadersControllerWrapper(OutputControllerWrapper):
383
 class OutputHeadersControllerWrapper(OutputControllerWrapper):
336
     pass
384
     pass
337
 
385
 

+ 6 - 0
hapic/description.py View File

38
     pass
38
     pass
39
 
39
 
40
 
40
 
41
+class OutputStreamDescription(Description):
42
+    pass
43
+
44
+
41
 class OutputFileDescription(Description):
45
 class OutputFileDescription(Description):
42
     pass
46
     pass
43
 
47
 
60
         input_forms: InputFormsDescription=None,
64
         input_forms: InputFormsDescription=None,
61
         input_files: InputFilesDescription=None,
65
         input_files: InputFilesDescription=None,
62
         output_body: OutputBodyDescription=None,
66
         output_body: OutputBodyDescription=None,
67
+        output_stream: OutputStreamDescription=None,
63
         output_file: OutputFileDescription=None,
68
         output_file: OutputFileDescription=None,
64
         output_headers: OutputHeadersDescription=None,
69
         output_headers: OutputHeadersDescription=None,
65
         errors: typing.List[ErrorDescription]=None,
70
         errors: typing.List[ErrorDescription]=None,
72
         self.input_forms = input_forms
77
         self.input_forms = input_forms
73
         self.input_files = input_files
78
         self.input_files = input_files
74
         self.output_body = output_body
79
         self.output_body = output_body
80
+        self.output_stream = output_stream
75
         self.output_file = output_file
81
         self.output_file = output_file
76
         self.output_headers = output_headers
82
         self.output_headers = output_headers
77
         self.errors = errors or []
83
         self.errors = errors or []

+ 56 - 8
hapic/ext/aiohttp/context.py View File

1
 # coding: utf-8
1
 # coding: utf-8
2
 import asyncio
2
 import asyncio
3
+import json
3
 import typing
4
 import typing
4
 from http import HTTPStatus
5
 from http import HTTPStatus
5
 from json import JSONDecodeError
6
 from json import JSONDecodeError
65
     def __init__(
66
     def __init__(
66
         self,
67
         self,
67
         app: web.Application,
68
         app: web.Application,
69
+        debug: bool = False,
68
     ) -> None:
70
     ) -> None:
69
         self._app = app
71
         self._app = app
72
+        self._debug = debug
70
 
73
 
71
     @property
74
     @property
72
     def app(self) -> web.Application:
75
     def app(self) -> web.Application:
110
         self,
113
         self,
111
         decorated_controller: DecoratedController,
114
         decorated_controller: DecoratedController,
112
     ) -> RouteRepresentation:
115
     ) -> RouteRepresentation:
113
-        pass
116
+        # TODO BS 2018-07-15: to do
117
+        raise NotImplementedError('todo')
114
 
118
 
115
     def get_swagger_path(
119
     def get_swagger_path(
116
         self,
120
         self,
117
         contextualised_rule: str,
121
         contextualised_rule: str,
118
     ) -> str:
122
     ) -> str:
119
-        pass
123
+        # TODO BS 2018-07-15: to do
124
+        raise NotImplementedError('todo')
120
 
125
 
121
     def by_pass_output_wrapping(
126
     def by_pass_output_wrapping(
122
         self,
127
         self,
123
         response: typing.Any,
128
         response: typing.Any,
124
     ) -> bool:
129
     ) -> bool:
125
-        pass
130
+        # TODO BS 2018-07-15: to do
131
+        raise NotImplementedError('todo')
126
 
132
 
127
     def add_view(
133
     def add_view(
128
         self,
134
         self,
130
         http_method: str,
136
         http_method: str,
131
         view_func: typing.Callable[..., typing.Any],
137
         view_func: typing.Callable[..., typing.Any],
132
     ) -> None:
138
     ) -> None:
133
-        pass
139
+        # TODO BS 2018-07-15: to do
140
+        raise NotImplementedError('todo')
134
 
141
 
135
     def serve_directory(
142
     def serve_directory(
136
         self,
143
         self,
137
         route_prefix: str,
144
         route_prefix: str,
138
         directory_path: str,
145
         directory_path: str,
139
     ) -> None:
146
     ) -> None:
140
-        pass
147
+        # TODO BS 2018-07-15: to do
148
+        raise NotImplementedError('todo')
141
 
149
 
142
     def is_debug(
150
     def is_debug(
143
         self,
151
         self,
144
     ) -> bool:
152
     ) -> bool:
145
-        pass
153
+        return self._debug
146
 
154
 
147
     def handle_exception(
155
     def handle_exception(
148
         self,
156
         self,
149
         exception_class: typing.Type[Exception],
157
         exception_class: typing.Type[Exception],
150
         http_code: int,
158
         http_code: int,
151
     ) -> None:
159
     ) -> None:
152
-        pass
160
+        # TODO BS 2018-07-15: to do
161
+        raise NotImplementedError('todo')
153
 
162
 
154
     def handle_exceptions(
163
     def handle_exceptions(
155
         self,
164
         self,
156
         exception_classes: typing.List[typing.Type[Exception]],
165
         exception_classes: typing.List[typing.Type[Exception]],
157
         http_code: int,
166
         http_code: int,
158
     ) -> None:
167
     ) -> None:
159
-        pass
168
+        # TODO BS 2018-07-15: to do
169
+        raise NotImplementedError('todo')
170
+
171
+    async def get_stream_response_object(
172
+        self,
173
+        func_args,
174
+        func_kwargs,
175
+        http_code: HTTPStatus = HTTPStatus.OK,
176
+        headers: dict = None,
177
+    ) -> web.StreamResponse:
178
+        headers = headers or {
179
+            'Content-Type': 'text/plain; charset=utf-8',
180
+        }
181
+
182
+        response = web.StreamResponse(
183
+            status=http_code,
184
+            headers=headers,
185
+        )
186
+
187
+        try:
188
+            request = func_args[0]
189
+        except IndexError:
190
+            raise WorkflowException(
191
+                'Unable to get aiohttp request object',
192
+            )
193
+        request = typing.cast(Request, request)
194
+
195
+        await response.prepare(request)
196
+
197
+        return response
198
+
199
+    async def feed_stream_response(
200
+        self,
201
+        stream_response: web.StreamResponse,
202
+        serialized_item: dict,
203
+    ) -> None:
204
+        await stream_response.write(
205
+            # FIXME BS 2018-07-25: need \n :/
206
+            json.dumps(serialized_item).encode('utf-8') + b'\n',
207
+        )

+ 30 - 0
hapic/hapic.py View File

22
 from hapic.decorator import InputFilesControllerWrapper
22
 from hapic.decorator import InputFilesControllerWrapper
23
 from hapic.decorator import OutputBodyControllerWrapper
23
 from hapic.decorator import OutputBodyControllerWrapper
24
 from hapic.decorator import AsyncOutputBodyControllerWrapper
24
 from hapic.decorator import AsyncOutputBodyControllerWrapper
25
+from hapic.decorator import AsyncOutputStreamControllerWrapper
25
 from hapic.decorator import OutputHeadersControllerWrapper
26
 from hapic.decorator import OutputHeadersControllerWrapper
26
 from hapic.decorator import OutputFileControllerWrapper
27
 from hapic.decorator import OutputFileControllerWrapper
27
 from hapic.description import InputBodyDescription
28
 from hapic.description import InputBodyDescription
32
 from hapic.description import InputQueryDescription
33
 from hapic.description import InputQueryDescription
33
 from hapic.description import InputFilesDescription
34
 from hapic.description import InputFilesDescription
34
 from hapic.description import OutputBodyDescription
35
 from hapic.description import OutputBodyDescription
36
+from hapic.description import OutputStreamDescription
35
 from hapic.description import OutputHeadersDescription
37
 from hapic.description import OutputHeadersDescription
36
 from hapic.description import OutputFileDescription
38
 from hapic.description import OutputFileDescription
37
 from hapic.doc import DocGenerator
39
 from hapic.doc import DocGenerator
170
             return decoration.get_wrapper(func)
172
             return decoration.get_wrapper(func)
171
         return decorator
173
         return decorator
172
 
174
 
175
+    def output_stream(
176
+        self,
177
+        item_schema: typing.Any,
178
+        processor: ProcessorInterface = None,
179
+        context: ContextInterface = None,
180
+        error_http_code: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR,
181
+        default_http_code: HTTPStatus = HTTPStatus.OK,
182
+    ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Any]:
183
+        processor = processor or MarshmallowOutputProcessor()
184
+        processor.schema = item_schema
185
+        context = context or self._context_getter
186
+
187
+        if self._async:
188
+            decoration = AsyncOutputStreamControllerWrapper(
189
+                context=context,
190
+                processor=processor,
191
+                error_http_code=error_http_code,
192
+                default_http_code=default_http_code,
193
+            )
194
+        else:
195
+            # TODO BS 2018-07-25: To do
196
+            raise NotImplementedError('todo')
197
+
198
+        def decorator(func):
199
+            self._buffer.output_stream = OutputStreamDescription(decoration)
200
+            return decoration.get_wrapper(func)
201
+        return decorator
202
+
173
     def output_headers(
203
     def output_headers(
174
         self,
204
         self,
175
         schema: typing.Any,
205
         schema: typing.Any,