pyramid-session-redis 2.0.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.
@@ -0,0 +1,750 @@
1
+ # -*- coding: utf-8 -*-
2
+ # stdlib
3
+ import functools
4
+ import pickle
5
+ from typing import Any
6
+ from typing import Callable
7
+ from typing import Dict
8
+ from typing import Optional
9
+ from typing import TYPE_CHECKING
10
+
11
+ # pypi
12
+ from webob.cookies import SignedSerializer
13
+
14
+ # local
15
+ from . import compat # noqa: F401 ; trigger compat routines
16
+ from .connection import get_default_connection
17
+ from .exceptions import InvalidSession
18
+ from .exceptions import InvalidSession_NoSessionCookie
19
+ from .session import RedisSession
20
+ from .util import _generate_session_id
21
+ from .util import _parse_settings
22
+ from .util import _StringSerializer
23
+ from .util import _validate_session_options
24
+ from .util import configs_bool # noqa: F401 ; included for legacy
25
+ from .util import configs_dotable
26
+ from .util import create_unique_session_id
27
+ from .util import empty_session_payload
28
+ from .util import LazyCreateSession
29
+ from .util import NotSpecified
30
+ from .util import TYPING_COOKIE_EXPIRES
31
+ from .util import TYPING_SESSION_ID
32
+
33
+ if TYPE_CHECKING:
34
+ from pyramid.config import Configurator
35
+ from redis.client import Redis as RedisClient
36
+ from redis.connection import ConnectionPool
37
+ from webob.request import Request
38
+ from webob.response import Response
39
+
40
+ from .util import SignedSerializerInterface
41
+
42
+ # from pyramid.request import Request # webob has stubs; pyramid does not
43
+ # from pyramid.response import Response # webob has stubs; pyramid does not
44
+
45
+
46
+ __VERSION__ = "2.0.0"
47
+
48
+
49
+ # ==============================================================================
50
+
51
+
52
+ INVALIDATE_EMPTY_SESSION = True
53
+
54
+
55
+ def check_response_allow_cookies(response: "Response") -> bool:
56
+ """
57
+ reference implementation for ``func_check_response_allow_cookies``
58
+ If view has set any of these response headers, do not add a session
59
+ cookie on this response. This way views generating cacheable content,
60
+ like images, can signal the downstream web server that this content
61
+ is safe. Otherwise if we set a cookie on these responses it could
62
+ result to user session leakage.
63
+ """
64
+ # The view signals this is cacheable response
65
+ # and we should not stamp a session cookie on it
66
+ cookieless_headers = ["expires", "cache-control"]
67
+ for header in cookieless_headers:
68
+ if header in response.headers:
69
+ return False
70
+ return True
71
+
72
+
73
+ def includeme(config: "Configurator") -> None:
74
+ """
75
+ This function is detected by Pyramid so that you can easily include
76
+ `pyramid_session_redis` in your `main` method like so::
77
+
78
+ config.include('pyramid_session_redis')
79
+
80
+ Parameters:
81
+
82
+ ``config``
83
+ A Pyramid ``config.Configurator``
84
+ """
85
+ settings = config.registry.settings
86
+
87
+ # special rule for converting dotted python paths to callables
88
+ for option in configs_dotable:
89
+ key = "redis.sessions.%s" % option
90
+ if key in settings:
91
+ settings[key] = config.maybe_dotted(settings[key])
92
+ session_factory = session_factory_from_settings(settings)
93
+ config.set_session_factory(session_factory)
94
+
95
+
96
+ def session_factory_from_settings(settings: Dict) -> Callable:
97
+ """
98
+ Convenience method to construct a ``RedisSessionFactory`` callable from
99
+ Paste config settings. Only settings prefixed with "redis.sessions" will be
100
+ inspected and, if needed, coerced to their appropriate types (for example,
101
+ casting the ``timeout`` value as an `int`).
102
+
103
+ Parameters:
104
+
105
+ ``settings``
106
+ A dict of Pyramid application settings
107
+ """
108
+ options = _parse_settings(settings)
109
+ return RedisSessionFactory(**options)
110
+
111
+
112
+ def RedisSessionFactory(
113
+ secret: Optional[str] = None, # alternate is `cookie_signer`
114
+ timeout: Optional[int] = 1200,
115
+ cookie_name: str = "session",
116
+ cookie_max_age: Optional[int] = None,
117
+ cookie_path: str = "/",
118
+ cookie_domain: Optional[str] = None,
119
+ cookie_secure: bool = False,
120
+ cookie_httponly: bool = True,
121
+ cookie_expires: TYPING_COOKIE_EXPIRES = None, # TYPING_COOKIE_EXPIRES includes None
122
+ cookie_comment: Optional[str] = None,
123
+ cookie_samesite: Optional[str] = None,
124
+ cookie_on_exception: bool = True,
125
+ redis_url: Optional[str] = None,
126
+ redis_host: str = "localhost",
127
+ redis_port: int = 6379,
128
+ redis_db: int = 0,
129
+ redis_password: Optional[str] = None,
130
+ redis_client_callable: Optional[Callable[..., "RedisClient"]] = None,
131
+ serialize: Callable[[Dict], bytes] = pickle.dumps, # dict->bytes
132
+ deserialize: Callable[[bytes], Dict] = pickle.loads, # bytes->dict
133
+ id_generator: Callable[[], str] = _generate_session_id,
134
+ set_redis_ttl: bool = True,
135
+ set_redis_ttl_readheavy: Optional[bool] = None,
136
+ detect_changes: bool = True,
137
+ deserialized_fails_new: Optional[bool] = None,
138
+ func_check_response_allow_cookies: Optional[Callable[["Response"], bool]] = None,
139
+ func_invalid_logger: Optional[Callable[["Request", InvalidSession], Any]] = None,
140
+ timeout_trigger: Optional[int] = None,
141
+ python_expires: bool = True,
142
+ cookie_signer: Optional[
143
+ "SignedSerializerInterface"
144
+ ] = None, # alternate for `secret`
145
+ redis_socket_timeout: Optional[float] = None,
146
+ redis_connection_pool: Optional["ConnectionPool"] = None,
147
+ redis_encoding: Optional[str] = None,
148
+ redis_encoding_errors: Optional[str] = None,
149
+ redis_unix_socket_path: Optional[str] = None,
150
+ invalidate_empty_session: bool = INVALIDATE_EMPTY_SESSION,
151
+ optimistic_concurrency: bool = False,
152
+ ) -> Callable:
153
+ """
154
+ Constructs and returns a session factory that will provide session data
155
+ from a Redis server. The returned factory can be supplied as the
156
+ ``session_factory`` argument of a :class:`pyramid.config.Configurator`
157
+ constructor, or used as the ``session_factory`` argument of the
158
+ :meth:`pyramid.config.Configurator.set_session_factory` method.
159
+
160
+ Parameters:
161
+
162
+ ``secret``
163
+ A string which is used to sign the cookie. As an alternate, you can set
164
+ this to ``None`` and provide a ``cookie_signer`` argument.
165
+
166
+ ``timeout``
167
+ A number of seconds of inactivity before a session times out.
168
+ If set to 0 or None, no timeout will occur or be managed in Redis or Python.
169
+
170
+ ``cookie_name``
171
+ The name of the cookie used for sessioning. Default: ``session``.
172
+ This is passed on to the underlying ``WebOb.response.Response.set_cookie``
173
+ framework as ``name``.
174
+
175
+ ``cookie_max_age``
176
+ The maximum age of the cookie used for sessioning (in seconds).
177
+ Default: ``None`` (browser scope).
178
+ This is passed on to the underlying ``WebOb.response.Response.set_cookie``
179
+ framework as ``max_age``.
180
+
181
+ ``cookie_path``
182
+ The path used for the session cookie. Default: ``/``.
183
+ This is passed on to the underlying ``WebOb.response.Response.set_cookie``
184
+ framework as ``path``.
185
+
186
+ ``cookie_domain``
187
+ The domain used for the session cookie. Default: ``None`` (no domain).
188
+ This is passed on to the underlying ``WebOb.response.Response.set_cookie``
189
+ framework as ``domain``.
190
+
191
+ ``cookie_secure``
192
+ Boolean value; Default: ``False``.
193
+ The 'secure' flag of the session cookie.
194
+ This is passed on to the underlying ``WebOb.response.Response.set_cookie``
195
+ framework as ``secure``.
196
+
197
+ ``cookie_httponly``
198
+ Boolean value; Default: ``True``.
199
+ The 'httpOnly' flag of the session cookie.
200
+ This is passed on to the underlying ``WebOb.response.Response.set_cookie``
201
+ framework as ``httponly``.
202
+
203
+ ``cookie_expires``
204
+ Default: ``None``.
205
+ Passed to `WebOb.response.Response.set_cookie` as ``expires``.
206
+ This is a ``datetime.datetime``, ``datetime.timedelta``, or ``None``.
207
+ BEWARE: WebOb may be removing this in 1.9.
208
+
209
+ ``cookie_comment``
210
+ Default: ``None``.
211
+ The 'comment' attribute of the session cookie.
212
+ This is paseed on to the underlying ``WebOb.response.Response.set_cookie``
213
+ framework as ``comment``.
214
+ If set to ``None`` or not specified, it will not be passed on.
215
+
216
+ ``cookie_samesite``
217
+ Default: ``None``.
218
+ The 'SameSite' attribute of the session cookie.
219
+ This is paseed on to the underlying ``WebOb.response.Response.set_cookie``
220
+ framework as ``samesite`` and **requires WebOb 1.8.0 or higher**.
221
+ If set to ``None`` or not specified, it will not be passed on.
222
+ Should only be ``"strict"``, ``"lax"`` or ``"none"``.
223
+
224
+ ``cookie_on_exception``
225
+ Boolean value; Default: ``True``.
226
+ If ``True``, set a session cookie even if an exception occurs
227
+ while rendering a view.
228
+
229
+ ``redis_url``
230
+ Default: ``None``.
231
+ A connection string for a Redis server, in the format:
232
+ redis://username:password@localhost:6379/0
233
+
234
+ ``redis_host`` replacement
235
+ Default: ``localhost``.
236
+ A string representing the IP of your Redis server.
237
+
238
+ ``redis_port`` replacement
239
+ Default: ``6379``.
240
+ An integer representing the port of your Redis server.
241
+
242
+ ``redis_db`` replacement
243
+ Integer value; Default: ``0``
244
+ An integer to select a specific database on your Redis server.
245
+
246
+ ``redis_password`` replacement
247
+ Default: ``None``.
248
+ A string password to connect to your Redis server/database if
249
+ required.
250
+
251
+ ``redis_client_callable`` replacement
252
+ Default: ``None``.
253
+ A python callable that accepts a Pyramid `request` and Redis config options
254
+ and returns a Redis client such as redis-py's `StrictRedis`.
255
+
256
+ ``serialize``
257
+ Default: ``pickle.dumps``
258
+ A function to serialize the session dict for storage in Redis.
259
+
260
+ ``deserialize``
261
+ Default: ``pickle.loads``.
262
+ A function to deserialize the stored session data in Redis.
263
+
264
+ ``id_generator``
265
+ Default: private function that uses sha1 with the time and random elements
266
+ to create a 40 character unique ID.
267
+ A function to create a unique ID to be used as the session key when a
268
+ session is first created.
269
+ As of `v1.7`, ``id_generator`` MUST return a str.
270
+
271
+ ``set_redis_ttl``
272
+ Boolean value; Default `True`.
273
+ If set to ``True``, will set a TTL. If
274
+ ``False`` will not set a TTL and assumes that Redis is configured as a
275
+ least-recently-used cache [http://redis.io/topics/lru-cache] and will NOT
276
+ send EXPIRY data of sessions to Redis (the value of `timeout` will be
277
+ ignored if set). This does not require or imply that no ``timeout`` data is
278
+ handled within the Python payload, it just determines if Redis will be
279
+ involved with timeout logic.
280
+
281
+ ``set_redis_ttl_readheavy``
282
+ Boolean value; Default: ``None``.
283
+ If ``True``, sets TTL data in Redis within
284
+ a PIPELINE via GET+EXPIRE and supresses automatic TTL refresh during the
285
+ deferred cleanup phase. If not ``True``, an EXPIRE is sent as a separate
286
+ action during the deferred cleanup phase. The optimized behavior improves
287
+ performance on read-heavy operations, but may degrade performance on
288
+ write-heavy operations.
289
+ This requires a ``timeout`` and ``set_redis_ttl`` to be True; it is not
290
+ compatible with ``timeout_trigger`` or ``python_expires``.
291
+
292
+ ``detect_changes``
293
+ Boolean value; Default: ``True``.
294
+ If set to ``True``, will calculate nested changes after
295
+ serialization to ensure persistence of nested data.
296
+
297
+ ``deserialized_fails_new``
298
+ Boolean value; Default: ``None``.
299
+ If ``True`` will handle deserializtion errors
300
+ by creating a new session.
301
+
302
+ ``func_check_response_allow_cookies``
303
+ Default: ``None``.
304
+ A callable function that accepts a response, returning ``True`` if the
305
+ cookie can be sent and ``False`` if it should not.
306
+ An example callable is available in
307
+ ``check_response_allow_cookies``, which checks for `expires` and
308
+ `cache-control` cookies.
309
+
310
+ ``func_invalid_logger``
311
+ Default: ``None``.
312
+ A callable function that expects a single argument of a raised
313
+ `InvalidSession` exception. If not ``None``, this will be called so your
314
+ application can monitor.
315
+
316
+ ``timeout_trigger``
317
+ Integer value; Default ``None``.
318
+ If unset or ``0``, timeouts will be updated on
319
+ every access by setting an EXPIRY in Redis and/or updating the ``expires``
320
+ value in the session payload. If set to an INT, the updates will only be
321
+ set once the threshold is crossed.
322
+
323
+ ``python_expires``
324
+ Boolean value; Default ``True``.
325
+ If ``True``, allows for timeout logic to be
326
+ tracked in Python.
327
+
328
+ ``cookie_signer``
329
+ Default: ``None``
330
+ If specified, ``secret`` MUST be ``None``.
331
+ An object with two methods, ``loads`` and ``dumps``::
332
+ The ``loads`` method should accept bytes and return a Python string.
333
+ The ``dumps`` method should accept a Python string and return bytes.
334
+ A ``ValueError`` should be raised for malformed inputs.
335
+
336
+ ``redis_socket_timeout``
337
+ Default: ``None``.
338
+ Passthrough argument to the `StrictRedis` constructor.
339
+
340
+ ``redis_connection_pool``
341
+ Default: ``None``.
342
+ Passthrough argument to the `StrictRedis` constructor.
343
+
344
+ ``redis_encoding``
345
+ Default: ``utf-8``.
346
+ Passthrough argument to the `StrictRedis` constructor.
347
+
348
+ ``redis_encoding_errors``
349
+ Default: ``strict``.
350
+ Passthrough argument to the `StrictRedis` constructor.
351
+
352
+ ``redis_unix_socket_path``
353
+ Default: ``None``.
354
+ Passthrough argument to the `StrictRedis` constructor.
355
+
356
+ ``invalidate_empty_session``
357
+ Default: ``False``
358
+ Will invalidate an empty session, deleting the cookie and expiring
359
+ the backend storage.
360
+ * Defaulted to `True` in 1.8
361
+ * Added in v1.7 with default as `False`
362
+
363
+ ``optimistic_concurrency``
364
+ Default: ``False``. If enabled, persistence uses WATCH/GET/MULTI/EXEC
365
+ to reject writes when the stored payload changed or was removed since
366
+ it was loaded. Raises ``SessionConflict`` without retrying stale data.
367
+ This adds Redis round trips only when enabled. All session writers
368
+ must enable it for consistent protection against lost updates.
369
+
370
+ # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
371
+
372
+ Timeout Example
373
+ ---------------
374
+
375
+ Given this example:
376
+
377
+ timeout = 1200
378
+ timeout_trigger = 600
379
+
380
+ The following timeline would occur
381
+
382
+ time | action | timeout | next threshold
383
+ -----+--------+---------+--------------
384
+ 0 | CREATE | 1200 | 600
385
+ 599 | | 1200 | 600
386
+ 600 | UPDATE | 1800 | 1200
387
+ 599 | | 1800 | 1200
388
+ 1200 | UPDATE | 2400 | 1800
389
+
390
+ The feature has the ability to significantly lower the amount of processing
391
+ on Redis, however it means a session timeout expires after the last trigger
392
+ and not the last usage.
393
+
394
+ For example, with a timeout trigger of 10 minutes on a 1 hour session, if a
395
+ user leaves the site at 49 minutes and returns at 61 minutes, the trigger
396
+ will not have been made and the session will have expired.
397
+
398
+ Redis **kwargs
399
+ ---------------
400
+
401
+ The following arguments are passed straight to the ``StrictRedis``
402
+ constructor and allow you to further configure the Redis client::
403
+
404
+ modern | previously deprecated name
405
+ -----------------------+--------------------
406
+ redis_socket_timeout | socket_timeout
407
+ redis_connection_pool | connection_pool
408
+ redis_encoding | charset
409
+ redis_encoding_errors | errors
410
+ redis_unix_socket_path | unix_socket_path
411
+ """
412
+ _validate_session_options(
413
+ timeout=timeout,
414
+ set_redis_ttl=set_redis_ttl,
415
+ set_redis_ttl_readheavy=set_redis_ttl_readheavy,
416
+ timeout_trigger=timeout_trigger,
417
+ python_expires=python_expires,
418
+ )
419
+ if timeout == 0:
420
+ timeout = None
421
+
422
+ if timeout_trigger and not python_expires: # fix this
423
+ python_expires = True
424
+
425
+ _set_redis_ttl_onexit = False
426
+ if (timeout and set_redis_ttl) and (
427
+ not timeout_trigger and not python_expires and not set_redis_ttl_readheavy
428
+ ):
429
+ _set_redis_ttl_onexit = True
430
+
431
+ # good for all factory() requests
432
+ set_cookie_kwargs: Dict[str, Any] = {
433
+ "max_age": cookie_max_age,
434
+ "path": cookie_path,
435
+ "domain": cookie_domain,
436
+ "secure": cookie_secure,
437
+ "httponly": cookie_httponly,
438
+ }
439
+ if cookie_comment is not None:
440
+ set_cookie_kwargs["comment"] = cookie_comment
441
+ if cookie_expires is not None:
442
+ set_cookie_kwargs["expires"] = cookie_expires
443
+ if cookie_samesite is not None:
444
+ set_cookie_kwargs["samesite"] = cookie_samesite
445
+
446
+ # good for all factory() requests
447
+ redis_options = dict(
448
+ host=redis_host,
449
+ port=redis_port,
450
+ db=redis_db,
451
+ password=redis_password,
452
+ socket_timeout=redis_socket_timeout,
453
+ connection_pool=redis_connection_pool,
454
+ unix_socket_path=redis_unix_socket_path,
455
+ )
456
+
457
+ # accept newer encoding and encoding_errors args while
458
+ # retaining backwards compatibility
459
+ if redis_encoding is not None:
460
+ redis_options["encoding"] = redis_encoding
461
+ if redis_encoding_errors is not None:
462
+ redis_options["encoding_errors"] = redis_encoding_errors
463
+
464
+ # good for all factory() requests
465
+ new_payload_func = functools.partial(
466
+ empty_session_payload,
467
+ timeout=timeout or 0,
468
+ python_expires=python_expires,
469
+ )
470
+
471
+ # good for all factory() requests
472
+ delete_cookie_func = functools.partial(
473
+ _delete_cookie,
474
+ cookie_name=cookie_name,
475
+ cookie_path=cookie_path,
476
+ cookie_domain=cookie_domain,
477
+ )
478
+
479
+ _secret_cookiesigner = (secret, cookie_signer)
480
+ _cookie_signer: "SignedSerializerInterface"
481
+ if all(_secret_cookiesigner) or not any(_secret_cookiesigner):
482
+ raise ValueError(
483
+ "One, and only one, of `secret` and `cookie_signer` must be provided."
484
+ )
485
+ if secret is not None:
486
+ # the second argument is the salt.
487
+ # customizing this would needlessly complicate integration
488
+
489
+ # webob docs `SignedSerializer`:
490
+ # An object with two methods: `loads`` and ``dumps``. The ``loads`` method
491
+ # should accept bytes and return a Python object. The ``dumps`` method
492
+ # should accept a Python object and return bytes.
493
+
494
+ # since we use a string session_id, utilizing `_StringSerializer`
495
+ # saves the overhead of using the default JSON serializer
496
+
497
+ # SignedSerializer(secret, salt, hashalg="sha512", serializer=None)
498
+ _cookie_signer = SignedSerializer(
499
+ secret,
500
+ "pyramid_session_redis.",
501
+ "sha512",
502
+ serializer=_StringSerializer(), # convert to a string, not json
503
+ )
504
+ else:
505
+ if TYPE_CHECKING:
506
+ assert cookie_signer is not None
507
+ _cookie_signer = cookie_signer
508
+
509
+ def factory(
510
+ request: "Request",
511
+ new_session_id_func: Callable[..., str] = create_unique_session_id,
512
+ ) -> RedisSession:
513
+ # an explicit client callable gets priority over the default
514
+ redis_conn: "RedisClient" = (
515
+ redis_client_callable(request, **redis_options)
516
+ if redis_client_callable is not None
517
+ else get_default_connection(request, url=redis_url, **redis_options) # type: ignore[arg-type]
518
+ )
519
+
520
+ new_session_func = functools.partial(
521
+ new_session_id_func,
522
+ redis=redis_conn,
523
+ timeout=timeout,
524
+ serialize=serialize,
525
+ generator=id_generator,
526
+ set_redis_ttl=set_redis_ttl,
527
+ # set_redis_ttl_readheavy=set_redis_ttl_readheavy, # not needed on NEW
528
+ # _set_redis_ttl_onexit=_set_redis_ttl_onexit, # not needed on NEW
529
+ new_payload_func=new_payload_func,
530
+ python_expires=python_expires,
531
+ )
532
+
533
+ session_id: Optional[TYPING_SESSION_ID]
534
+ try:
535
+ # attempt to retrieve a session_id from the cookie
536
+ session_id = _get_session_id_from_cookie(
537
+ request=request,
538
+ cookie_name=cookie_name,
539
+ cookie_signer=_cookie_signer,
540
+ )
541
+ if not session_id:
542
+ raise InvalidSession_NoSessionCookie("No `session_id` in cookie.")
543
+ session_cookie_was_valid = True
544
+ session = RedisSession(
545
+ redis=redis_conn,
546
+ session_id=session_id,
547
+ new=False,
548
+ new_session=new_session_func,
549
+ new_payload_func=new_payload_func,
550
+ serialize=serialize,
551
+ deserialize=deserialize,
552
+ set_redis_ttl=set_redis_ttl,
553
+ set_redis_ttl_readheavy=set_redis_ttl_readheavy,
554
+ _set_redis_ttl_onexit=_set_redis_ttl_onexit,
555
+ detect_changes=detect_changes,
556
+ deserialized_fails_new=deserialized_fails_new,
557
+ timeout_trigger=timeout_trigger,
558
+ timeout=timeout,
559
+ python_expires=python_expires,
560
+ optimistic_concurrency=optimistic_concurrency,
561
+ )
562
+ except InvalidSession as e:
563
+ if func_invalid_logger is not None:
564
+ # send the instance for logging
565
+ func_invalid_logger(request, e)
566
+ session_id = LazyCreateSession
567
+ session_cookie_was_valid = False
568
+ session = RedisSession(
569
+ redis=redis_conn,
570
+ session_id=session_id,
571
+ new=True,
572
+ new_session=new_session_func,
573
+ new_payload_func=new_payload_func,
574
+ serialize=serialize,
575
+ deserialize=deserialize,
576
+ set_redis_ttl=set_redis_ttl,
577
+ # set_redis_ttl_readheavy=set_redis_ttl_readheavy, # not needed on NEW
578
+ # _set_redis_ttl_onexit=_set_redis_ttl_onexit, # not needed on NEW
579
+ detect_changes=detect_changes,
580
+ timeout_trigger=timeout_trigger,
581
+ timeout=timeout,
582
+ python_expires=python_expires,
583
+ optimistic_concurrency=optimistic_concurrency,
584
+ )
585
+
586
+ set_cookie_func = functools.partial(
587
+ _set_cookie,
588
+ session,
589
+ cookie_signer=_cookie_signer,
590
+ cookie_name=cookie_name,
591
+ **set_cookie_kwargs,
592
+ )
593
+ cookie_callback = functools.partial(
594
+ _cookie_callback,
595
+ session,
596
+ session_cookie_was_valid=session_cookie_was_valid,
597
+ cookie_on_exception=cookie_on_exception,
598
+ set_cookie_func=set_cookie_func,
599
+ delete_cookie_func=delete_cookie_func,
600
+ func_check_response_allow_cookies=func_check_response_allow_cookies,
601
+ invalidate_empty_session=invalidate_empty_session,
602
+ )
603
+ request.add_response_callback(cookie_callback)
604
+ request.add_finished_callback(session._deferred_callback)
605
+ return session
606
+
607
+ return factory
608
+
609
+
610
+ def _get_session_id_from_cookie(
611
+ request: "Request",
612
+ cookie_name: str,
613
+ cookie_signer: "SignedSerializerInterface", # has `.loads`, `.dumps`; MUST return a str
614
+ ) -> Optional[str]:
615
+ """
616
+ Attempts to retrieve and return a session ID from a session cookie in the
617
+ current request. Returns None if the cookie isn't found or the value cannot
618
+ be deserialized for any reason.
619
+ """
620
+ cookieval = request.cookies.get(cookie_name)
621
+ if cookieval is not None:
622
+ try:
623
+ session_id = cookie_signer.loads(cookieval.encode())
624
+ if TYPE_CHECKING:
625
+ # because we load this, it can't be a `LazyCreateSession`
626
+ assert isinstance(session_id, str)
627
+ return session_id
628
+ except ValueError:
629
+ pass
630
+
631
+ return None
632
+
633
+
634
+ def _set_cookie(
635
+ session: RedisSession,
636
+ request: "Request",
637
+ response: "Response",
638
+ cookie_signer: "SignedSerializerInterface", # has `.loads`, `.dumps`; MUST return a str
639
+ cookie_name: str,
640
+ **kwargs,
641
+ ) -> None:
642
+ """
643
+ `session` is via functools.partial
644
+ `request` and `response` are appended by add_response_callback
645
+ """
646
+ cookieval = cookie_signer.dumps(session.ensure_id())
647
+ # `.dumps` generates bytes; `.set_cookie` expects str
648
+ response.set_cookie(cookie_name, cookieval.decode(), **kwargs)
649
+
650
+
651
+ def _delete_cookie(
652
+ response: "Response",
653
+ cookie_name: str,
654
+ cookie_path: str,
655
+ cookie_domain: Optional[str] = None,
656
+ ) -> None:
657
+ response.delete_cookie(cookie_name, path=cookie_path, domain=cookie_domain)
658
+
659
+
660
+ def _cookie_callback(
661
+ session: RedisSession,
662
+ request: "Request",
663
+ response: "Response",
664
+ session_cookie_was_valid: bool,
665
+ cookie_on_exception: bool,
666
+ set_cookie_func: Callable[..., None],
667
+ delete_cookie_func: Callable[..., None],
668
+ func_check_response_allow_cookies: Optional[Callable[["Response"], bool]],
669
+ invalidate_empty_session: bool,
670
+ ) -> None:
671
+ """
672
+ Response callback to set the appropriate Set-Cookie header.
673
+ `session` is via functools.partial
674
+ `request` and `response` are appended by add_response_callback
675
+
676
+ Important: `invalidate_empty_session` must remain an option so people
677
+ can keep empty sessions if using a workaround.
678
+
679
+ see: https://github.com/jvanasco/pyramid_session_redis/issues/67
680
+ """
681
+
682
+ # `session._session_state` will not exist after `invalidate` and other methods
683
+ # so we will extract some info from it...
684
+ please_recookie = None
685
+ _override_args = {}
686
+ if "_session_state" in session.__dict__:
687
+ if session._session_state.please_recookie:
688
+ please_recookie = True
689
+ if session._session_state.cookie_expires != NotSpecified:
690
+ _override_args["expires"] = session._session_state.cookie_expires
691
+ if session._session_state.cookie_max_age != NotSpecified:
692
+ _override_args["max_age"] = session._session_state.cookie_max_age
693
+
694
+ # helper function for multiple contexts
695
+ # note how this helper function uses the above-set `_override_args`
696
+ def _set_cookie_and_response() -> None:
697
+ set_cookie_func(request=request, response=response, **_override_args)
698
+
699
+ # If we set a cookie we need to make sure that downstream
700
+ # web servicess do not serve this response from a cache
701
+ # for requests coming in with a different session cookie.
702
+ # Otherwise we might leak sessions between users.
703
+ varies = ("Cookie",)
704
+ vary = set(response.vary if response.vary is not None else [])
705
+ vary |= set(varies)
706
+ response.vary = vary
707
+
708
+ if func_check_response_allow_cookies is not None:
709
+ if not func_check_response_allow_cookies(response):
710
+ # if we don't want to send cookies on this response,
711
+ # we might not want to persist or refresh
712
+ # session._session_state.dont_persist = True
713
+ # session._session_state.dont_refresh = True
714
+ return
715
+
716
+ # gate this function against `session._invalidated`
717
+ # `session.new` will create a new `_session_state` if invalidated
718
+ # creating a new _session_state will lose the invalidated check
719
+ if invalidate_empty_session and not session._invalidated:
720
+ # if the session is empty...
721
+ if not session.managed_dict:
722
+ if session.new:
723
+ # mark this session to not persist
724
+ session._session_state.dont_persist = True
725
+ session._session_state.dont_refresh = True
726
+ else:
727
+ # invalidate the session, clearing Redis and setting a marker
728
+ session.invalidate()
729
+ # the next block will delete the cookie and return
730
+
731
+ if session._invalidated:
732
+ if session_cookie_was_valid:
733
+ delete_cookie_func(response=response)
734
+ return
735
+
736
+ if session.new:
737
+ if not session.session_id_safecheck:
738
+ return
739
+ if request.exception is None or cookie_on_exception is True:
740
+ _set_cookie_and_response()
741
+ elif session_cookie_was_valid:
742
+ # We don't set a cookie for the new session here (as
743
+ # `cookie_on_exception` is `False` and an exception was raised), but we
744
+ # still need to delete the existing cookie for the session that the
745
+ # request started with (as the session has now been invalidated).
746
+ delete_cookie_func(response=response)
747
+ else:
748
+ if please_recookie:
749
+ if request.exception is None or cookie_on_exception is True:
750
+ _set_cookie_and_response()