fresco 3.2.0__tar.gz → 3.3.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.

Potentially problematic release.


This version of fresco might be problematic. Click here for more details.

Files changed (64) hide show
  1. {fresco-3.2.0 → fresco-3.3.1}/CHANGELOG.rst +16 -0
  2. {fresco-3.2.0/fresco.egg-info → fresco-3.3.1}/PKG-INFO +1 -1
  3. fresco-3.3.1/fresco/__init__.py +128 -0
  4. {fresco-3.2.0 → fresco-3.3.1}/fresco/core.py +10 -8
  5. {fresco-3.2.0 → fresco-3.3.1}/fresco/middleware.py +1 -1
  6. {fresco-3.2.0 → fresco-3.3.1}/fresco/multidict.py +51 -34
  7. {fresco-3.2.0 → fresco-3.3.1}/fresco/options.py +131 -21
  8. {fresco-3.2.0 → fresco-3.3.1}/fresco/request.py +1 -1
  9. {fresco-3.2.0 → fresco-3.3.1}/fresco/requestcontext.py +16 -9
  10. {fresco-3.2.0 → fresco-3.3.1}/fresco/response.py +24 -23
  11. {fresco-3.2.0 → fresco-3.3.1}/fresco/routing.py +41 -10
  12. {fresco-3.2.0 → fresco-3.3.1}/fresco/subrequests.py +3 -2
  13. {fresco-3.2.0 → fresco-3.3.1}/fresco/tests/test_options.py +85 -5
  14. {fresco-3.2.0 → fresco-3.3.1}/fresco/tests/test_routing.py +14 -0
  15. {fresco-3.2.0 → fresco-3.3.1}/fresco/util/http.py +2 -1
  16. {fresco-3.2.0 → fresco-3.3.1/fresco.egg-info}/PKG-INFO +1 -1
  17. fresco-3.2.0/fresco/__init__.py +0 -27
  18. {fresco-3.2.0 → fresco-3.3.1}/LICENSE.txt +0 -0
  19. {fresco-3.2.0 → fresco-3.3.1}/MANIFEST.in +0 -0
  20. {fresco-3.2.0 → fresco-3.3.1}/README.rst +0 -0
  21. {fresco-3.2.0 → fresco-3.3.1}/fresco/cookie.py +0 -0
  22. {fresco-3.2.0 → fresco-3.3.1}/fresco/decorators.py +0 -0
  23. {fresco-3.2.0 → fresco-3.3.1}/fresco/exceptions.py +0 -0
  24. {fresco-3.2.0 → fresco-3.3.1}/fresco/routeargs.py +0 -0
  25. {fresco-3.2.0 → fresco-3.3.1}/fresco/static.py +0 -0
  26. {fresco-3.2.0 → fresco-3.3.1}/fresco/tests/__init__.py +0 -0
  27. {fresco-3.2.0 → fresco-3.3.1}/fresco/tests/fixtures.py +0 -0
  28. {fresco-3.2.0 → fresco-3.3.1}/fresco/tests/test_cookie.py +0 -0
  29. {fresco-3.2.0 → fresco-3.3.1}/fresco/tests/test_core.py +0 -0
  30. {fresco-3.2.0 → fresco-3.3.1}/fresco/tests/test_decorators.py +0 -0
  31. {fresco-3.2.0 → fresco-3.3.1}/fresco/tests/test_exceptions.py +0 -0
  32. {fresco-3.2.0 → fresco-3.3.1}/fresco/tests/test_middleware.py +0 -0
  33. {fresco-3.2.0 → fresco-3.3.1}/fresco/tests/test_multidict.py +0 -0
  34. {fresco-3.2.0 → fresco-3.3.1}/fresco/tests/test_request.py +0 -0
  35. {fresco-3.2.0 → fresco-3.3.1}/fresco/tests/test_requestcontext.py +0 -0
  36. {fresco-3.2.0 → fresco-3.3.1}/fresco/tests/test_response.py +0 -0
  37. {fresco-3.2.0 → fresco-3.3.1}/fresco/tests/test_routeargs.py +0 -0
  38. {fresco-3.2.0 → fresco-3.3.1}/fresco/tests/test_static.py +0 -0
  39. {fresco-3.2.0 → fresco-3.3.1}/fresco/tests/test_subrequests.py +0 -0
  40. {fresco-3.2.0 → fresco-3.3.1}/fresco/tests/util/__init__.py +0 -0
  41. {fresco-3.2.0 → fresco-3.3.1}/fresco/tests/util/form_data.py +0 -0
  42. {fresco-3.2.0 → fresco-3.3.1}/fresco/tests/util/test_common.py +0 -0
  43. {fresco-3.2.0 → fresco-3.3.1}/fresco/tests/util/test_http.py +0 -0
  44. {fresco-3.2.0 → fresco-3.3.1}/fresco/tests/util/test_security.py +0 -0
  45. {fresco-3.2.0 → fresco-3.3.1}/fresco/tests/util/test_urls.py +0 -0
  46. {fresco-3.2.0 → fresco-3.3.1}/fresco/tests/util/test_wsgi.py +0 -0
  47. {fresco-3.2.0 → fresco-3.3.1}/fresco/types.py +0 -0
  48. {fresco-3.2.0 → fresco-3.3.1}/fresco/typing.py +0 -0
  49. {fresco-3.2.0 → fresco-3.3.1}/fresco/util/__init__.py +0 -0
  50. {fresco-3.2.0 → fresco-3.3.1}/fresco/util/cache.py +0 -0
  51. {fresco-3.2.0 → fresco-3.3.1}/fresco/util/common.py +0 -0
  52. {fresco-3.2.0 → fresco-3.3.1}/fresco/util/contentencodings.py +0 -0
  53. {fresco-3.2.0 → fresco-3.3.1}/fresco/util/file.py +0 -0
  54. {fresco-3.2.0 → fresco-3.3.1}/fresco/util/io.py +0 -0
  55. {fresco-3.2.0 → fresco-3.3.1}/fresco/util/object.py +0 -0
  56. {fresco-3.2.0 → fresco-3.3.1}/fresco/util/security.py +0 -0
  57. {fresco-3.2.0 → fresco-3.3.1}/fresco/util/textproc.py +0 -0
  58. {fresco-3.2.0 → fresco-3.3.1}/fresco/util/urls.py +0 -0
  59. {fresco-3.2.0 → fresco-3.3.1}/fresco/util/wsgi.py +0 -0
  60. {fresco-3.2.0 → fresco-3.3.1}/fresco.egg-info/SOURCES.txt +0 -0
  61. {fresco-3.2.0 → fresco-3.3.1}/fresco.egg-info/dependency_links.txt +0 -0
  62. {fresco-3.2.0 → fresco-3.3.1}/fresco.egg-info/top_level.txt +0 -0
  63. {fresco-3.2.0 → fresco-3.3.1}/setup.cfg +0 -0
  64. {fresco-3.2.0 → fresco-3.3.1}/setup.py +0 -0
@@ -1,6 +1,22 @@
1
1
  Changelog
2
2
  =========
3
3
 
4
+ 3.3.1 (released 2023-05-18)
5
+ ---------------------------
6
+
7
+ - The ``route_wsgi`` and ``route_all`` methods now only match at a
8
+ path separator boundary (``'/'``).
9
+ - Bugfix: subrequest now works with views routed via ``RRoute`` (and so expect
10
+ an initial ``request`` argument)
11
+
12
+ 3.3.0
13
+ -----
14
+
15
+ - Options: fresco.options.Options can now take a list of paths as its first argument
16
+ - Options: tags can now be specified with environment variable substitutions
17
+ (eg "{FOO}" would load files tagged with the current value of the 'FOO'
18
+ environment variable)
19
+ - Options: add ``fresco.options.list_from_str`` and ``fresco.options.dict_from_options``
4
20
 
5
21
  3.2.0 (released 2023-04-16)
6
22
  ---------------------------
@@ -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
@@ -0,0 +1,128 @@
1
+ # Copyright 2015 Oliver Cope
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+ __version__ = "3.3.1"
16
+
17
+ DEFAULT_CHARSET = "UTF-8"
18
+
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
+ ]
75
+
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
@@ -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()
@@ -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):
@@ -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 = {}
@@ -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
@@ -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"],