chisel 2.3.2__tar.gz → 2.4.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.
- {chisel-2.3.2/src/chisel.egg-info → chisel-2.4.0}/PKG-INFO +16 -5
- {chisel-2.3.2 → chisel-2.4.0}/README.md +15 -4
- {chisel-2.3.2 → chisel-2.4.0}/pyproject.toml +1 -1
- {chisel-2.3.2 → chisel-2.4.0}/src/chisel/__init__.py +1 -1
- {chisel-2.3.2 → chisel-2.4.0}/src/chisel/action.py +36 -27
- {chisel-2.3.2 → chisel-2.4.0}/src/chisel/app.py +144 -88
- {chisel-2.3.2 → chisel-2.4.0}/src/chisel/doc.py +4 -6
- {chisel-2.3.2 → chisel-2.4.0}/src/chisel/request.py +13 -17
- {chisel-2.3.2 → chisel-2.4.0/src/chisel.egg-info}/PKG-INFO +16 -5
- {chisel-2.3.2 → chisel-2.4.0}/LICENSE +0 -0
- {chisel-2.3.2 → chisel-2.4.0}/setup.cfg +0 -0
- {chisel-2.3.2 → chisel-2.4.0}/src/chisel/static/__init__.py +0 -0
- {chisel-2.3.2 → chisel-2.4.0}/src/chisel/static/chiselDoc.bare +0 -0
- {chisel-2.3.2 → chisel-2.4.0}/src/chisel/static/index.html +0 -0
- {chisel-2.3.2 → chisel-2.4.0}/src/chisel/static/markdown-up.tar.gz +0 -0
- {chisel-2.3.2 → chisel-2.4.0}/src/chisel.egg-info/SOURCES.txt +0 -0
- {chisel-2.3.2 → chisel-2.4.0}/src/chisel.egg-info/dependency_links.txt +0 -0
- {chisel-2.3.2 → chisel-2.4.0}/src/chisel.egg-info/requires.txt +0 -0
- {chisel-2.3.2 → chisel-2.4.0}/src/chisel.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: chisel
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: Lightweight WSGI application framework, schema-validated JSON APIs, and API documentation
|
|
5
5
|
Author-email: "Craig A. Hobbs" <craigahobbs@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -30,7 +30,7 @@ Dynamic: license-file
|
|
|
30
30
|
[](https://github.com/craigahobbs/chisel/blob/main/LICENSE)
|
|
31
31
|
[](https://pypi.org/project/chisel/)
|
|
32
32
|
|
|
33
|
-
Chisel is a
|
|
33
|
+
Chisel is a lightweight Python WSGI application framework built for creating well-documented,
|
|
34
34
|
schema-validated JSON web APIs.
|
|
35
35
|
|
|
36
36
|
|
|
@@ -46,11 +46,13 @@ Chisel provides the [action](https://craigahobbs.github.io/chisel/action.html#ch
|
|
|
46
46
|
decorator for easily implementing schema-validated JSON APIs.
|
|
47
47
|
|
|
48
48
|
~~~ python
|
|
49
|
+
import chisel
|
|
50
|
+
|
|
49
51
|
@chisel.action(spec='''
|
|
50
52
|
# Sum a list of numbers
|
|
51
53
|
action sum_numbers
|
|
52
54
|
urls
|
|
53
|
-
|
|
55
|
+
GET
|
|
54
56
|
|
|
55
57
|
query
|
|
56
58
|
# The list of numbers
|
|
@@ -87,6 +89,14 @@ status, _, content_bytes = application.request('GET', '/sum_numbers')
|
|
|
87
89
|
b'{"error":"InvalidInput","message":"Required member \\"numbers\\" missing (query string)"}'
|
|
88
90
|
~~~
|
|
89
91
|
|
|
92
|
+
A [chisel.Application](https://craigahobbs.github.io/chisel/app.html#chisel.Application) is a
|
|
93
|
+
standard [WSGI](https://peps.python.org/pep-3333/) application object - host it with any WSGI
|
|
94
|
+
server. For example:
|
|
95
|
+
|
|
96
|
+
~~~ sh
|
|
97
|
+
gunicorn module:application
|
|
98
|
+
~~~
|
|
99
|
+
|
|
90
100
|
|
|
91
101
|
## API Documentation
|
|
92
102
|
|
|
@@ -99,8 +109,9 @@ application = chisel.Application()
|
|
|
99
109
|
application.add_requests(chisel.create_doc_requests())
|
|
100
110
|
~~~
|
|
101
111
|
|
|
102
|
-
By default the documentation application is hosted at "/doc/". An example of
|
|
103
|
-
|
|
112
|
+
By default the documentation application is hosted at "/doc/". An example of Chisel's
|
|
113
|
+
[documentation output](https://craigahobbs.github.io/chisel/example/#var.vName='chisel_doc_request')
|
|
114
|
+
is available.
|
|
104
115
|
|
|
105
116
|
|
|
106
117
|
## Development
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://github.com/craigahobbs/chisel/blob/main/LICENSE)
|
|
6
6
|
[](https://pypi.org/project/chisel/)
|
|
7
7
|
|
|
8
|
-
Chisel is a
|
|
8
|
+
Chisel is a lightweight Python WSGI application framework built for creating well-documented,
|
|
9
9
|
schema-validated JSON web APIs.
|
|
10
10
|
|
|
11
11
|
|
|
@@ -21,11 +21,13 @@ Chisel provides the [action](https://craigahobbs.github.io/chisel/action.html#ch
|
|
|
21
21
|
decorator for easily implementing schema-validated JSON APIs.
|
|
22
22
|
|
|
23
23
|
~~~ python
|
|
24
|
+
import chisel
|
|
25
|
+
|
|
24
26
|
@chisel.action(spec='''
|
|
25
27
|
# Sum a list of numbers
|
|
26
28
|
action sum_numbers
|
|
27
29
|
urls
|
|
28
|
-
|
|
30
|
+
GET
|
|
29
31
|
|
|
30
32
|
query
|
|
31
33
|
# The list of numbers
|
|
@@ -62,6 +64,14 @@ status, _, content_bytes = application.request('GET', '/sum_numbers')
|
|
|
62
64
|
b'{"error":"InvalidInput","message":"Required member \\"numbers\\" missing (query string)"}'
|
|
63
65
|
~~~
|
|
64
66
|
|
|
67
|
+
A [chisel.Application](https://craigahobbs.github.io/chisel/app.html#chisel.Application) is a
|
|
68
|
+
standard [WSGI](https://peps.python.org/pep-3333/) application object - host it with any WSGI
|
|
69
|
+
server. For example:
|
|
70
|
+
|
|
71
|
+
~~~ sh
|
|
72
|
+
gunicorn module:application
|
|
73
|
+
~~~
|
|
74
|
+
|
|
65
75
|
|
|
66
76
|
## API Documentation
|
|
67
77
|
|
|
@@ -74,8 +84,9 @@ application = chisel.Application()
|
|
|
74
84
|
application.add_requests(chisel.create_doc_requests())
|
|
75
85
|
~~~
|
|
76
86
|
|
|
77
|
-
By default the documentation application is hosted at "/doc/". An example of
|
|
78
|
-
|
|
87
|
+
By default the documentation application is hosted at "/doc/". An example of Chisel's
|
|
88
|
+
[documentation output](https://craigahobbs.github.io/chisel/example/#var.vName='chisel_doc_request')
|
|
89
|
+
is available.
|
|
79
90
|
|
|
80
91
|
|
|
81
92
|
## Development
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# https://github.com/craigahobbs/chisel/blob/main/LICENSE
|
|
3
3
|
|
|
4
4
|
"""
|
|
5
|
-
Chisel is a
|
|
5
|
+
Chisel is a lightweight Python WSGI application framework with tools for building well-documented,
|
|
6
6
|
well-tested, schema-validated JSON web APIs.
|
|
7
7
|
"""
|
|
8
8
|
|
|
@@ -17,7 +17,7 @@ from .request import Request
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
# Regex for parsing the Content-Type header
|
|
20
|
-
RE_CONTENT_TYPE_HEADER = re.compile(r'\
|
|
20
|
+
RE_CONTENT_TYPE_HEADER = re.compile(r'(?:^|[;\s])charset\s*=\s*"?(?P<charset>[^";\s]+)', re.IGNORECASE)
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
def action(action_callback=None, **kwargs):
|
|
@@ -58,7 +58,8 @@ def action(action_callback=None, **kwargs):
|
|
|
58
58
|
'message': 'Invalid value "1" (type "str") for member "numbers", expected '
|
|
59
59
|
'type "array" (query string)'}
|
|
60
60
|
|
|
61
|
-
When :attr:`~chisel.Application.validate_output` the response dictionary is also validated
|
|
61
|
+
When :attr:`~chisel.Application.validate_output` is True, the response dictionary is also validated against the
|
|
62
|
+
output schema.
|
|
62
63
|
|
|
63
64
|
:param ~collections.abc.Callable action_callback: The action callback function
|
|
64
65
|
"""
|
|
@@ -128,25 +129,34 @@ class Action(Request):
|
|
|
128
129
|
>>> def my_action(ctx, req):
|
|
129
130
|
... return {}
|
|
130
131
|
|
|
131
|
-
The first
|
|
132
|
-
|
|
132
|
+
The first argument, "ctx", is the :class:`~chisel.Context` object. The second argument is the request object which
|
|
133
|
+
contains the schema-validated, combined path parameters, query string parameters, and JSON request content
|
|
133
134
|
parameters.
|
|
134
135
|
|
|
135
136
|
:param ~collections.abc.Callable action_callback: The action callback function
|
|
136
137
|
:param str name: The action request name
|
|
137
138
|
:param list(tuple) urls: The list of URL method/path tuples. The first value is the HTTP request method (e.g. 'GET')
|
|
138
|
-
or None to match any. The second value is the URL path or None to use the default path.
|
|
139
|
+
or None to match any. The second value is the URL path or None to use the default path. If the action's
|
|
140
|
+
specification contains a "urls" section, the specification's URLs take precedence.
|
|
139
141
|
:param dict types: Optional dictionary of user type models
|
|
140
142
|
:param str spec: Optional action `Schema Markdown <https://craigahobbs.github.io/schema-markdown-js/language/>`__ specification.
|
|
141
143
|
If a specification isn't provided it can be provided through the "types" argument.
|
|
142
144
|
:param bool wsgi_response: If True, the callback function's response is a WSGI application function
|
|
143
145
|
response. Default is False.
|
|
144
|
-
:param str jsonp: Optional JSONP key
|
|
145
146
|
"""
|
|
146
147
|
|
|
147
|
-
__slots__ = (
|
|
148
|
+
__slots__ = (
|
|
149
|
+
'action_callback',
|
|
150
|
+
'types',
|
|
151
|
+
'wsgi_response',
|
|
152
|
+
'_input_type',
|
|
153
|
+
'_query_type',
|
|
154
|
+
'_path_type',
|
|
155
|
+
'_output_type',
|
|
156
|
+
'_error_type'
|
|
157
|
+
)
|
|
148
158
|
|
|
149
|
-
def __init__(self, action_callback, name=None, urls=(('POST', None),), types=None, spec=None, wsgi_response=False
|
|
159
|
+
def __init__(self, action_callback, name=None, urls=(('POST', None),), types=None, spec=None, wsgi_response=False):
|
|
150
160
|
|
|
151
161
|
# Use the action callback name if no name is provided
|
|
152
162
|
if name is None:
|
|
@@ -179,8 +189,12 @@ class Action(Request):
|
|
|
179
189
|
#: If True, the callback function's response is a WSGI application function response.
|
|
180
190
|
self.wsgi_response = wsgi_response
|
|
181
191
|
|
|
182
|
-
|
|
183
|
-
self.
|
|
192
|
+
# Pre-compute the section types and the error response type
|
|
193
|
+
self._input_type = self._get_section_type('input')
|
|
194
|
+
self._query_type = self._get_section_type('query')
|
|
195
|
+
self._path_type = self._get_section_type('path')
|
|
196
|
+
self._output_type = self._get_section_type('output')
|
|
197
|
+
self._error_type = self._get_error_type()
|
|
184
198
|
|
|
185
199
|
@property
|
|
186
200
|
def model(self):
|
|
@@ -231,13 +245,13 @@ class Action(Request):
|
|
|
231
245
|
|
|
232
246
|
# Handle the action
|
|
233
247
|
is_get = (environ['REQUEST_METHOD'] == 'GET')
|
|
234
|
-
|
|
248
|
+
app_validate_output = ctx.app.validate_output
|
|
235
249
|
validate_output = True
|
|
236
250
|
try:
|
|
237
251
|
# Read the request content
|
|
238
252
|
try:
|
|
239
253
|
content = None if is_get else environ['wsgi.input'].read()
|
|
240
|
-
except:
|
|
254
|
+
except Exception:
|
|
241
255
|
raise _ActionErrorInternal(HTTPStatus.REQUEST_TIMEOUT, 'IOError', message='Error reading request content')
|
|
242
256
|
|
|
243
257
|
# De-serialize the JSON content
|
|
@@ -255,7 +269,7 @@ class Action(Request):
|
|
|
255
269
|
raise _ActionErrorInternal(HTTPStatus.BAD_REQUEST, 'InvalidInput', message=f'Invalid request JSON: {exc}')
|
|
256
270
|
|
|
257
271
|
# Validate the content
|
|
258
|
-
input_types, input_type = self.
|
|
272
|
+
input_types, input_type = self._input_type
|
|
259
273
|
try:
|
|
260
274
|
request = validate_type(input_types, input_type, request)
|
|
261
275
|
except ValidationError as exc:
|
|
@@ -275,13 +289,8 @@ class Action(Request):
|
|
|
275
289
|
ctx.log.warning('Error decoding query string for action "%s": %.1000r', self.name, query_string)
|
|
276
290
|
raise _ActionErrorInternal(HTTPStatus.BAD_REQUEST, 'InvalidInput', message=f'{exc}')
|
|
277
291
|
|
|
278
|
-
# JSONP?
|
|
279
|
-
if is_get and self.jsonp and self.jsonp in request_query:
|
|
280
|
-
jsonp = f'{request_query[self.jsonp]}'
|
|
281
|
-
del request_query[self.jsonp]
|
|
282
|
-
|
|
283
292
|
# Validate the query string
|
|
284
|
-
query_types, query_type = self.
|
|
293
|
+
query_types, query_type = self._query_type
|
|
285
294
|
try:
|
|
286
295
|
request_query = validate_type(query_types, query_type, request_query)
|
|
287
296
|
except ValidationError as exc:
|
|
@@ -294,7 +303,7 @@ class Action(Request):
|
|
|
294
303
|
)
|
|
295
304
|
|
|
296
305
|
# Validate the path args
|
|
297
|
-
path_types, path_type = self.
|
|
306
|
+
path_types, path_type = self._path_type
|
|
298
307
|
request_path = ctx.url_args if ctx.url_args is not None else {}
|
|
299
308
|
try:
|
|
300
309
|
request_path = validate_type(path_types, path_type, request_path)
|
|
@@ -321,23 +330,23 @@ class Action(Request):
|
|
|
321
330
|
return response
|
|
322
331
|
if response is None:
|
|
323
332
|
response = {}
|
|
324
|
-
output_types, output_type = self.
|
|
333
|
+
output_types, output_type = self._output_type
|
|
325
334
|
except ActionError as exc:
|
|
326
335
|
status = exc.status or HTTPStatus.BAD_REQUEST
|
|
327
336
|
response = {'error': exc.error}
|
|
328
337
|
if exc.message is not None:
|
|
329
338
|
response['message'] = exc.message
|
|
330
|
-
if
|
|
331
|
-
if exc.error
|
|
339
|
+
if app_validate_output:
|
|
340
|
+
if exc.error == 'UnexpectedError':
|
|
332
341
|
validate_output = False
|
|
333
342
|
else:
|
|
334
|
-
output_types, output_type = self.
|
|
335
|
-
except Exception
|
|
343
|
+
output_types, output_type = self._error_type
|
|
344
|
+
except Exception:
|
|
336
345
|
ctx.log.exception('Unexpected error in action "%s"', self.name)
|
|
337
346
|
raise _ActionErrorInternal(HTTPStatus.INTERNAL_SERVER_ERROR, 'UnexpectedError')
|
|
338
347
|
|
|
339
348
|
# Validate the response
|
|
340
|
-
if not self.wsgi_response and validate_output and
|
|
349
|
+
if not self.wsgi_response and validate_output and app_validate_output:
|
|
341
350
|
try:
|
|
342
351
|
validate_type(output_types, output_type, response)
|
|
343
352
|
except ValidationError as exc:
|
|
@@ -353,4 +362,4 @@ class Action(Request):
|
|
|
353
362
|
response['member'] = exc.member
|
|
354
363
|
|
|
355
364
|
# Serialize the response as JSON
|
|
356
|
-
return ctx.response_json(status, response
|
|
365
|
+
return ctx.response_json(status, response)
|
|
@@ -6,6 +6,7 @@ Chisel WSGI application base class and utilities
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from datetime import datetime, timedelta, timezone
|
|
9
|
+
from email.utils import format_datetime
|
|
9
10
|
from http import HTTPStatus
|
|
10
11
|
from io import BytesIO
|
|
11
12
|
import logging
|
|
@@ -15,9 +16,8 @@ from urllib.parse import quote, unquote
|
|
|
15
16
|
from schema_markdown import encode_query_string, JSONEncoder
|
|
16
17
|
|
|
17
18
|
|
|
18
|
-
# Regular expression for matching URL
|
|
19
|
-
RE_URL_ARG = re.compile(r'
|
|
20
|
-
RE_URL_ARG_ESC = re.compile(r'/\\{([A-Za-z]\w*)\\}')
|
|
19
|
+
# Regular expression for matching a URL argument path segment (e.g. "{id}")
|
|
20
|
+
RE_URL_ARG = re.compile(r'\{([A-Za-z][A-Za-z0-9_]*)\}')
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
class Application:
|
|
@@ -35,7 +35,9 @@ class Application:
|
|
|
35
35
|
'validate_output',
|
|
36
36
|
'requests',
|
|
37
37
|
'__request_urls',
|
|
38
|
-
'
|
|
38
|
+
'__request_paths',
|
|
39
|
+
'__request_regex',
|
|
40
|
+
'__request_regex_urls'
|
|
39
41
|
)
|
|
40
42
|
|
|
41
43
|
def __init__(self):
|
|
@@ -46,8 +48,8 @@ class Application:
|
|
|
46
48
|
#: The application's log format string. The default is ``'%(levelname)s [%(process)s / %(thread)s] %(message)s'``.
|
|
47
49
|
self.log_format = '%(levelname)s [%(process)s / %(thread)s] %(message)s'
|
|
48
50
|
|
|
49
|
-
#: Set to True for "pretty" request output. Individual requests can
|
|
50
|
-
#: fit. For example, :class:`~chisel.Action`
|
|
51
|
+
#: Set to True for "pretty" request output. Individual requests can use this application state as they see
|
|
52
|
+
#: fit. For example, :class:`~chisel.Action` requests return indented JSON when this value is True. Default is
|
|
51
53
|
#: False.
|
|
52
54
|
self.pretty_output = False
|
|
53
55
|
|
|
@@ -60,32 +62,67 @@ class Application:
|
|
|
60
62
|
self.requests = {}
|
|
61
63
|
|
|
62
64
|
self.__request_urls = {}
|
|
65
|
+
self.__request_paths = set()
|
|
63
66
|
self.__request_regex = []
|
|
67
|
+
self.__request_regex_urls = set()
|
|
64
68
|
|
|
65
69
|
def add_request(self, request):
|
|
66
70
|
"""
|
|
67
|
-
Add a :class:`~chisel.Request` to the application.
|
|
71
|
+
Add a :class:`~chisel.Request` to the application. URL arguments (e.g. ``'/documents/{id}'``) must span an
|
|
72
|
+
entire path segment.
|
|
68
73
|
|
|
69
74
|
:param ~chisel.Request request: The request object.
|
|
75
|
+
:raises ValueError: If the request name or a request URL is redefined, or if a request URL contains an
|
|
76
|
+
invalid URL argument
|
|
70
77
|
"""
|
|
71
78
|
|
|
72
79
|
# Duplicate request name?
|
|
73
80
|
if request.name in self.requests:
|
|
74
81
|
raise ValueError(f'redefinition of request "{request.name}"')
|
|
75
|
-
self.requests[request.name] = request
|
|
76
82
|
|
|
77
|
-
#
|
|
83
|
+
# Validate the request URLs - the request is added only if the entire request is valid
|
|
84
|
+
request_urls = {}
|
|
85
|
+
request_regex = []
|
|
78
86
|
for method, url in request.urls:
|
|
79
87
|
|
|
80
88
|
# URL with arguments?
|
|
81
|
-
if
|
|
82
|
-
|
|
83
|
-
|
|
89
|
+
if '{' in url or '}' in url:
|
|
90
|
+
|
|
91
|
+
# Compute the URL regular expression - it is matched with fullmatch
|
|
92
|
+
url_args = []
|
|
93
|
+
regex_segments = []
|
|
94
|
+
for segment in url.split('/'):
|
|
95
|
+
match_url_arg = RE_URL_ARG.fullmatch(segment)
|
|
96
|
+
if match_url_arg is not None:
|
|
97
|
+
url_arg = match_url_arg.group(1)
|
|
98
|
+
if url_arg in url_args:
|
|
99
|
+
raise ValueError(f'duplicate URL argument "{segment}" in URL "{url}" of request "{request.name}"')
|
|
100
|
+
url_args.append(url_arg)
|
|
101
|
+
regex_segments.append(f'(?P<{url_arg}>[^/]+)')
|
|
102
|
+
elif '{' in segment or '}' in segment:
|
|
103
|
+
raise ValueError(f'invalid URL argument "{segment}" in URL "{url}" of request "{request.name}"')
|
|
104
|
+
else:
|
|
105
|
+
regex_segments.append(re.escape(segment))
|
|
106
|
+
|
|
107
|
+
# Duplicate request URL? URL argument URLs match regardless of argument names.
|
|
108
|
+
url_key = (method, RE_URL_ARG.sub('{}', url))
|
|
109
|
+
if url_key in self.__request_regex_urls or any(key == url_key for key, _ in request_regex):
|
|
110
|
+
raise ValueError(f'redefinition of request URL "{url}"')
|
|
111
|
+
request_regex.append((url_key, re.compile('/'.join(regex_segments))))
|
|
84
112
|
else:
|
|
85
|
-
|
|
86
|
-
|
|
113
|
+
# Duplicate request URL?
|
|
114
|
+
url_key = (method, url)
|
|
115
|
+
if url_key in self.__request_urls or url_key in request_urls:
|
|
87
116
|
raise ValueError(f'redefinition of request URL "{url}"')
|
|
88
|
-
|
|
117
|
+
request_urls[url_key] = request
|
|
118
|
+
|
|
119
|
+
# Add the request and its URLs
|
|
120
|
+
self.requests[request.name] = request
|
|
121
|
+
self.__request_urls.update(request_urls)
|
|
122
|
+
self.__request_paths.update(path for _, path in request_urls)
|
|
123
|
+
for url_key, url_regex in request_regex:
|
|
124
|
+
self.__request_regex_urls.add(url_key)
|
|
125
|
+
self.__request_regex.append((url_key[0], url_regex, request))
|
|
89
126
|
|
|
90
127
|
def add_requests(self, requests):
|
|
91
128
|
"""
|
|
@@ -110,50 +147,37 @@ class Application:
|
|
|
110
147
|
:rtype: tuple(chisel.Request or None, dict or None)
|
|
111
148
|
"""
|
|
112
149
|
|
|
113
|
-
#
|
|
150
|
+
# Match the request by exact URL and method
|
|
114
151
|
request = self.__request_urls.get((request_method, path_info))
|
|
115
152
|
if request is not None:
|
|
116
153
|
return request, None
|
|
117
154
|
|
|
118
|
-
# Match the request by
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
(request, regex.match(path_info)) for method, regex, request in self.__request_regex
|
|
125
|
-
if method is not None and method == request_method
|
|
126
|
-
)
|
|
127
|
-
if request_match
|
|
128
|
-
),
|
|
129
|
-
(None, None)
|
|
130
|
-
)
|
|
131
|
-
if request is not None:
|
|
132
|
-
return request, url_args
|
|
155
|
+
# Match the request by URL regular expression and method
|
|
156
|
+
for method, regex, request in self.__request_regex:
|
|
157
|
+
if method is not None and method == request_method:
|
|
158
|
+
match_path = regex.fullmatch(path_info)
|
|
159
|
+
if match_path is not None:
|
|
160
|
+
return request, {unquote(url_arg): unquote(url_value) for url_arg, url_value in match_path.groupdict().items()}
|
|
133
161
|
|
|
134
162
|
# Match the request by exact URL (any method)
|
|
135
|
-
request
|
|
136
|
-
if request is None:
|
|
163
|
+
request = self.__request_urls.get((None, path_info))
|
|
164
|
+
if request is not None:
|
|
165
|
+
return request, None
|
|
137
166
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if request_match
|
|
148
|
-
),
|
|
149
|
-
(None, None)
|
|
150
|
-
)
|
|
151
|
-
return request, url_args
|
|
167
|
+
# Match the request by URL regular expression (any method)
|
|
168
|
+
for method, regex, request in self.__request_regex:
|
|
169
|
+
if method is None:
|
|
170
|
+
match_path = regex.fullmatch(path_info)
|
|
171
|
+
if match_path is not None:
|
|
172
|
+
return request, {unquote(url_arg): unquote(url_value) for url_arg, url_value in match_path.groupdict().items()}
|
|
173
|
+
|
|
174
|
+
# No matching request
|
|
175
|
+
return None, None
|
|
152
176
|
|
|
153
177
|
def __call__(self, environ, start_response):
|
|
154
178
|
"""
|
|
155
|
-
The chisel application WSGI callback. When the application
|
|
156
|
-
appropriate :class:`~chisel.Request
|
|
179
|
+
The chisel application WSGI callback. When the application receives an HTTP request, this method matches the
|
|
180
|
+
appropriate :class:`~chisel.Request` object and then calls its :func:`~chisel.Request.__call__` method. The
|
|
157
181
|
application and URL path arguments (e.g. ``'/documents/{id}'``) are made available to the request through the
|
|
158
182
|
request's :class:`~chisel.Context` object.
|
|
159
183
|
|
|
@@ -175,10 +199,12 @@ class Application:
|
|
|
175
199
|
# Create the request context
|
|
176
200
|
ctx = environ[Context.ENVIRON_CTX] = Context(self, environ, start_response, url_args)
|
|
177
201
|
|
|
178
|
-
# Request not found?
|
|
202
|
+
# Request not found? The request path exists if it matches an exact URL under any method or a URL regular
|
|
203
|
+
# expression under another method - match_request already tried this method's and any-method's regexes.
|
|
179
204
|
if request is None:
|
|
180
|
-
if
|
|
181
|
-
any(regex.
|
|
205
|
+
if path_info in self.__request_paths or \
|
|
206
|
+
any(regex.fullmatch(path_info)
|
|
207
|
+
for method, regex, _ in self.__request_regex if method is not None and method != request_method):
|
|
182
208
|
response = ctx.response_text(HTTPStatus.METHOD_NOT_ALLOWED)
|
|
183
209
|
else:
|
|
184
210
|
response = ctx.response_text(HTTPStatus.NOT_FOUND)
|
|
@@ -186,11 +212,22 @@ class Application:
|
|
|
186
212
|
# Handle the request
|
|
187
213
|
try:
|
|
188
214
|
response = request(ctx.environ, ctx.start_response)
|
|
189
|
-
except:
|
|
190
|
-
|
|
215
|
+
except Exception:
|
|
216
|
+
# A logging failure (e.g. invalid log_format) must not suppress the error response
|
|
217
|
+
try:
|
|
218
|
+
ctx.log.exception('exception raised by request "%s"', request.name)
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
191
221
|
response = ctx.response_text(HTTPStatus.INTERNAL_SERVER_ERROR)
|
|
192
222
|
|
|
193
223
|
if is_head:
|
|
224
|
+
# PEP 3333 - the discarded response content must be closed. A close failure must not
|
|
225
|
+
# suppress the HEAD response.
|
|
226
|
+
if hasattr(response, 'close'):
|
|
227
|
+
try:
|
|
228
|
+
response.close()
|
|
229
|
+
except Exception:
|
|
230
|
+
pass
|
|
194
231
|
return []
|
|
195
232
|
return response
|
|
196
233
|
|
|
@@ -223,7 +260,7 @@ class Context:
|
|
|
223
260
|
:param dict url_args: The parsed URL arguments dictionary
|
|
224
261
|
"""
|
|
225
262
|
|
|
226
|
-
__slots__ = ('app', 'environ', '_start_response', 'url_args', '
|
|
263
|
+
__slots__ = ('app', 'environ', '_start_response', 'url_args', '_log', 'headers')
|
|
227
264
|
|
|
228
265
|
#: The context WSGI environ key
|
|
229
266
|
ENVIRON_CTX = 'chisel.ctx'
|
|
@@ -244,19 +281,39 @@ class Context:
|
|
|
244
281
|
#: The request's header map. These headers are added to the response.
|
|
245
282
|
self.headers = {}
|
|
246
283
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
284
|
+
self._log = None
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def log(self):
|
|
288
|
+
"""
|
|
289
|
+
The python logger instance. Write log messages using this object directly. The logger is created lazily on
|
|
290
|
+
first access using the application's :attr:`~chisel.Application.log_level` and
|
|
291
|
+
:attr:`~chisel.Application.log_format`.
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
if self._log is None:
|
|
295
|
+
# Assign the logger before formatting so a callable log_format may access it
|
|
296
|
+
log = self._log = logging.getLoggerClass()('')
|
|
297
|
+
log.setLevel(self.app.log_level)
|
|
298
|
+
wsgi_errors = self.environ.get('wsgi.errors')
|
|
299
|
+
if wsgi_errors is None:
|
|
300
|
+
handler = logging.NullHandler()
|
|
301
|
+
else:
|
|
302
|
+
handler = logging.StreamHandler(wsgi_errors)
|
|
303
|
+
if callable(self.app.log_format):
|
|
304
|
+
handler.setFormatter(self.app.log_format(self))
|
|
305
|
+
else:
|
|
306
|
+
handler.setFormatter(logging.Formatter(self.app.log_format))
|
|
307
|
+
log.addHandler(handler)
|
|
308
|
+
return self._log
|
|
309
|
+
|
|
310
|
+
@log.setter
|
|
311
|
+
def log(self, log):
|
|
312
|
+
self._log = log
|
|
313
|
+
|
|
314
|
+
@log.deleter
|
|
315
|
+
def log(self):
|
|
316
|
+
self._log = None
|
|
260
317
|
|
|
261
318
|
@staticmethod
|
|
262
319
|
def create_environ(request_method, path_info, query_string='', wsgi_input=b'', environ=None):
|
|
@@ -268,7 +325,7 @@ class Context:
|
|
|
268
325
|
:param str query_string: Optional query string
|
|
269
326
|
:param bytes wsgi_input: Optional request content
|
|
270
327
|
:param dict environ: Optional environ dict. If not provided, a minimal default environ is created.
|
|
271
|
-
:returns: The created
|
|
328
|
+
:returns: The created environ dict
|
|
272
329
|
"""
|
|
273
330
|
|
|
274
331
|
if environ is None:
|
|
@@ -302,7 +359,8 @@ class Context:
|
|
|
302
359
|
|
|
303
360
|
def add_header(self, key, value):
|
|
304
361
|
"""
|
|
305
|
-
Add a header key/value to the request's response
|
|
362
|
+
Add a header key/value to the request's response. Adding a header key again replaces its value - repeated
|
|
363
|
+
response headers (e.g. multiple "Set-Cookie" headers) are not supported.
|
|
306
364
|
|
|
307
365
|
>>> @chisel.action(spec='''
|
|
308
366
|
... action my_action
|
|
@@ -354,7 +412,8 @@ class Context:
|
|
|
354
412
|
|
|
355
413
|
:param str control: ``'public'``, ``'private'``, or None (for no-cache)
|
|
356
414
|
:param int ttl_seconds: Cache duration in seconds. Do not specify for no-cache.
|
|
357
|
-
:param ~datetime.datetime utcnow: A :func:`~datetime.datetime` to use as the current datetime
|
|
415
|
+
:param ~datetime.datetime utcnow: A :func:`~datetime.datetime` to use as the current datetime. A naive
|
|
416
|
+
datetime is assumed to be UTC.
|
|
358
417
|
"""
|
|
359
418
|
|
|
360
419
|
if self.environ.get('REQUEST_METHOD') == 'GET':
|
|
@@ -362,13 +421,15 @@ class Context:
|
|
|
362
421
|
self.add_header('Cache-Control', 'no-cache')
|
|
363
422
|
else:
|
|
364
423
|
assert control in ('public', 'private')
|
|
365
|
-
assert isinstance(ttl_seconds, int) and ttl_seconds > 0
|
|
424
|
+
assert isinstance(ttl_seconds, int) and not isinstance(ttl_seconds, bool) and ttl_seconds > 0
|
|
366
425
|
self.add_header('Cache-Control', f'{control},max-age={ttl_seconds}')
|
|
367
426
|
if utcnow is None:
|
|
368
|
-
utcnow = datetime.
|
|
427
|
+
utcnow = datetime.now(timezone.utc)
|
|
428
|
+
elif utcnow.tzinfo is None:
|
|
429
|
+
utcnow = utcnow.replace(tzinfo=timezone.utc)
|
|
369
430
|
else:
|
|
370
431
|
utcnow = utcnow.astimezone(timezone.utc)
|
|
371
|
-
self.add_header('Expires', (utcnow + timedelta(seconds=ttl_seconds)
|
|
432
|
+
self.add_header('Expires', format_datetime(utcnow + timedelta(seconds=ttl_seconds), usegmt=True))
|
|
372
433
|
|
|
373
434
|
def response(self, status, content_type, content, headers=None):
|
|
374
435
|
"""
|
|
@@ -404,7 +465,7 @@ class Context:
|
|
|
404
465
|
self.start_response(status, response_headers)
|
|
405
466
|
return content
|
|
406
467
|
|
|
407
|
-
def response_text(self, status, text=None, content_type=
|
|
468
|
+
def response_text(self, status, text=None, content_type=None, encoding='utf-8', headers=None):
|
|
408
469
|
"""
|
|
409
470
|
A plain-text WSGI response
|
|
410
471
|
|
|
@@ -421,16 +482,19 @@ class Context:
|
|
|
421
482
|
>>> application = chisel.Application()
|
|
422
483
|
>>> application.add_request(my_action)
|
|
423
484
|
>>> application.request('GET', '/my_action')
|
|
424
|
-
('200 OK', [('Content-Type', 'text/plain')], b'Hello')
|
|
485
|
+
('200 OK', [('Content-Type', 'text/plain; charset=utf-8')], b'Hello')
|
|
425
486
|
|
|
426
487
|
:param status: The HTTP response status
|
|
427
488
|
:type status: ~http.HTTPStatus or str
|
|
428
489
|
:param str text: The response text
|
|
429
|
-
:param str content_type: The response content type. The default is "text/plain"
|
|
490
|
+
:param str content_type: The response content type. The default is "text/plain" with the
|
|
491
|
+
"encoding" parameter's charset.
|
|
430
492
|
:param str encoding: The content encoding. The default is "utf-8".
|
|
431
493
|
:param list(tuple) headers: Optional list of key/value header tuples to add to the response
|
|
432
494
|
"""
|
|
433
495
|
|
|
496
|
+
if content_type is None:
|
|
497
|
+
content_type = f'text/plain; charset={encoding}'
|
|
434
498
|
if text is None:
|
|
435
499
|
if isinstance(status, str):
|
|
436
500
|
text = status
|
|
@@ -438,7 +502,7 @@ class Context:
|
|
|
438
502
|
text = status.phrase
|
|
439
503
|
return self.response(status, content_type, [text.encode(encoding)], headers=headers)
|
|
440
504
|
|
|
441
|
-
def response_json(self, status, response, content_type='application/json', encoding='utf-8', headers=None
|
|
505
|
+
def response_json(self, status, response, content_type='application/json', encoding='utf-8', headers=None):
|
|
442
506
|
"""
|
|
443
507
|
A JSON response
|
|
444
508
|
|
|
@@ -463,7 +527,6 @@ class Context:
|
|
|
463
527
|
:param str content_type: The response content type. The default is "application/json".
|
|
464
528
|
:param str encoding: The content encoding. The default is "utf-8".
|
|
465
529
|
:param list(tuple) headers: Optional list of key/value header tuples to add to the response
|
|
466
|
-
:param str jsonp: Optional JSONP key
|
|
467
530
|
"""
|
|
468
531
|
|
|
469
532
|
encoder = JSONEncoder(
|
|
@@ -474,11 +537,7 @@ class Context:
|
|
|
474
537
|
separators=(',', ': ') if self.app.pretty_output else (',', ':')
|
|
475
538
|
)
|
|
476
539
|
content = encoder.encode(response)
|
|
477
|
-
|
|
478
|
-
content_list = [jsonp.encode(encoding), b'(', content.encode(encoding), b');']
|
|
479
|
-
else:
|
|
480
|
-
content_list = [content.encode(encoding)]
|
|
481
|
-
return self.response(status, content_type, content_list, headers=headers)
|
|
540
|
+
return self.response(status, content_type, [content.encode(encoding)], headers=headers)
|
|
482
541
|
|
|
483
542
|
def reconstruct_url(self, path_info=None, query_string=None, relative=False):
|
|
484
543
|
"""
|
|
@@ -538,12 +597,9 @@ class StartResponse:
|
|
|
538
597
|
... return [b'Hello']
|
|
539
598
|
>>> start_response = chisel.app.StartResponse()
|
|
540
599
|
>>> application({}, start_response)
|
|
600
|
+
[b'Hello']
|
|
541
601
|
>>> start_response.status, start_response.headers
|
|
542
602
|
('200 OK', [('Content-Type', 'text/plain')])
|
|
543
|
-
|
|
544
|
-
:param status: The HTTP response status
|
|
545
|
-
:type status: ~http.HTTPStatus or str
|
|
546
|
-
:param list(tuple) headers: Optional list of key/value header tuples to add to the response
|
|
547
603
|
"""
|
|
548
604
|
|
|
549
605
|
__slots__ = ('status', 'headers')
|
|
@@ -19,7 +19,7 @@ from .request import RedirectRequest, StaticRequest
|
|
|
19
19
|
def create_doc_requests(requests=None, root_path='/doc', api=True, app=True, markdown_up=False):
|
|
20
20
|
"""
|
|
21
21
|
Yield a series of requests for use with :meth:`~chisel.Application.add_requests` comprising the Chisel
|
|
22
|
-
documentation application. By default, the
|
|
22
|
+
documentation application. By default, the documentation application is hosted at "/doc/".
|
|
23
23
|
|
|
24
24
|
:param requests: A list of requests or None to use the application's requests
|
|
25
25
|
:type requests: list(~chisel.Request)
|
|
@@ -112,18 +112,16 @@ action chisel_doc_index
|
|
|
112
112
|
groups = {}
|
|
113
113
|
for request in requests.values():
|
|
114
114
|
request_group = request.doc_group or 'Uncategorized'
|
|
115
|
-
|
|
116
|
-
groups[request_group] = []
|
|
117
|
-
groups[request_group].append(request.name)
|
|
115
|
+
groups.setdefault(request_group, []).append(request.name)
|
|
118
116
|
return {
|
|
119
|
-
'title': ctx.environ
|
|
117
|
+
'title': ctx.environ.get('HTTP_HOST') or ctx.environ['SERVER_NAME'],
|
|
120
118
|
'groups': {group: sorted(names) for group, names in groups.items()}
|
|
121
119
|
}
|
|
122
120
|
|
|
123
121
|
|
|
124
122
|
class DocRequest(Action):
|
|
125
123
|
"""
|
|
126
|
-
The documentation request API. This API provides all the information the documentation
|
|
124
|
+
The documentation request API. This API provides all the information the documentation application needs to render
|
|
127
125
|
the request documentation page. The documentation request API's documentation is `here
|
|
128
126
|
<doc/#name=chisel_doc_request>`__.
|
|
129
127
|
|
|
@@ -8,7 +8,6 @@ Chisel request base class and common request classes
|
|
|
8
8
|
from functools import partial
|
|
9
9
|
import hashlib
|
|
10
10
|
from http import HTTPStatus
|
|
11
|
-
from itertools import chain
|
|
12
11
|
import posixpath
|
|
13
12
|
import re
|
|
14
13
|
|
|
@@ -37,8 +36,8 @@ def request(wsgi_callback=None, **kwargs):
|
|
|
37
36
|
...
|
|
38
37
|
('my_request', (('GET', '/my_request'),), 'This is my request', None)
|
|
39
38
|
|
|
40
|
-
The created :class:`~chisel.Request` object is passed to an application's :meth:`~chisel.add_request`
|
|
41
|
-
it with that
|
|
39
|
+
The created :class:`~chisel.Request` object is passed to an application's :meth:`~chisel.Application.add_request`
|
|
40
|
+
method to host it with that application.
|
|
42
41
|
|
|
43
42
|
>>> application = chisel.Application()
|
|
44
43
|
>>> application.add_request(my_request)
|
|
@@ -89,11 +88,10 @@ class Request:
|
|
|
89
88
|
#: The list of URL method/path tuples
|
|
90
89
|
self.urls = ((None, '/' + self.name),)
|
|
91
90
|
else:
|
|
92
|
-
self.urls = tuple(
|
|
93
|
-
(
|
|
94
|
-
((url[0] and url[0].upper(), url[1] or '/' + self.name),)
|
|
91
|
+
self.urls = tuple(
|
|
92
|
+
(None, '/' + self.name) if url is None else (url[0] and url[0].upper(), url[1] or '/' + self.name)
|
|
95
93
|
for url in urls
|
|
96
|
-
)
|
|
94
|
+
)
|
|
97
95
|
|
|
98
96
|
def __call__(self, environ, start_response):
|
|
99
97
|
"""
|
|
@@ -110,11 +108,11 @@ class Request:
|
|
|
110
108
|
|
|
111
109
|
class RedirectRequest(Request):
|
|
112
110
|
"""
|
|
113
|
-
A redirect
|
|
111
|
+
A redirect request
|
|
114
112
|
|
|
115
113
|
:param list(tuple) urls: The list of URL method/path tuples. The first value is the HTTP request method (e.g. 'GET')
|
|
116
114
|
or None to match any. The second value is the URL path or None to use the default path.
|
|
117
|
-
:param str redirect_url: The
|
|
115
|
+
:param str redirect_url: The redirected URL
|
|
118
116
|
:param bool permanent: If True, this is a permanent redirect
|
|
119
117
|
:param str name: The request name. By default the name is "redirect_<redirect_url>".
|
|
120
118
|
:param doc: The documentation markdown text lines
|
|
@@ -126,7 +124,7 @@ class RedirectRequest(Request):
|
|
|
126
124
|
|
|
127
125
|
def __init__(self, urls, redirect_url, permanent=True, name=None, doc=None, doc_group='Redirects'):
|
|
128
126
|
if name is None:
|
|
129
|
-
name = re.sub(r'
|
|
127
|
+
name = re.sub(r'[\W_]+', '_', f'redirect_{redirect_url}').rstrip('_')
|
|
130
128
|
if doc is None:
|
|
131
129
|
doc = (f'Redirect to {redirect_url}',)
|
|
132
130
|
super().__init__(name=name, urls=urls, doc=doc, doc_group=doc_group)
|
|
@@ -144,7 +142,7 @@ class StaticRequest(Request):
|
|
|
144
142
|
"""
|
|
145
143
|
A static resource request
|
|
146
144
|
|
|
147
|
-
:param str name: The request name
|
|
145
|
+
:param str name: The request name
|
|
148
146
|
:param bytes content: The static content
|
|
149
147
|
:param str content_type: Optional content type string. If None, the content type is auto-determined.
|
|
150
148
|
:param list(tuple) urls: The list of URL method/path tuples. The first value is the HTTP request method (e.g. 'GET')
|
|
@@ -165,7 +163,7 @@ class StaticRequest(Request):
|
|
|
165
163
|
'.html': 'text/html; charset=utf-8',
|
|
166
164
|
'.jpeg': 'image/jpeg',
|
|
167
165
|
'.jpg': 'image/jpeg',
|
|
168
|
-
'.js': '
|
|
166
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
169
167
|
'.json': 'application/json; charset=utf-8',
|
|
170
168
|
'.markdown': 'text/markdown; charset=utf-8',
|
|
171
169
|
'.md': 'text/markdown; charset=utf-8',
|
|
@@ -199,16 +197,14 @@ class StaticRequest(Request):
|
|
|
199
197
|
assert content_type, f'Unknown content type for static resource "{name}"'
|
|
200
198
|
self.content_type = content_type
|
|
201
199
|
|
|
202
|
-
# Compute the
|
|
203
|
-
|
|
204
|
-
md5.update(self.content)
|
|
205
|
-
self.etag = md5.hexdigest()
|
|
200
|
+
# Compute the ETag - a quoted entity-tag per RFC 7232
|
|
201
|
+
self.etag = f'"{hashlib.md5(self.content, usedforsecurity=False).hexdigest()}"'
|
|
206
202
|
|
|
207
203
|
def __call__(self, environ, start_response):
|
|
208
204
|
|
|
209
205
|
# Check the etag - is the resource modified?
|
|
210
206
|
if self.etag == environ.get('HTTP_IF_NONE_MATCH'):
|
|
211
|
-
start_response(self.STATUS_NOT_MODIFIED, [])
|
|
207
|
+
start_response(self.STATUS_NOT_MODIFIED, [('ETag', self.etag)])
|
|
212
208
|
return []
|
|
213
209
|
|
|
214
210
|
start_response(self.STATUS_OK, [('Content-Type', self.content_type), ('ETag', self.etag)])
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: chisel
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: Lightweight WSGI application framework, schema-validated JSON APIs, and API documentation
|
|
5
5
|
Author-email: "Craig A. Hobbs" <craigahobbs@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -30,7 +30,7 @@ Dynamic: license-file
|
|
|
30
30
|
[](https://github.com/craigahobbs/chisel/blob/main/LICENSE)
|
|
31
31
|
[](https://pypi.org/project/chisel/)
|
|
32
32
|
|
|
33
|
-
Chisel is a
|
|
33
|
+
Chisel is a lightweight Python WSGI application framework built for creating well-documented,
|
|
34
34
|
schema-validated JSON web APIs.
|
|
35
35
|
|
|
36
36
|
|
|
@@ -46,11 +46,13 @@ Chisel provides the [action](https://craigahobbs.github.io/chisel/action.html#ch
|
|
|
46
46
|
decorator for easily implementing schema-validated JSON APIs.
|
|
47
47
|
|
|
48
48
|
~~~ python
|
|
49
|
+
import chisel
|
|
50
|
+
|
|
49
51
|
@chisel.action(spec='''
|
|
50
52
|
# Sum a list of numbers
|
|
51
53
|
action sum_numbers
|
|
52
54
|
urls
|
|
53
|
-
|
|
55
|
+
GET
|
|
54
56
|
|
|
55
57
|
query
|
|
56
58
|
# The list of numbers
|
|
@@ -87,6 +89,14 @@ status, _, content_bytes = application.request('GET', '/sum_numbers')
|
|
|
87
89
|
b'{"error":"InvalidInput","message":"Required member \\"numbers\\" missing (query string)"}'
|
|
88
90
|
~~~
|
|
89
91
|
|
|
92
|
+
A [chisel.Application](https://craigahobbs.github.io/chisel/app.html#chisel.Application) is a
|
|
93
|
+
standard [WSGI](https://peps.python.org/pep-3333/) application object - host it with any WSGI
|
|
94
|
+
server. For example:
|
|
95
|
+
|
|
96
|
+
~~~ sh
|
|
97
|
+
gunicorn module:application
|
|
98
|
+
~~~
|
|
99
|
+
|
|
90
100
|
|
|
91
101
|
## API Documentation
|
|
92
102
|
|
|
@@ -99,8 +109,9 @@ application = chisel.Application()
|
|
|
99
109
|
application.add_requests(chisel.create_doc_requests())
|
|
100
110
|
~~~
|
|
101
111
|
|
|
102
|
-
By default the documentation application is hosted at "/doc/". An example of
|
|
103
|
-
|
|
112
|
+
By default the documentation application is hosted at "/doc/". An example of Chisel's
|
|
113
|
+
[documentation output](https://craigahobbs.github.io/chisel/example/#var.vName='chisel_doc_request')
|
|
114
|
+
is available.
|
|
104
115
|
|
|
105
116
|
|
|
106
117
|
## Development
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|