fresco 3.4.0__py3-none-any.whl → 3.6.0__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.

Potentially problematic release.


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

fresco/options.py CHANGED
@@ -12,12 +12,16 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
  #
15
+ import contextlib
16
+ import dataclasses
15
17
  import inspect
18
+ import itertools
16
19
  import json
17
20
  import logging
18
21
  import typing as t
19
22
  import os
20
23
  import re
24
+ from collections.abc import Mapping
21
25
  from decimal import Decimal
22
26
  from pathlib import Path
23
27
  from socket import gethostname
@@ -26,7 +30,6 @@ from typing import Callable
26
30
  from typing import Dict
27
31
  from typing import Iterable
28
32
  from typing import List
29
- from typing import Mapping
30
33
  from typing import Sequence
31
34
  from typing import Union
32
35
 
@@ -35,6 +38,8 @@ from fresco.exceptions import OptionsLoadedException
35
38
  __all__ = ["Options"]
36
39
 
37
40
  logger = logging.getLogger(__name__)
41
+ known_suffixes = {"py", "sh", "rc", "txt", "cfg", "ini", "json", "toml", "conf"}
42
+ priority_pattern = re.compile(r"([0-9]+)(?:-(.*))?$")
38
43
 
39
44
 
40
45
  class Options(dict):
@@ -96,7 +101,7 @@ class Options(dict):
96
101
  strict=True,
97
102
  dir=None,
98
103
  trigger_onload=True,
99
- ):
104
+ ) -> "Options":
100
105
  """
101
106
  Find all files matching glob pattern ``sources`` and populates the
102
107
  options object from those with matching filenames containing ``tags``.
@@ -119,20 +124,43 @@ class Options(dict):
119
124
  format. Any other files will be interpreted as simple lists of
120
125
  ```key=value`` pairs.
121
126
 
122
- Tags in filenames are delimited with periods,
123
- for example ".env.production.py".
127
+ Filename format
128
+ ---------------
129
+
130
+ The general format of filenames is:
131
+
132
+ .. code-block:: shell
133
+
134
+ <base>(.<priority number>-)(<tags>)(<suffix>)
135
+
136
+ Example filenames:
137
+
138
+ .. code-block:: shell
139
+
140
+ # Just <base>
141
+ .env
142
+
143
+ # <base>.<suffix>
144
+ .settings.toml
145
+
146
+ # <base>.<tags>.<suffix>
147
+ .env.dev.local..py
148
+
149
+ # <base>.<priority>-<tags>.<suffix>
150
+ .env.100-dev.py
151
+
152
+ Priority number, if specified, is used to determine loading order.
153
+ Lower numbers are loaded first. Priority numbers must be positive
154
+ integers.
155
+
156
+ Tags are delimited with periods,
157
+ for example ``.env.production.py``.
124
158
  The filename ``setttings.dev.local.ini`` would be
125
159
  considered to have the tags ``('dev', 'local')``
126
160
 
127
161
  Where filename contain multiple tags, all tags must match for the file
128
162
  to be loaded.
129
163
 
130
- Files are processed in the order that tags are specified in the
131
- ``tags`` parameter, and then in lexicographical order.
132
- For example, calling ``options.load(..., tags=["dev", "local"])`` would
133
- cause a file named "settings.dev" to be loaded before one named
134
- "settings.local".
135
-
136
164
  Tag names may contain the names of environment variable surrounded by
137
165
  braces, for example ``{USER}``. These will be substituted for the
138
166
  environment variable's value, with any dots or path separators replaced
@@ -143,8 +171,24 @@ class Options(dict):
143
171
 
144
172
  Files with the suffix ".sample" are unconditionally excluded.
145
173
 
146
- Files are loaded in the order specified by ``tags``, then in filename
147
- order. Environment variables, if requested, are loaded last.
174
+ Loading order
175
+ -------------
176
+
177
+ Files are loaded in the following order:
178
+
179
+ 1. On priority number, from low to high. If the priority number is not
180
+ given, a priority of zero is assumed
181
+
182
+ 2. Then in tag order, based on the ordering given in the
183
+ ``tags`` parameter
184
+
185
+ For example, calling ``options.load(..., tags=["dev", "local"])`` would
186
+ cause a file named "settings.dev" to be loaded before one named
187
+ "settings.local".
188
+
189
+ 3. Finally in lexicographical order.
190
+
191
+ Environment variables, if requested, are loaded last.
148
192
 
149
193
  Example::
150
194
 
@@ -152,19 +196,12 @@ class Options(dict):
152
196
  opts.load(".env*", ["dev", "host-{hostname}", "local"])
153
197
 
154
198
  Would load options from files named ``.env``, ``.env.json``, ``.env.dev.py``
155
- and ``.env.local.py.``
199
+ and ``.env.local.py``, in that order.
156
200
 
157
201
  """
158
202
  if self._is_loaded:
159
203
  raise OptionsLoadedException("Options have already been loaded")
160
204
 
161
- tag_substitutions = os.environ.copy()
162
- tag_substitutions["hostname"] = gethostname()
163
- tag_substitutions = {
164
- k: v.replace(".", "_").replace(os.pathsep, "_")
165
- for k, v in tag_substitutions.items()
166
- }
167
-
168
205
  candidates: List[Path] = []
169
206
  if dir is None:
170
207
  dir = Path(".")
@@ -181,52 +218,45 @@ class Options(dict):
181
218
  if p.suffix.lower() != ".sample"
182
219
  )
183
220
 
184
- if tags:
185
- subbed_tags = []
186
- for tag in tags:
187
- try:
188
- subbed_tags.append(tag.format(**tag_substitutions))
189
- except KeyError:
190
- pass
191
- tags = subbed_tags
192
- tagged_sources = []
193
- for p in candidates:
194
- candidate_tags = [t for t in str(p.name).split(".") if t][1:]
195
- if len(candidate_tags) == 0:
196
- tagged_sources.append((candidate_tags, p))
197
- else:
198
- # Ignore the final tag if it matches a common config file
199
- # extension
200
- if candidate_tags[-1].lower() in {
201
- "py",
202
- "sh",
203
- "rc",
204
- "txt",
205
- "cfg",
206
- "ini",
207
- "json",
208
- "toml",
209
- "conf",
210
- }:
211
- candidate_tags.pop()
212
-
213
- if all(t in tags for t in candidate_tags):
214
- tagged_sources.append((candidate_tags, p))
215
- matched = [
216
- ts[1]
217
- for ts in sorted(
218
- tagged_sources,
219
- key=(
220
- lambda ts: (
221
- ([], ts[1])
222
- if len(ts[0]) == 0 else
223
- (sorted(tags.index(t) for t in ts[0]), ts[1])
224
- )
225
- )
221
+ tag_substitutions = make_tag_substitutions()
222
+ subbed_tags: list[str] = []
223
+ for tag in tags:
224
+ try:
225
+ subbed_tags.append(tag.format(**tag_substitutions))
226
+ except KeyError:
227
+ pass
228
+ tags = subbed_tags
229
+ tagged_sources = []
230
+ for path in candidates:
231
+ priority = 0
232
+ filename = path.name
233
+ path_tags = []
234
+ path_tags = [t for t in str(filename).split(".") if t][1:]
235
+ if len(path_tags) > 0:
236
+ # Ignore the final tag if it matches a common config file
237
+ # extension
238
+ if path_tags[-1].lower() in known_suffixes:
239
+ path_tags.pop()
240
+
241
+ if path_tags:
242
+ if m := priority_pattern.match(path_tags[0]):
243
+ priority = int(m.group(1), 10)
244
+ if m.group(2):
245
+ path_tags[0] = m.group(2)
246
+ else:
247
+ path_tags = path_tags[1:]
248
+
249
+ if all(t in tags for t in path_tags):
250
+ tagged_sources.append(TaggedSource(priority, path_tags, path))
251
+ else:
252
+ excluded = [t for t in path_tags if t not in tags]
253
+ logger.debug(
254
+ f"Ignoring {path} as one or more tag does not match: {excluded=}"
226
255
  )
227
- ]
228
- else:
229
- matched = candidates
256
+
257
+ matched = [
258
+ ts.path for ts in sorted(tagged_sources, key=tagged_source_sort_key(tags))
259
+ ]
230
260
 
231
261
  for path in matched:
232
262
  existing_keys = set(self.keys())
@@ -234,7 +264,7 @@ class Options(dict):
234
264
  if path.suffix == ".py":
235
265
  self.update_from_file(str(path))
236
266
  elif path.suffix == ".toml":
237
- import toml
267
+ import toml # type: ignore
238
268
 
239
269
  with path.open("r") as f:
240
270
  self.update(toml.load(f))
@@ -262,9 +292,9 @@ class Options(dict):
262
292
  )
263
293
 
264
294
  if use_environ:
265
- for k in self:
266
- if k in os.environ:
267
- self[k] = parse_value(self, os.environ[k])
295
+ self |= {
296
+ k: parse_value(self, os.environ[k]) for k in self if k in os.environ
297
+ }
268
298
 
269
299
  if trigger_onload:
270
300
  self.do_loaded_callbacks()
@@ -280,9 +310,11 @@ class Options(dict):
280
310
  :param load_all: If true private symbols will also be loaded into the
281
311
  options object.
282
312
  """
283
- ns: Dict[str, Any] = {"__file__": path}
313
+ ns: Dict[str, Any] = {"__file__": path, "options": self}
284
314
  with open(path) as f:
285
315
  exec(f.read(), ns)
316
+ if ns.get("options") is self:
317
+ del ns["options"]
286
318
  self.update_from_dict(ns, load_all)
287
319
 
288
320
  def update_from_dict(self, d, load_all=False):
@@ -316,6 +348,42 @@ class Options(dict):
316
348
  self.update_from_dict(dict(inspect.getmembers(ob)), load_all)
317
349
 
318
350
 
351
+ @contextlib.contextmanager
352
+ def override_options(options, other=None, **kwargs):
353
+ """
354
+ Context manager that updates the given Options object with new values.
355
+ On exit, the old values will be restored.
356
+
357
+ This function is provided to assist with writing tests. It directly
358
+ modifies the given options object and does not prevent other threads from
359
+ accessing the modified values.
360
+ """
361
+ saved: list[tuple[str, t.Any]] = []
362
+ items: t.Iterable[tuple[str, t.Any]] = []
363
+ if isinstance(other, Mapping):
364
+ items = ((k, other[k]) for k in other.keys())
365
+
366
+ if kwargs:
367
+ items = itertools.chain(items, kwargs.items())
368
+
369
+ NOT_PRESENT = object()
370
+
371
+ for k, v in items:
372
+ if k in options:
373
+ saved.append((k, options[k]))
374
+ else:
375
+ saved.append((k, NOT_PRESENT))
376
+
377
+ options[k] = v
378
+
379
+ yield options
380
+ for k, v in saved:
381
+ if v is NOT_PRESENT:
382
+ del options[k]
383
+ else:
384
+ options[k] = v
385
+
386
+
319
387
  def parse_value(
320
388
  options: Mapping,
321
389
  v: str,
@@ -418,3 +486,47 @@ def dict_from_options(
418
486
  if recursive:
419
487
  return recursive_split(d, prefix[-1])
420
488
  return d
489
+
490
+
491
+ @dataclasses.dataclass
492
+ class TaggedSource:
493
+ priority: int
494
+ tags: list[str]
495
+ path: Path
496
+
497
+
498
+ def tagged_source_sort_key(
499
+ tags: list[str],
500
+ ) -> Callable[[TaggedSource], tuple[int, list[int], str]]:
501
+ """
502
+ Return a function that can be used as the ``key`` argument of``sorted``,
503
+ that sorts TaggedSource objects based on
504
+ priority, tag order, then filename.
505
+ """
506
+
507
+ def _sort_key(ts: TaggedSource) -> tuple[int, list[int], str]:
508
+ sorted_tags = [tags.index(t) for t in ts.tags]
509
+ return (ts.priority, sorted_tags, str(ts.path))
510
+
511
+ return _sort_key
512
+
513
+
514
+ def make_tag_substitutions():
515
+ """
516
+ Return a dict of substitutions to make in tag names, based on the current
517
+ hostname and environment variables
518
+
519
+ This permits tags to be specified as, for example,
520
+ '{hostname}' or '{FRESCO_PROFILE}'
521
+ and the associated value will be substituted in.
522
+
523
+ Path separators and dots (which separate tags) are replaced in substitution
524
+ values by an underscore.
525
+ """
526
+ substitutions = os.environ.copy()
527
+ substitutions["hostname"] = gethostname()
528
+ substitutions = {
529
+ k: v.replace(".", "_").replace(os.pathsep, "_")
530
+ for k, v in substitutions.items()
531
+ }
532
+ return substitutions
fresco/py.typed ADDED
File without changes
fresco/request.py CHANGED
@@ -23,6 +23,7 @@ from urllib.parse import ParseResult
23
23
  from urllib.parse import quote
24
24
  from urllib.parse import urlparse
25
25
  from urllib.parse import urlunparse
26
+ from decimal import Decimal
26
27
  import typing as t
27
28
  import datetime
28
29
  import json
@@ -32,6 +33,7 @@ import re
32
33
  from fresco import exceptions
33
34
  from fresco.cookie import parse_cookie_header
34
35
  from fresco.multidict import MultiDict
36
+ from fresco.defaults import DEFAULT_CHARSET
35
37
  from fresco.types import QuerySpec
36
38
  from fresco.util.http import FileUpload
37
39
  from fresco.util.http import get_body_bytes
@@ -48,7 +50,15 @@ __all__ = "Request", "currentrequest"
48
50
  KB = 1024
49
51
  MB = 1024 * KB
50
52
 
51
- _marker = object()
53
+
54
+ class _Marker:
55
+ ...
56
+
57
+
58
+ _marker = _Marker()
59
+
60
+ T1 = t.TypeVar("T1")
61
+ T2 = t.TypeVar("T2")
52
62
 
53
63
 
54
64
  class Request(object):
@@ -86,7 +96,7 @@ class Request(object):
86
96
  environ: t.Dict
87
97
 
88
98
  #: Encoding used to decode WSGI parameters, notably PATH_INFO and form data
89
- default_charset = fresco.DEFAULT_CHARSET
99
+ default_charset = DEFAULT_CHARSET
90
100
 
91
101
  #: The decoder class to use for JSON request payloads
92
102
  json_decoder_class = json.JSONDecoder
@@ -196,7 +206,9 @@ class Request(object):
196
206
  by the client.
197
207
  """
198
208
  try:
199
- return self.json_decoder_class(*args, **kwargs).decode(self.body)
209
+ return self.json_decoder_class(*args, **kwargs).decode(
210
+ self.body # type: ignore
211
+ )
200
212
  except ValueError:
201
213
  raise exceptions.RequestParseError("Payload is not valid JSON")
202
214
 
@@ -235,35 +247,64 @@ class Request(object):
235
247
 
236
248
  return self._query
237
249
 
238
- def __getitem__(self, key, _marker=_marker):
250
+ def __getitem__(self, key: str) -> str:
239
251
  """
240
252
  Return the value of ``key`` from submitted form values.
241
253
  """
242
- v = self.get(key, _marker)
243
- if v is _marker:
254
+ v = self.get(key, default=None)
255
+ if v is None:
244
256
  raise KeyError(key)
245
257
  return v
246
258
 
247
- def get(self, key, default=_marker, type=None):
259
+ @t.overload
260
+ def get(
261
+ self, key: str, default: T1, type: t.Callable[[str], T2] = str
262
+ ) -> t.Union[T1, T2, None]:
263
+ ...
264
+
265
+ @t.overload
266
+ def get(self, key: str, *, type: t.Callable[[str], T2]) -> t.Union[T2, None]:
267
+ ...
268
+
269
+ @t.overload
270
+ def get(
271
+ self, key: str, *, type: t.Callable[[str], T2] = str, required: t.Literal[True]
272
+ ) -> T2:
273
+ ...
274
+
275
+ def get(
276
+ self,
277
+ key: str,
278
+ default=_marker,
279
+ type: t.Callable[[str], T2] = str,
280
+ required: bool = False,
281
+ ):
248
282
  """
249
283
  Look up ``key`` in submitted form values.
250
284
 
251
- :param type: The type to which the returned result should be converted
252
- :param default: The value produced if the argument is missing
285
+ :param type:
286
+ The type to which the returned result should be converted
287
+
288
+ A :class:`fresco.exceptions.BadRequest` error is raised if ``type`` is
289
+ specified and the value cannot be converted to the given type
290
+
291
+ :param default:
292
+ The value to return if the key not present.
293
+
294
+ :param required:
295
+ If True, a missing key will cause a
296
+ :class:`fresco.exceptions.BadRequest` to be raised.
253
297
 
254
- If ``type`` is specified and the value cannot be converted to the given
255
- type, or the value is not present and and no default value has been
256
- specified, then a :class:`fresco.exceptions.BadRequest` error is raised.
257
298
  """
258
- default_specified = default is not _marker
259
- value = self.form.get(key, default)
260
- if value is default:
261
- if default_specified:
262
- return default
263
- if type is None:
299
+ value = self.form.get(key, _marker)
300
+ if value is _marker:
301
+ if required:
302
+ raise exceptions.BadRequest()
303
+ if default is _marker:
264
304
  return None
305
+ return default
265
306
 
266
- if type is None:
307
+ if type is str:
267
308
  return value
268
309
  try:
269
310
  return type(value)
@@ -276,15 +317,97 @@ class Request(object):
276
317
  """
277
318
  return self.form.getlist(key)
278
319
 
279
- def getint(self, key, default=_marker) -> int:
320
+ @t.overload
321
+ def getbool(self, key: str) -> t.Union[bool, None]:
322
+ ...
323
+
324
+ @t.overload
325
+ def getbool(self, key: str, default: T1) -> t.Union[bool, T1, None]:
326
+ ...
327
+
328
+ @t.overload
329
+ def getbool(self, key: str, *, required: t.Literal[True]) -> bool:
330
+ ...
331
+
332
+ def getbool(
333
+ self, key: str, default: T1 = _marker, required: bool = False
334
+ ) -> t.Union[bool, T1, None]:
280
335
  """
281
- Return the named key, converted to an integer.
336
+ Return the named key, converted to a bool.
337
+ """
338
+ if required:
339
+ return self.get(key, type=bool, required=True)
340
+ else:
341
+ return self.get(key, default=default, type=bool)
342
+
343
+ @t.overload
344
+ def getdecimal(self, key: str) -> t.Union[Decimal, None]:
345
+ ...
346
+
347
+ @t.overload
348
+ def getdecimal(self, key: str, default: T1) -> t.Union[Decimal, T1, None]:
349
+ ...
282
350
 
283
- If the key is not present or could not be converted to an int and no
284
- default is specified, :class:`~fresco.exceptions.BadRequest` will be
285
- raised.
351
+ @t.overload
352
+ def getdecimal(self, key: str, *, required: t.Literal[True]) -> Decimal:
353
+ ...
354
+
355
+ def getdecimal(
356
+ self, key: str, default: T1 = _marker, required: bool = False
357
+ ) -> t.Union[Decimal, T1, None]:
358
+ """
359
+ Return the named key, converted to a decimal.Decimal
286
360
  """
287
- return self.get(key, default, type=int)
361
+ if required:
362
+ return self.get(key, type=Decimal, required=True)
363
+ else:
364
+ return self.get(key, default=default, type=Decimal)
365
+
366
+ @t.overload
367
+ def getfloat(self, key: str) -> t.Union[float, None]:
368
+ ...
369
+
370
+ @t.overload
371
+ def getfloat(self, key: str, default: T1) -> t.Union[float, T1, None]:
372
+ ...
373
+
374
+ @t.overload
375
+ def getfloat(self, key: str, *, required: t.Literal[True]) -> float:
376
+ ...
377
+
378
+ def getfloat(
379
+ self, key: str, default: T1 = _marker, required: bool = False
380
+ ) -> t.Union[float, T1, None]:
381
+ """
382
+ Return the named key, converted to a float.
383
+ """
384
+ if required:
385
+ return self.get(key, type=float, required=True)
386
+ else:
387
+ return self.get(key, default=default, type=float)
388
+
389
+ @t.overload
390
+ def getint(self, key: str) -> t.Union[int, None]:
391
+ ...
392
+
393
+ @t.overload
394
+ def getint(self, key: str, default: T1) -> t.Union[int, T1, None]:
395
+ ...
396
+
397
+ @t.overload
398
+ def getint(self, key: str, *, required: t.Literal[True]) -> int:
399
+ ...
400
+
401
+ def getint(
402
+ self, key: str, default: T1 = _marker, required: bool = False
403
+ ) -> t.Union[int, T1, None]:
404
+ """
405
+ Return the named key, converted to an integer.
406
+ """
407
+ if required:
408
+ return self.get(key, type=int, required=True)
409
+ else:
410
+ return self.get(key, default=default, type=int)
288
411
 
289
412
  def __contains__(self, key):
290
413
  """
@@ -293,9 +416,7 @@ class Request(object):
293
416
  return key in self.form
294
417
 
295
418
  @property
296
- def now( # type: ignore
297
- self, now=datetime.datetime.now, utc=datetime.timezone.utc
298
- ):
419
+ def now(self, now=datetime.datetime.now, utc=datetime.timezone.utc): # type: ignore
299
420
  """
300
421
  Return a timezone-aware UTC datetime instance. The value returned is
301
422
  guaranteed to be constant throughout the lifetime of the request.
@@ -451,7 +572,7 @@ class Request(object):
451
572
  return self._parsed_url
452
573
 
453
574
  @property
454
- def path_info(self, environ_to_str=environ_to_str) -> str: # type: ignore
575
+ def path_info(self, environ_to_str=environ_to_str) -> str:
455
576
  """
456
577
  The PATH_INFO value as a string
457
578
 
@@ -463,9 +584,9 @@ class Request(object):
463
584
  raise exceptions.BadRequest
464
585
 
465
586
  @property
466
- def script_name(self):
467
- """\
468
- The SCRIPT_NAME value as a unicode string
587
+ def script_name(self) -> str:
588
+ """
589
+ The SCRIPT_NAME value as a string
469
590
 
470
591
  Note that SCRIPT_NAME is already unquoted by the server
471
592
  """
@@ -477,12 +598,12 @@ class Request(object):
477
598
  @property
478
599
  def query_string(self) -> t.Optional[str]:
479
600
  """
480
- The QUERY_STRING value as a unicode string
601
+ The QUERY_STRING value as a string
481
602
  """
482
603
  return self.environ.get("QUERY_STRING")
483
604
 
484
605
  @property
485
- def referrer(self):
606
+ def referrer(self) -> t.Optional[str]:
486
607
  """
487
608
  Return the HTTP referer header, or ``None`` if this is not available.
488
609
  """
fresco/requestcontext.py CHANGED
@@ -68,6 +68,9 @@ class RequestContext(object):
68
68
  def currentcontext(self) -> ContextDict:
69
69
  return self._contexts[self._ident_func()][-1]
70
70
 
71
+ def __contains__(self, item):
72
+ return item in self._contexts[self._ident_func()][-1]
73
+
71
74
  def __getattr__(self, item):
72
75
  try:
73
76
  return self._contexts[self._ident_func()][-1][item]