fresco 3.2.0__py3-none-any.whl → 3.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fresco/__init__.py CHANGED
@@ -12,16 +12,117 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
  #
15
- __version__ = "3.2.0"
15
+ __version__ = "3.3.1"
16
16
 
17
17
  DEFAULT_CHARSET = "UTF-8"
18
18
 
19
- from .request import * # noqa
20
- from .requestcontext import context # noqa
21
- from .response import * # noqa
22
- from .core import * # noqa
23
- from .routing import * # noqa
24
- from .routeargs import * # noqa
25
- from .subrequests import * # noqa
19
+ __all__ = [
20
+ "Request",
21
+ "currentrequest",
22
+ "context",
23
+ "Response",
24
+ "FrescoApp",
25
+ "urlfor",
26
+ "ALL_METHODS",
27
+ "DEFAULT_CHARSET",
28
+ "GET",
29
+ "HEAD",
30
+ "POST",
31
+ "PUT",
32
+ "DELETE",
33
+ "OPTIONS",
34
+ "TRACE",
35
+ "CONNECT",
36
+ "VERSION_CONTROL",
37
+ "REPORT",
38
+ "CHECKOUT",
39
+ "CHECKIN",
40
+ "UNCHECKOUT",
41
+ "MKWORKSPACE",
42
+ "UPDATE",
43
+ "LABEL",
44
+ "MERGE",
45
+ "BASELINE_CONTROL",
46
+ "MKACTIVITY",
47
+ "ORDERPATCH",
48
+ "ACL",
49
+ "SEARCH",
50
+ "PATCH",
51
+ "DelegateRoute",
52
+ "Options",
53
+ "Route",
54
+ "RouteCollection",
55
+ "RRoute",
56
+ "routearg",
57
+ "FormArg",
58
+ "PostArg",
59
+ "QueryArg",
60
+ "GetArg",
61
+ "CookieArg",
62
+ "SessionArg",
63
+ "RequestObject",
64
+ "FormData",
65
+ "PostData",
66
+ "QueryData",
67
+ "GetData",
68
+ "routefor",
69
+ "XForwarded",
70
+ "subrequest",
71
+ "subrequest_bytes",
72
+ "subrequest_raw",
73
+ "object_or_404",
74
+ ]
26
75
 
27
- from .util.common import * # noqa
76
+ from fresco.request import Request
77
+ from fresco.request import currentrequest
78
+ from fresco.requestcontext import context
79
+ from fresco.response import Response
80
+ from fresco.core import FrescoApp
81
+ from fresco.core import urlfor
82
+ from fresco.options import Options
83
+ from fresco.routing import Route
84
+ from fresco.routing import RRoute
85
+ from fresco.routing import RouteCollection
86
+ from fresco.routing import DelegateRoute
87
+ from fresco.routing import routefor
88
+ from fresco.routing import ALL_METHODS
89
+ from fresco.routing import GET
90
+ from fresco.routing import HEAD
91
+ from fresco.routing import POST
92
+ from fresco.routing import PUT
93
+ from fresco.routing import DELETE
94
+ from fresco.routing import OPTIONS
95
+ from fresco.routing import TRACE
96
+ from fresco.routing import CONNECT
97
+ from fresco.routing import VERSION_CONTROL
98
+ from fresco.routing import REPORT
99
+ from fresco.routing import CHECKOUT
100
+ from fresco.routing import CHECKIN
101
+ from fresco.routing import UNCHECKOUT
102
+ from fresco.routing import MKWORKSPACE
103
+ from fresco.routing import UPDATE
104
+ from fresco.routing import LABEL
105
+ from fresco.routing import MERGE
106
+ from fresco.routing import BASELINE_CONTROL
107
+ from fresco.routing import MKACTIVITY
108
+ from fresco.routing import ORDERPATCH
109
+ from fresco.routing import ACL
110
+ from fresco.routing import SEARCH
111
+ from fresco.routing import PATCH
112
+ from fresco.routeargs import routearg
113
+ from fresco.routeargs import FormArg
114
+ from fresco.routeargs import PostArg
115
+ from fresco.routeargs import QueryArg
116
+ from fresco.routeargs import GetArg
117
+ from fresco.routeargs import CookieArg
118
+ from fresco.routeargs import SessionArg
119
+ from fresco.routeargs import RequestObject
120
+ from fresco.routeargs import FormData
121
+ from fresco.routeargs import PostData
122
+ from fresco.routeargs import QueryData
123
+ from fresco.routeargs import GetData
124
+ from fresco.middleware import XForwarded
125
+ from fresco.subrequests import subrequest
126
+ from fresco.subrequests import subrequest_bytes
127
+ from fresco.subrequests import subrequest_raw
128
+ from fresco.util.common import object_or_404
fresco/core.py CHANGED
@@ -151,9 +151,9 @@ class FrescoApp(RouteCollection):
151
151
 
152
152
  def get_response(
153
153
  self,
154
- request,
155
- path,
156
- method,
154
+ request: Request,
155
+ path: str,
156
+ method: str,
157
157
  currentcontext=context.currentcontext,
158
158
  normpath=normpath,
159
159
  ):
@@ -259,12 +259,12 @@ class FrescoApp(RouteCollection):
259
259
 
260
260
  # Is the URL just missing a trailing '/'?
261
261
  if not path or path[-1] != "/":
262
- for _ in self.get_methods(request, path + "/"):
262
+ if self.get_methods(request, path + "/"):
263
263
  return Response.unrestricted_redirect_permanent(path + "/")
264
264
 
265
265
  return Response.not_found()
266
266
 
267
- def view(self, request=None) -> Response:
267
+ def view(self, request: t.Optional[Request] = None) -> Response:
268
268
  request = request or context.request
269
269
  try:
270
270
  path = request.path_info
@@ -287,7 +287,9 @@ class FrescoApp(RouteCollection):
287
287
 
288
288
  return response
289
289
 
290
- def handle_http_error_response(self, request, response):
290
+ def handle_http_error_response(
291
+ self, request: Request, response: Response
292
+ ) -> Response:
291
293
  """
292
294
  Call any process_http_error_response handlers and return the
293
295
  (potentially modified) response object.
@@ -303,8 +305,8 @@ class FrescoApp(RouteCollection):
303
305
  self.log_exception(request)
304
306
  return response
305
307
 
306
- def get_methods(self, request, path):
307
- """\
308
+ def get_methods(self, request: Request, path: str) -> Set[str]:
309
+ """
308
310
  Return the HTTP methods valid in routes to the given path
309
311
  """
310
312
  methods: Set[str] = set()
fresco/middleware.py CHANGED
@@ -12,7 +12,7 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
  #
15
- __all__ = "XForwarded"
15
+ __all__ = ["XForwarded"]
16
16
 
17
17
 
18
18
  class XForwarded(object):
fresco/multidict.py CHANGED
@@ -16,12 +16,24 @@
16
16
  An order preserving multidict implementation
17
17
  """
18
18
  from collections.abc import MutableMapping
19
+ from collections.abc import Mapping
19
20
  from itertools import repeat
21
+ import typing as t
20
22
  from typing import Any
21
23
  from typing import Dict
22
24
  from typing import Set
23
25
 
24
26
 
27
+ if t.TYPE_CHECKING:
28
+ from typeshed import SupportsKeysAndGetItem
29
+ else:
30
+ SupportsKeysAndGetItem = Mapping
31
+
32
+ SupportsKeysAndGetItemOrIterable = t.Union[
33
+ SupportsKeysAndGetItem, t.Iterable[t.Tuple[Any, Any]]
34
+ ]
35
+
36
+
25
37
  class MultiDict(MutableMapping):
26
38
  """
27
39
  Dictionary-like object that supports multiple values per key. Insertion
@@ -44,9 +56,13 @@ class MultiDict(MutableMapping):
44
56
 
45
57
  """
46
58
 
59
+ _dict: Dict[Any, Any]
60
+ _order: t.List[Any]
47
61
  setdefault = MutableMapping.setdefault
48
62
 
49
- def __init__(self, *args, **kwargs):
63
+ def __init__(
64
+ self, mapping_or_iterable: SupportsKeysAndGetItemOrIterable = tuple(), **kwargs
65
+ ):
50
66
  """
51
67
  MultiDicts can be constructed in the following ways:
52
68
 
@@ -76,13 +92,9 @@ class MultiDict(MutableMapping):
76
92
  MultiDict(...)
77
93
 
78
94
  """
79
- if len(args) > 1:
80
- raise TypeError(
81
- "%s expected at most 2 arguments, got %d"
82
- % (self.__class__.__name__, 1 + len(args))
83
- )
84
- self.clear()
85
- self.update(*args, **kwargs)
95
+ self._order = []
96
+ self._dict = {}
97
+ self._update(mapping_or_iterable, True, kwargs)
86
98
 
87
99
  def __getitem__(self, key):
88
100
  """
@@ -289,7 +301,7 @@ class MultiDict(MutableMapping):
289
301
  self._order.remove((key, value))
290
302
  return value
291
303
 
292
- def popitem(self):
304
+ def popitem(self) -> t.Tuple[Any, Any]:
293
305
  """
294
306
  Return and remove a ``(key, value)`` pair from the dictionary.
295
307
 
@@ -314,7 +326,17 @@ class MultiDict(MutableMapping):
314
326
  raise KeyError("popitem(): dictionary is empty")
315
327
  return key, self.pop(key)
316
328
 
317
- def update(self, *args, **kwargs):
329
+ @t.overload
330
+ def update(
331
+ self, mapping_or_iterable: SupportsKeysAndGetItem, /, **kwargs: Any
332
+ ) -> None:
333
+ ...
334
+
335
+ @t.overload
336
+ def update(self, /, **kwargs: Any) -> None:
337
+ ...
338
+
339
+ def update(self, mapping_or_iterable=tuple(), **kwargs):
318
340
  r"""
319
341
  Update the MultiDict from another MultiDict, regular dictionary or a
320
342
  iterable of ``(key, value)`` pairs. New keys overwrite old keys - use
@@ -349,29 +371,24 @@ class MultiDict(MutableMapping):
349
371
  >>> d.update(mood='okay')
350
372
 
351
373
  """
352
- if len(args) > 1:
353
- raise TypeError("expected at most 1 argument, got %d" % (1 + len(args),))
354
- if args:
355
- other = args[0]
356
- else:
357
- other = []
358
- return self._update(other, True, **kwargs)
374
+ self._update(mapping_or_iterable, True, kwargs)
359
375
 
360
- def extend(self, *args, **kwargs):
376
+ def extend(
377
+ self, mapping_or_iterable: SupportsKeysAndGetItemOrIterable = tuple(), **kwargs
378
+ ):
361
379
  """
362
380
  Extend the MultiDict with another MultiDict, regular dictionary or a
363
381
  iterable of ``(key, value)`` pairs. This is similar to :meth:`update`
364
382
  except that new keys are added to old keys.
365
383
  """
366
- if len(args) > 1:
367
- raise TypeError("expected at most 1 argument, got %d", (1 + len(args),))
368
- if args:
369
- other = args[0]
370
- else:
371
- other = []
372
- return self._update(other, False, **kwargs)
384
+ return self._update(mapping_or_iterable, False, kwargs)
373
385
 
374
- def _update(self, *args, **kwargs):
386
+ def _update(
387
+ self,
388
+ other: SupportsKeysAndGetItemOrIterable,
389
+ replace: bool,
390
+ extra_kwargs: Dict[Any, Any],
391
+ ):
375
392
  """
376
393
  Update the MultiDict from another object and optionally kwargs.
377
394
 
@@ -380,17 +397,15 @@ class MultiDict(MutableMapping):
380
397
  :param replace: if ``True``, entries will replace rather than extend
381
398
  existing entries (second positional arg)
382
399
  """
383
- other, replace = args
384
-
385
400
  if isinstance(other, self.__class__):
386
401
  items = list(other.allitems())
387
- elif isinstance(other, dict):
402
+ elif isinstance(other, Mapping):
388
403
  items = list(other.items())
389
404
  else:
390
405
  items = list(other)
391
406
 
392
- if kwargs:
393
- items += list(kwargs.items())
407
+ if extra_kwargs:
408
+ items += list(extra_kwargs.items())
394
409
 
395
410
  if replace:
396
411
  replaced = {k for k, v in items if k in self._dict}
@@ -398,9 +413,11 @@ class MultiDict(MutableMapping):
398
413
  for key in replaced:
399
414
  self._dict[key] = []
400
415
 
416
+ setdefault = self._dict.setdefault
417
+ append_order = self._order.append
401
418
  for k, v in items:
402
- self._dict.setdefault(k, []).append(v)
403
- self._order.append((k, v))
419
+ setdefault(k, []).append(v)
420
+ append_order((k, v))
404
421
 
405
422
  def __repr__(self):
406
423
  """
@@ -428,4 +445,4 @@ class MultiDict(MutableMapping):
428
445
 
429
446
  def clear(self):
430
447
  self._order = []
431
- self._dict: Dict[Any, Any] = {}
448
+ self._dict = {}
fresco/options.py CHANGED
@@ -15,6 +15,7 @@
15
15
  import inspect
16
16
  import json
17
17
  import logging
18
+ import typing as t
18
19
  import os
19
20
  import re
20
21
  from decimal import Decimal
@@ -31,6 +32,8 @@ from typing import Union
31
32
 
32
33
  from fresco.exceptions import OptionsLoadedException
33
34
 
35
+ __all__ = ["Options"]
36
+
34
37
  logger = logging.getLogger(__name__)
35
38
 
36
39
 
@@ -72,25 +75,39 @@ class Options(dict):
72
75
  for func in self._loaded_callbacks:
73
76
  func(self)
74
77
 
78
+ def trigger_onload(self):
79
+ """
80
+ Mark the options object as having loaded and call any registered
81
+ onload callbacks
82
+ """
83
+ if self._is_loaded:
84
+ raise OptionsLoadedException("Options have already been loaded")
85
+ self.do_loaded_callbacks()
86
+ self.__dict__["_is_loaded"] = True
87
+
75
88
  def copy(self):
76
89
  return self.__class__(super().copy())
77
90
 
78
91
  def load(
79
92
  self,
80
- sources: str,
93
+ sources: t.Union[str, t.Iterable[t.Union[Path, str]]],
81
94
  tags: Sequence[str] = [],
82
95
  use_environ=False,
83
96
  strict=True,
84
97
  dir=None,
98
+ trigger_onload=True,
85
99
  ):
86
100
  """
87
101
  Find all files matching glob pattern ``sources`` and populates the
88
102
  options object from those with matching filenames containing ``tags``.
89
103
 
90
- :param sources: glob pattern or glob patterns separated by ";"
91
- :param tags: a list of tags to look for in file names. If a filename
92
- contains multiple tags, all the tags in the filename must
93
- match for it to be loaded.
104
+ :param sources:
105
+ one or more glob patterns separated by ";",
106
+ or a list of glob patterns
107
+
108
+ :param tags:
109
+ a list of tags to look for in file names.
110
+
94
111
  :param use_environ: if true, environment variables matching previously
95
112
  loaded keys will be loaded into the options object.
96
113
  This happens after all files have been processed.
@@ -102,13 +119,21 @@ class Options(dict):
102
119
  format. Any other files will be interpreted as simple lists of
103
120
  ```key=value`` pairs.
104
121
 
105
- Tags in filenames should be delimited with periods, eg ".env.production.py". For
106
- For example the filename ``setttings.dev.local.ini`` would be
107
- considered to have the tags, ``('dev', 'local')``
122
+ Tags in filenames are delimited with periods,
123
+ for example ".env.production.py".
124
+ The filename ``setttings.dev.local.ini`` would be
125
+ considered to have the tags ``('dev', 'local')``
126
+
127
+ Where filename contain multiple tags, all tags must match for the file
128
+ to be loaded.
129
+
130
+ Tag names may contain the names of environment variable surrounded by
131
+ braces, for example ``{USER}``. These will be substituted for the
132
+ environment variable's value, with any dots or path separators replaced
133
+ by underscores.
108
134
 
109
- The string '{hostname}' can be included as part of a tag name: it will
110
- be substituted for the current host's name with dots replaced by
111
- underscores.
135
+ The special variable ``{hostname}`` will be substituted for the current
136
+ host's name with dots replaced by underscores.
112
137
 
113
138
  Files with the suffix ".sample" are unconditionally excluded.
114
139
 
@@ -118,7 +143,7 @@ class Options(dict):
118
143
  Example::
119
144
 
120
145
  opts = Options()
121
- opts.load(Options(), ".env*", ["dev", "host-{hostname}", "local"])
146
+ opts.load(".env*", ["dev", "host-{hostname}", "local"])
122
147
 
123
148
  Would load options from files named ``.env``, ``.env.json``, ``.env.dev.py``
124
149
  and ``.env.local.py.``
@@ -126,15 +151,24 @@ class Options(dict):
126
151
  """
127
152
  if self._is_loaded:
128
153
  raise OptionsLoadedException("Options have already been loaded")
129
- hostname = gethostname().replace(".", "_")
154
+
155
+ tag_substitutions = os.environ.copy()
156
+ tag_substitutions["hostname"] = gethostname()
157
+ tag_substitutions = {
158
+ k: v.replace(".", "_").replace(os.pathsep, "_")
159
+ for k, v in tag_substitutions.items()
160
+ }
161
+
130
162
  candidates: List[Path] = []
131
163
  if dir is None:
132
164
  dir = Path(".")
133
165
  else:
134
166
  dir = Path(dir)
135
167
 
136
- for source in sources.split(";"):
137
- sourcepath = dir / Path(source.strip())
168
+ if isinstance(sources, str):
169
+ sources = [s.strip() for s in sources.split(";")]
170
+ for source in sources:
171
+ sourcepath = dir / Path(source)
138
172
  candidates.extend(
139
173
  p
140
174
  for p in sourcepath.parent.glob(sourcepath.name)
@@ -142,7 +176,13 @@ class Options(dict):
142
176
  )
143
177
 
144
178
  if tags:
145
- tags = [t.format(hostname=hostname) for t in tags]
179
+ subbed_tags = []
180
+ for tag in tags:
181
+ try:
182
+ subbed_tags.append(tag.format(**tag_substitutions))
183
+ except KeyError:
184
+ pass
185
+ tags = subbed_tags
146
186
  tagged_sources = []
147
187
  for p in candidates:
148
188
  candidate_tags = [t for t in str(p.name).split(".") if t][1:]
@@ -216,8 +256,9 @@ class Options(dict):
216
256
  if k in os.environ:
217
257
  self[k] = parse_value(self, os.environ[k])
218
258
 
219
- self.do_loaded_callbacks()
220
- self.__dict__["_is_loaded"] = True
259
+ if trigger_onload:
260
+ self.do_loaded_callbacks()
261
+ self.__dict__["_is_loaded"] = True
221
262
  return self
222
263
 
223
264
  def update_from_file(self, path, load_all=False):
@@ -256,9 +297,11 @@ class Options(dict):
256
297
 
257
298
  def update_from_object(self, ob, load_all=False):
258
299
  """
259
- Update the instance with any symbols listed in object `ob`
260
- :param load_all: If true private symbols will also be loaded into the
261
- options object.
300
+ Update the instance with any symbols found in object ``ob``.
301
+
302
+ :param load_all:
303
+ If true private symbols will also be loaded into the options
304
+ object.
262
305
  """
263
306
  self.update_from_dict(dict(inspect.getmembers(ob)), load_all)
264
307
 
@@ -298,3 +341,70 @@ def parse_key_value_pairs(options, lines: Iterable[str]):
298
341
  k = k.strip()
299
342
  values[k] = options[k] = parse_value(options, v)
300
343
  return values
344
+
345
+
346
+ def list_from_str(s, separator=","):
347
+ """
348
+ Return the value of ``s`` split into a list of times by ``separator``.
349
+ Spaces around items will be stripped.
350
+ """
351
+ if isinstance(s, str):
352
+ if not s.strip():
353
+ return []
354
+ return [item.strip() for item in s.split(",")]
355
+ raise TypeError(f"Expected string, got {s!r}")
356
+
357
+
358
+ def dict_from_options(
359
+ prefix: str, options: t.Mapping[str, Any], recursive: bool = False
360
+ ) -> t.Dict[str, Any]:
361
+ """
362
+ Create a dictionary containing all items in ``options`` whose keys start
363
+ with ``prefix``, with that prefix stripped.
364
+
365
+ Example:
366
+
367
+ >>> from fresco.options import Options, dict_from_options
368
+ >>> options = Options(
369
+ ... {"DATABASE_HOST": "127.0.0.1", "DATABASE_USER": "scott"}
370
+ ... )
371
+ >>> dict_from_options("DATABASE_", options)
372
+ {'HOST': '127.0.0.1', 'USER': 'scott'}
373
+
374
+ If ``recursive`` is True, a recursive splitting will be
375
+ applied. The last character of ``prefix`` will be used as the separator,
376
+ for example::
377
+
378
+ >>> from fresco.options import dict_from_options
379
+ >>> options = Options(
380
+ ... {
381
+ ... "DATABASE_DEV_HOST": "127.0.0.1",
382
+ ... "DATABASE_DEV_USER": "scott",
383
+ ... "DATABASE_PROD_HOST": "192.168.0.78",
384
+ ... "DATABASE_PROD_HOST": "tiger",
385
+ ... }
386
+ ... )
387
+ >>> dict_from_options("DATABASE_", options, recursive=True)
388
+ {'DEV': {'HOST': '127.0.0.1', ...}, 'PROD': {'HOST', ...}}
389
+
390
+ """
391
+
392
+ prefixlen = len(prefix)
393
+ d = {k[prefixlen:]: v for k, v in options.items() if k.startswith(prefix)}
394
+
395
+ def recursive_split(d: t.Mapping[str, Any], sep: str) -> t.Dict[str, Any]:
396
+ d_: t.Dict[str, Any] = {}
397
+ for k in d:
398
+ if sep in k:
399
+ ks = k.split(sep)
400
+ subdict = d_
401
+ for subkey in ks[:-1]:
402
+ subdict = subdict.setdefault(subkey, {})
403
+ subdict[ks[-1]] = d[k]
404
+ else:
405
+ d_[k] = d[k]
406
+ return d_
407
+
408
+ if recursive:
409
+ return recursive_split(d, prefix[-1])
410
+ return d
fresco/request.py CHANGED
@@ -137,7 +137,7 @@ class Request(object):
137
137
  the ``query`` property.
138
138
  """
139
139
  if self._form is None:
140
- if self.environ["REQUEST_METHOD"] in ("PUT", "POST"):
140
+ if self.environ["REQUEST_METHOD"] in {"PUT", "POST"}:
141
141
  items, close = parse_post(
142
142
  self.environ,
143
143
  self.environ["wsgi.input"],
fresco/requestcontext.py CHANGED
@@ -16,13 +16,16 @@ from collections import defaultdict
16
16
  from threading import get_ident
17
17
  from typing import Any
18
18
  from typing import Dict
19
- from typing import List
19
+ import typing as t
20
20
 
21
- __all__ = "context", "RequestContext"
21
+ __all__ = ["context", "RequestContext"]
22
+
23
+ ContextDict = t.Dict[str, Any]
24
+ ContextStack = t.List[ContextDict]
22
25
 
23
26
 
24
27
  class RequestContext(object):
25
- """\
28
+ """
26
29
  A local storage class that maintains different values for each request
27
30
  context in which it is used.
28
31
 
@@ -42,13 +45,17 @@ class RequestContext(object):
42
45
 
43
46
  __slots__ = ["_contexts", "_ident_func"]
44
47
 
45
- def __init__(self, ident_func=get_ident, setattr=object.__setattr__):
46
- c: Dict[Any, List] = defaultdict(list)
48
+ def __init__(
49
+ self,
50
+ ident_func: t.Callable[[], t.Any] = get_ident,
51
+ setattr=object.__setattr__
52
+ ):
53
+ c: Dict[Any, ContextStack] = defaultdict(list)
47
54
  c[ident_func()] = [{}]
48
55
  setattr(self, "_contexts", c)
49
56
  setattr(self, "_ident_func", ident_func)
50
57
 
51
- def push(self, **bindnames):
58
+ def push(self, **bindnames: t.Any):
52
59
  self._contexts[self._ident_func()].append(bindnames)
53
60
 
54
61
  def pop(self):
@@ -58,7 +65,7 @@ class RequestContext(object):
58
65
  if not ctx:
59
66
  del self._contexts[ident]
60
67
 
61
- def currentcontext(self):
68
+ def currentcontext(self) -> ContextDict:
62
69
  return self._contexts[self._ident_func()][-1]
63
70
 
64
71
  def __getattr__(self, item):
@@ -67,7 +74,7 @@ class RequestContext(object):
67
74
  except (KeyError, IndexError):
68
75
  raise AttributeError(item)
69
76
 
70
- def __setattr__(self, item, ob):
77
+ def __setattr__(self, item: str, ob: t.Any):
71
78
  self._contexts[self._ident_func()][-1][item] = ob
72
79
 
73
80
  def __delattr__(self, item):
@@ -81,7 +88,7 @@ class RequestContext(object):
81
88
  __setitem__ = __setattr__
82
89
  __delitem__ = __delattr__
83
90
 
84
- def get(self, item, default=None):
91
+ def get(self, item: str, default=None):
85
92
  return self.currentcontext().get(item, default)
86
93
 
87
94
  def __repr__(self):
fresco/response.py CHANGED
@@ -261,7 +261,7 @@ default_charset = "UTF-8"
261
261
 
262
262
 
263
263
  def encoder(stream, charset):
264
- r"""\
264
+ """
265
265
  Encode a response iterator using the given character set.
266
266
  """
267
267
  if charset is None:
@@ -326,7 +326,7 @@ def make_headers(
326
326
 
327
327
 
328
328
  class Response(object):
329
- """\
329
+ """
330
330
  Model an HTTP response
331
331
  """
332
332
 
@@ -500,7 +500,7 @@ class Response(object):
500
500
  )
501
501
 
502
502
  def get_headers(self, name):
503
- """\
503
+ """
504
504
  Return the list of headers set with the given name.
505
505
 
506
506
  Synopsis::
@@ -515,7 +515,7 @@ class Response(object):
515
515
  ]
516
516
 
517
517
  def get_header(self, name, default=""):
518
- """\
518
+ """
519
519
  Return the concatenated values of the named header(s) or ``default`` if
520
520
  the header has not been set.
521
521
 
@@ -547,7 +547,7 @@ class Response(object):
547
547
 
548
548
  @property
549
549
  def content_type(self):
550
- """\
550
+ """
551
551
  Return the value of the ``Content-Type`` header if set, otherwise
552
552
  ``None``.
553
553
  """
@@ -557,7 +557,7 @@ class Response(object):
557
557
  return None
558
558
 
559
559
  def add_header(self, name, value, make_header_name=make_header_name):
560
- """\
560
+ """
561
561
  Return a new response object with the given additional header.
562
562
 
563
563
  Synopsis::
@@ -571,7 +571,7 @@ class Response(object):
571
571
  return self.replace(headers=(self.headers + [(make_header_name(name), value)]))
572
572
 
573
573
  def add_headers(self, headers=[], **kwheaders):
574
- """\
574
+ """
575
575
  Return a new response object with the given additional headers.
576
576
 
577
577
  Synopsis::
@@ -587,7 +587,7 @@ class Response(object):
587
587
  return self.replace(headers=make_headers(self.headers + headers, kwheaders))
588
588
 
589
589
  def remove_headers(self, *headers):
590
- """\
590
+ """
591
591
  Return a new response object with the named headers removed.
592
592
 
593
593
  Synopsis::
@@ -617,7 +617,7 @@ class Response(object):
617
617
  httponly=False,
618
618
  samesite="Lax",
619
619
  ):
620
- """\
620
+ """
621
621
  Return a new response object with the given cookie added.
622
622
 
623
623
  Synopsis::
@@ -720,7 +720,7 @@ class Response(object):
720
720
  )
721
721
 
722
722
  def buffered(self):
723
- """\
723
+ """
724
724
  Return a new response object with the content buffered into a list.
725
725
  This will also generate a content-length header.
726
726
 
@@ -756,7 +756,7 @@ class Response(object):
756
756
 
757
757
  @classmethod
758
758
  def not_found(cls, request=None):
759
- """\
759
+ """
760
760
  Return an HTTP not found response (404).
761
761
 
762
762
  Synopsis::
@@ -824,7 +824,7 @@ class Response(object):
824
824
 
825
825
  @classmethod
826
826
  def forbidden(cls, message="Sorry, access is denied"):
827
- """\
827
+ """
828
828
  Return an HTTP forbidden response (403).
829
829
 
830
830
  Synopsis::
@@ -840,7 +840,7 @@ class Response(object):
840
840
 
841
841
  @classmethod
842
842
  def bad_request(cls, request=None):
843
- """\
843
+ """
844
844
  Return an HTTP bad request response.
845
845
 
846
846
  Synopsis::
@@ -863,7 +863,7 @@ class Response(object):
863
863
 
864
864
  @classmethod
865
865
  def length_required(cls, request=None):
866
- """\
866
+ """
867
867
  Return an HTTP Length Required response (411).
868
868
 
869
869
  Synopsis::
@@ -886,7 +886,7 @@ class Response(object):
886
886
 
887
887
  @classmethod
888
888
  def payload_too_large(cls, request=None):
889
- """\
889
+ """
890
890
  Return an HTTP Payload Too Large response (413)::
891
891
 
892
892
  >>> response = Response.payload_too_large()
@@ -901,7 +901,7 @@ class Response(object):
901
901
 
902
902
  @classmethod
903
903
  def method_not_allowed(cls, valid_methods):
904
- """\
904
+ """
905
905
  Return an HTTP method not allowed response (405)::
906
906
 
907
907
  >>> from fresco import context
@@ -923,7 +923,7 @@ class Response(object):
923
923
 
924
924
  @classmethod
925
925
  def internal_server_error(cls):
926
- """\
926
+ """
927
927
  Return an HTTP internal server error response (500).
928
928
 
929
929
  Synopsis::
@@ -944,7 +944,7 @@ class Response(object):
944
944
  def unrestricted_redirect(
945
945
  cls, location, request=None, status=STATUS_FOUND, **kwargs
946
946
  ):
947
- """\
947
+ """
948
948
  Return an HTTP redirect reponse (30x).
949
949
 
950
950
  :param location: The redirect location or a view specification.
@@ -1060,7 +1060,8 @@ class Response(object):
1060
1060
  :param kwargs: kwargs to be passed to :func:`fresco.core.urlfor` to
1061
1061
  construct the redirect URL, or the fallback in the case
1062
1062
  that ``location`` is already a qualified URL.
1063
- Synopsis:
1063
+
1064
+ Synopsis::
1064
1065
 
1065
1066
  >>> def view():
1066
1067
  ... return Response.redirect("/new-location")
@@ -1084,7 +1085,7 @@ class Response(object):
1084
1085
 
1085
1086
  @classmethod
1086
1087
  def redirect_permanent(cls, *args, **kwargs):
1087
- """\
1088
+ """
1088
1089
  Return an HTTP permanent redirect reponse.
1089
1090
 
1090
1091
  :param location: the URI of the new location. If relative this will be
@@ -1097,7 +1098,7 @@ class Response(object):
1097
1098
 
1098
1099
  @classmethod
1099
1100
  def redirect_temporary(cls, *args, **kwargs):
1100
- """\
1101
+ """
1101
1102
  Return an HTTP permanent redirect reponse.
1102
1103
 
1103
1104
  :param location: the URI of the new location. If relative this will be
@@ -1110,7 +1111,7 @@ class Response(object):
1110
1111
 
1111
1112
  @classmethod
1112
1113
  def meta_refresh(cls, location, delay=1, request=None):
1113
- """\
1114
+ """
1114
1115
  Return an HTML page containing a <meta http-equiv="refresh"> tag,
1115
1116
  causing the browser to redirect to the given location after ``delay``
1116
1117
  seconds.
@@ -1172,7 +1173,7 @@ class Response(object):
1172
1173
 
1173
1174
 
1174
1175
  def dump_response(r, line_break=b"\r\n", encoding="UTF-8"):
1175
- """\
1176
+ """
1176
1177
  Return a byte-string representation of the given response, as for a HTTP
1177
1178
  response.
1178
1179
  """
fresco/routing.py CHANGED
@@ -370,15 +370,15 @@ class PathConverter(StrConverter):
370
370
 
371
371
 
372
372
  class MatchAllURLsPattern(Pattern):
373
- """\
374
- A pattern matcher that matches all URLs starting with the given prefix. No
375
- arguments are parsed from the URL.
373
+ """
374
+ A pattern matcher that matches all paths starting with the given prefix.
375
+ No arguments are parsed from the URL.
376
376
  """
377
377
 
378
378
  def __init__(self, path):
379
379
  self.path = path
380
380
 
381
- def match(self, path):
381
+ def match(self, path: str) -> t.Optional[PathMatch]:
382
382
  if path.startswith(self.path):
383
383
  return PathMatch(self.path, path[len(self.path) :], (), {})
384
384
  return None
@@ -400,6 +400,32 @@ class MatchAllURLsPattern(Pattern):
400
400
  return "%s*" % (self.path,)
401
401
 
402
402
 
403
+ class PrefixPattern(MatchAllURLsPattern):
404
+ """
405
+ A pattern matcher that matches the given prefix. The prefix will only be
406
+ matched at a path separator boundary.
407
+
408
+ ``PrefixPattern('/foo')`` will match the paths ``/foo`` and ``/foo/bar``,
409
+ but not ``/foobar``.
410
+
411
+ No arguments are parsed from the URL.
412
+ """
413
+
414
+ def __init__(self, path):
415
+ super().__init__(path)
416
+ if path[-1] == "/":
417
+ self.path_with_sep = path
418
+ else:
419
+ self.path_with_sep = f"{self.path}/"
420
+
421
+ def match(self, path: str) -> t.Optional[PathMatch]:
422
+ prefix = path[: len(self.path_with_sep)]
423
+
424
+ if prefix == self.path or prefix == self.path_with_sep:
425
+ return PathMatch(self.path, path[len(self.path) :], (), {})
426
+ return None
427
+
428
+
403
429
  class ExtensiblePattern(Pattern):
404
430
  """\
405
431
  An extensible URL pattern matcher.
@@ -547,10 +573,10 @@ class ExtensiblePattern(Pattern):
547
573
  """
548
574
  return eval("(lambda *args, **kwargs: (args, kwargs))(%s)" % argstr)
549
575
 
550
- def match(self, path):
576
+ def match(self, path) -> t.Optional[PathMatch]:
551
577
  """
552
- Test ``path`` and return a tuple of parsed ``(args, kwargs)``, or
553
- ``None`` if there was no match.
578
+ Test ``path`` and return a PathMatch object or ``None`` if there was no
579
+ match.
554
580
  """
555
581
  mo = self.regex_match(path)
556
582
  if mo is None:
@@ -1358,13 +1384,18 @@ class RouteCollection(MutableSequence):
1358
1384
  exc = self.__routed_views__[viewspec] = RouteNotFound(viewspec)
1359
1385
  raise exc
1360
1386
 
1361
- def _get_routes(self, key):
1387
+ def _get_routes(
1388
+ self, key: Tuple[t.Optional[str], str]
1389
+ ) -> t.Sequence[t.Tuple[Route, t.Optional[PathMatch]]]:
1362
1390
  method, path = key
1363
1391
  routes = ((r, r.match(path, method)) for r in self.__routes__)
1364
1392
  return [(r, t) for (r, t) in routes if t is not None]
1365
1393
 
1366
1394
  def get_route_traversals(
1367
- self, path: str, method: Optional[str], request: Optional[Request] = None
1395
+ self,
1396
+ path: str,
1397
+ method: Optional[str],
1398
+ request: Optional[Request] = None,
1368
1399
  ) -> t.Iterator[RouteTraversal]:
1369
1400
  """
1370
1401
  Generate RouteTraversals for routes matching the given path and
@@ -1604,7 +1635,7 @@ class RouteCollection(MutableSequence):
1604
1635
  :param path: the path prefix at which the view will be routed
1605
1636
  """
1606
1637
  return self.route(
1607
- MatchAllURLsPattern(path),
1638
+ PrefixPattern(path),
1608
1639
  methods,
1609
1640
  view,
1610
1641
  route_class=route_class,
fresco/subrequests.py CHANGED
@@ -34,12 +34,12 @@ def subrequest(view, *args, **kwargs):
34
34
  - **A callable**: a new subrequest context will be created and
35
35
  ``view(*args, **kwargs)`` will be called. Middleware and application
36
36
  hooks will not be called, and any
37
- :class:`~fresco.routeargs.RouteArg`s defined will not be resolved.
37
+ :class:`~fresco.routeargs.RouteArg` instances will not be resolved.
38
38
 
39
39
  - **Any other value**: the view function is looked up using the same
40
40
  route resolution rules as :meth:`~fresco.core.FrescoApp.urlfor`.
41
41
  Middleware and hooks will be skipped, but
42
- :class:`~fresco.routeargs.RouteArg`s will be resolved.
42
+ :class:`~fresco.routeargs.RouteArg` instances will be resolved.
43
43
 
44
44
  If you pass in a view callable you can force RouteArgs to be resolved by
45
45
  specifying ``_resolve=True``.
@@ -272,6 +272,7 @@ def _get_args_for_route(
272
272
  route_kwargs = {}
273
273
  route_args = list(
274
274
  chain(
275
+ [request] if route.provide_request else [],
275
276
  ((a(request) if isinstance(a, RouteArg) else a) for a in route.view_args),
276
277
  (
277
278
  mutable_args.pop(0)
fresco/util/http.py CHANGED
@@ -202,6 +202,7 @@ def parse_querystring(
202
202
  charset: Optional[str] = None,
203
203
  strict: bool = False,
204
204
  keep_blank_values: bool = True,
205
+ unquote_plus=unquote_plus,
205
206
  ) -> List[Tuple[str, str]]:
206
207
  """
207
208
  Return ``(key, value)`` pairs from the given querystring::
@@ -519,7 +520,7 @@ def read_until(
519
520
  exhausted.
520
521
  """
521
522
  buf = b""
522
- found = None
523
+ found: t.Optional[bool] = None
523
524
 
524
525
  def _found():
525
526
  if found is None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fresco
3
- Version: 3.2.0
3
+ Version: 3.3.1
4
4
  Summary: A Web/WSGI micro-framework
5
5
  Home-page: https://ollycope.com/software/fresco/latest/
6
6
  Author: Oliver Cope
@@ -1,18 +1,18 @@
1
- fresco/__init__.py,sha256=20OR-r7Rhjf-tUQ6_Pf99TxkSO647PahYGmTeb44DBE,917
1
+ fresco/__init__.py,sha256=e0KXsDXDex3v2Efu5cGbMvN-jttFfz1aDo7pyG7O_rE,3520
2
2
  fresco/cookie.py,sha256=Qnx8yOjU4LUJ1fqi7YvqbhAA01rCsclJGl_fxI68slw,7055
3
- fresco/core.py,sha256=PrixFhBBUUZUb4LUTW4gYnO6OuzR8remPhOZ7PNeLSk,26509
3
+ fresco/core.py,sha256=mwYR6UP0zRSjcFlNZf51-otX9EcmvgjEZ7GDVQoQexg,26615
4
4
  fresco/decorators.py,sha256=84NUpRJ-M7GK6wDl42bmCRSvgoWzCsy1huyvGnSAPPw,3232
5
5
  fresco/exceptions.py,sha256=KE-LoYUGnho6KltzkU6cnm9vUiUhAiDIjPqn5ba-YCA,4410
6
- fresco/middleware.py,sha256=Tu4QoSBeqqLqwlXd--mVTAdo5W8TYF11HauayprYnII,4171
7
- fresco/multidict.py,sha256=_VsRb7Q112M6M_OJ6QL2Rx81d3ar3SoZwMrUzogYedE,13004
8
- fresco/options.py,sha256=sW-lDDljNPvH4IfXngS93dfCaz09moxEhEWLuHhKz6A,10272
9
- fresco/request.py,sha256=CQ7fqk0eaxNIQdD1uU0XKsBIaJp6tI57Klof4NTVAy4,27071
10
- fresco/requestcontext.py,sha256=eVl-xpSUTHedzAIphFPHWF2MVy0sAbWku5rT6mFRSRo,3339
11
- fresco/response.py,sha256=wEvHwhUdpgt5F3LGnn6UmWF0GmF6IOlGxAjKKpu3ZFM,37099
6
+ fresco/middleware.py,sha256=uCAuOtBnvaVBJGrQa8ecvLkSZ6aSKmWaJqxB8Rv4SsQ,4173
7
+ fresco/multidict.py,sha256=0CaNNIcFnZ1hLk3NExhNvjc_BtK4zVB26L9gP_7MeNM,13362
8
+ fresco/options.py,sha256=JneUzO1Ep4c3UbX-1icBZ9cZGheVmZAXdvegrbI39mw,13614
9
+ fresco/request.py,sha256=JCETzqzGZgPpS7IBdqi4Hk3CTwTGiaTtRXFW_nPP1yg,27071
10
+ fresco/requestcontext.py,sha256=P-SkKJkKLYVqNiR2zwooRROnSnE2VMj2P2-eD5DW5Qg,3504
11
+ fresco/response.py,sha256=Pz8icriWhHNVsTkB5x5y99yvcz-nFC7Eobuv7tfLE3s,37078
12
12
  fresco/routeargs.py,sha256=dxNlqbQ1FrgIY6OCFzcEMdZ0OVyjlMQdQGLmUJgdPYU,10176
13
- fresco/routing.py,sha256=BrzTcT788JG_u4eLHaFcViztee1-OWHloD8WZI1CSA8,57738
13
+ fresco/routing.py,sha256=nh7TiyDPQ1Y4RCXJGj_Wk-BP1UqpNyh6BKqxhNHlQ8U,58670
14
14
  fresco/static.py,sha256=9SKQ3P1YFTP45Qiic-ycvkpKRvqIURp1XSzPazTmYLI,2513
15
- fresco/subrequests.py,sha256=fM0EFmevw4_umHucWWKxIGMRUFHmSKCMdjKaTcaSAiQ,11016
15
+ fresco/subrequests.py,sha256=wFJWLuhVAcei5imYc5NBSCBaHBEm-X4_XswPtK3O4Zw,11081
16
16
  fresco/types.py,sha256=UHITRMDoS90s2zV2wNpqLFhRWESfaBAMuQnL4c21mqY,106
17
17
  fresco/typing.py,sha256=jQ1r_wme54J08XZUdmuuW4M8ve-gEhm1Wriwctls3r8,347
18
18
  fresco/util/__init__.py,sha256=mJkaZzvYgBnxsBAGv8y_P1yzonHqWgw6VF2Zs4rmJEA,7
@@ -20,15 +20,15 @@ fresco/util/cache.py,sha256=EjzF9EzzDw4U4coOykkJEgh_8HMDpwhEbYKBo_TeOZQ,1609
20
20
  fresco/util/common.py,sha256=8lvrjhELvYsUWxu7DZi1OJcUOFk2ILYndNsnaS0IqjM,1258
21
21
  fresco/util/contentencodings.py,sha256=kRFQze8pi6o8bh1nMJa2BZRTAZ5Ud6PPBIhofhhZNDQ,5484
22
22
  fresco/util/file.py,sha256=Vp7qJTo9RouUeHq25ExyBGkGTHuW-9Q7D_0GB54DFe8,1383
23
- fresco/util/http.py,sha256=xToyXIGqNoS_9qvKHrFLTQAX18O3d7BpWpQdgV_eqTI,22126
23
+ fresco/util/http.py,sha256=GQC-wL0lUpOF8zogrFHrh_3D7UbrksrLg_OVsv1c74Y,22175
24
24
  fresco/util/io.py,sha256=xxwDNJOcewY8lAR4Ce3cmB_zlrys8JGsESgwGWE198Y,1289
25
25
  fresco/util/object.py,sha256=FjYNfPHzvBqq1rn0Y6As-2AVZ_SZOjH-lrSy4EbYmHY,370
26
26
  fresco/util/security.py,sha256=nXEdoCak_2c4OA1L1wGwhZygS22s2fzwR0Kp-DdwKZg,1058
27
27
  fresco/util/textproc.py,sha256=e5jLTofKCqdm6_Fy8XOyR43AJr5APtL59Kd8cNA9PrQ,2309
28
28
  fresco/util/urls.py,sha256=aaVovLyXNlVoGviiLN94ImqXf-LTQs_xooEIyi3LBc4,9195
29
29
  fresco/util/wsgi.py,sha256=dV69VjJ7W8OqAxWzlA0dz7vAuC1MliNBnbr1MSyB6Is,12970
30
- fresco-3.2.0.dist-info/LICENSE.txt,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
31
- fresco-3.2.0.dist-info/METADATA,sha256=TF7FJ3TRRPYhrghF5mFi0Cb54eE6eMYFy_xcImUJtIo,1575
32
- fresco-3.2.0.dist-info/WHEEL,sha256=G16H4A3IeoQmnOrYV4ueZGKSjhipXx8zc8nu9FGlvMA,92
33
- fresco-3.2.0.dist-info/top_level.txt,sha256=p_1aMce5Shjq9fIfdbB-aN8wCDhjF_iYnn98bUebbII,7
34
- fresco-3.2.0.dist-info/RECORD,,
30
+ fresco-3.3.1.dist-info/LICENSE.txt,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
31
+ fresco-3.3.1.dist-info/METADATA,sha256=DEjrYiyD-gUvPOYEjyZz8y1y8PBrzIUxPx9wFuv7w_o,1575
32
+ fresco-3.3.1.dist-info/WHEEL,sha256=G16H4A3IeoQmnOrYV4ueZGKSjhipXx8zc8nu9FGlvMA,92
33
+ fresco-3.3.1.dist-info/top_level.txt,sha256=p_1aMce5Shjq9fIfdbB-aN8wCDhjF_iYnn98bUebbII,7
34
+ fresco-3.3.1.dist-info/RECORD,,
File without changes