chisel 2.3.2__tar.gz → 2.4.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chisel
3
- Version: 2.3.2
3
+ Version: 2.4.1
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
  [![GitHub](https://img.shields.io/github/license/craigahobbs/chisel)](https://github.com/craigahobbs/chisel/blob/main/LICENSE)
31
31
  [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/chisel)](https://pypi.org/project/chisel/)
32
32
 
33
- Chisel is a light-weight Python WSGI application framework built for creating well-documented,
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
- GET
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 of Chisel's documentation output is
103
- available [here](https://craigahobbs.github.io/chisel/example/#var.vName='chisel_doc_request').
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
  [![GitHub](https://img.shields.io/github/license/craigahobbs/chisel)](https://github.com/craigahobbs/chisel/blob/main/LICENSE)
6
6
  [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/chisel)](https://pypi.org/project/chisel/)
7
7
 
8
- Chisel is a light-weight Python WSGI application framework built for creating well-documented,
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
- GET
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 of Chisel's documentation output is
78
- available [here](https://craigahobbs.github.io/chisel/example/#var.vName='chisel_doc_request').
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "chisel"
7
- version = "2.3.2"
7
+ version = "2.4.1"
8
8
  description = "Lightweight WSGI application framework, schema-validated JSON APIs, and API documentation"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -2,7 +2,7 @@
2
2
  # https://github.com/craigahobbs/chisel/blob/main/LICENSE
3
3
 
4
4
  """
5
- Chisel is a light-weight Python WSGI application framework with tools for building well-documented,
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'\bcharset\s*=\s*(?P<charset>\S+)')
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 to the output schema.
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 arugument, "ctx", is the :class:`~chisel.Context` object. The second argument is the request object which
132
- contiains the schema-validated, combined path parameters, query string parameters, and JSON request content
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__ = ('action_callback', 'types', 'wsgi_response', 'jsonp')
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, jsonp=None):
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
- #: JSONP key or None
183
- self.jsonp = jsonp
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
- jsonp = None
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._get_section_type('input')
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._get_section_type('query')
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._get_section_type('path')
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._get_section_type('output')
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 ctx.app.validate_output:
331
- if exc.error in ('UnexpectedError',):
339
+ if app_validate_output:
340
+ if exc.error == 'UnexpectedError':
332
341
  validate_output = False
333
342
  else:
334
- output_types, output_type = self._get_error_type()
335
- except Exception as exc:
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 ctx.app.validate_output:
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, jsonp=jsonp)
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 arguments
19
- RE_URL_ARG = re.compile(r'/\{([A-Za-z]\w*)\}')
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
- '__request_regex'
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 : use this application state they see
50
- #: fit. For example, :class:`~chisel.Action` : requests return indented JSON when this value is True. Default is
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
- # Add the request URLs
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 RE_URL_ARG.search(url):
82
- request_regex = '^' + RE_URL_ARG_ESC.sub(r'/(?P<\1>[^/]+)', re.escape(url)) + '$'
83
- self.__request_regex.append((method, re.compile(request_regex), request))
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
- request_key = (method, url)
86
- if request_key in self.__request_urls:
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
- self.__request_urls[request_key] = request
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
- # Exact match?
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 method and URL regex
119
- request, url_args = next(
120
- (
121
- (request, {unquote(url_arg): unquote(url_value) for url_arg, url_value in request_match.groupdict().items()})
122
- for request, request_match in
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, url_args = self.__request_urls.get((None, path_info)), None
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
- # Match the request by URL regex (any method)
139
- request, url_args = next(
140
- (
141
- (request, {unquote(url_arg): unquote(url_value) for url_arg, url_value in request_match.groupdict().items()})
142
- for request, request_match in
143
- (
144
- (request, regex.match(path_info)) for method, regex, request in self.__request_regex
145
- if method is None
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 recieves an HTTP request, this method matches the
156
- appropriate :class:`~chisel.Request' object and then calls its :func:`~chisel.Request.__call__` method. The
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 any(path == path_info for _, path in self.__request_urls) or \
181
- any(regex.match(path_info) for _, regex, _ in self.__request_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
- ctx.log.exception('exception raised by request "%s"', request.name)
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', 'log', 'headers')
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
- #: The python logger instance. Write log messages using this object directly.
248
- self.log = logging.getLoggerClass()('')
249
- self.log.setLevel(app.log_level)
250
- wsgi_errors = environ.get('wsgi.errors') if environ else None
251
- if wsgi_errors is None:
252
- handler = logging.NullHandler()
253
- else:
254
- handler = logging.StreamHandler(wsgi_errors)
255
- if callable(app.log_format):
256
- handler.setFormatter(app.log_format(self))
257
- else:
258
- handler.setFormatter(logging.Formatter(app.log_format))
259
- self.log.addHandler(handler)
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 :class:`~chisel.Context` object
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.utcnow()
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)).strftime('%a, %d %b %Y %H:%M:%S GMT'))
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='text/plain', encoding='utf-8', headers=None):
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, jsonp=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
- if jsonp:
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 documenation application is hosted at "/doc/".
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
- if request_group not in groups:
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['HTTP_HOST'],
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 applicaton needs to render
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` method to host
41
- it with that applicaton.
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(chain.from_iterable(
93
- ((None, '/' + self.name),) if url is None else \
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 reqeust
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 redirectd URL
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'([^\w]|_)+', '_', f'redirect_{redirect_url}').rstrip('_')
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. The default name is the callback function's 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': 'application/javascript; charset=utf-8',
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 etag
203
- md5 = hashlib.md5()
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.2
3
+ Version: 2.4.1
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
  [![GitHub](https://img.shields.io/github/license/craigahobbs/chisel)](https://github.com/craigahobbs/chisel/blob/main/LICENSE)
31
31
  [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/chisel)](https://pypi.org/project/chisel/)
32
32
 
33
- Chisel is a light-weight Python WSGI application framework built for creating well-documented,
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
- GET
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 of Chisel's documentation output is
103
- available [here](https://craigahobbs.github.io/chisel/example/#var.vName='chisel_doc_request').
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