navigator-session 0.7.0__tar.gz → 0.8.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.
- {navigator_session-0.7.0 → navigator_session-0.8.1}/PKG-INFO +2 -3
- navigator_session-0.8.1/navigator_session/data.py +355 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session/version.py +2 -1
- {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session.egg-info/PKG-INFO +2 -3
- {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session.egg-info/SOURCES.txt +2 -1
- {navigator_session-0.7.0 → navigator_session-0.8.1}/pyproject.toml +9 -6
- navigator_session-0.8.1/tests/test_session_data.py +542 -0
- navigator_session-0.7.0/navigator_session/data.py +0 -211
- {navigator_session-0.7.0 → navigator_session-0.8.1}/CHANGES.rst +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/LICENSE +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/MANIFEST.in +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/Makefile +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/README.md +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/docs/Makefile +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/docs/api.rst +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/docs/authors.rst +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/docs/conf.py +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/docs/examples.rst +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/docs/index.rst +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/docs/make.bat +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/docs/requirements-dev.txt +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/docs/requirements.txt +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session/__init__.py +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session/conf.py +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session/middleware.py +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session/session.py +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session/storages/__init__.py +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session/storages/abstract.py +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session/storages/cookie.py +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session/storages/redis.py +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session.egg-info/dependency_links.txt +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session.egg-info/requires.txt +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session.egg-info/top_level.txt +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/setup.cfg +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/setup.py +0 -0
- {navigator_session-0.7.0 → navigator_session-0.8.1}/tests/__init__.py +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: navigator-session
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.1
|
|
4
4
|
Summary: Navigator Session allows us to store user-specific data into session object.
|
|
5
5
|
Author-email: Jesus Lara Gimenez <jesuslarag@gmail.com>
|
|
6
|
-
License: Apache
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
7
|
Project-URL: Homepage, https://github.com/phenobarbital/navigator-session
|
|
8
8
|
Project-URL: Source, https://github.com/phenobarbital/navigator-session
|
|
9
9
|
Project-URL: Funding, https://paypal.me/phenobarbital
|
|
@@ -22,7 +22,6 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
22
22
|
Classifier: Programming Language :: Python :: 3.12
|
|
23
23
|
Classifier: Framework :: AsyncIO
|
|
24
24
|
Classifier: Framework :: aiohttp
|
|
25
|
-
Classifier: License :: OSI Approved :: Apache Software License
|
|
26
25
|
Requires-Python: >=3.9.13
|
|
27
26
|
Description-Content-Type: text/markdown
|
|
28
27
|
License-File: LICENSE
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
import time
|
|
3
|
+
from typing import Union, Optional, Any
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from collections.abc import Iterator, Mapping, MutableMapping
|
|
6
|
+
import jsonpickle
|
|
7
|
+
from jsonpickle.unpickler import loadclass
|
|
8
|
+
from aiohttp import web
|
|
9
|
+
from datamodel import BaseModel
|
|
10
|
+
from .conf import (
|
|
11
|
+
SESSION_KEY,
|
|
12
|
+
SESSION_ID,
|
|
13
|
+
SESSION_STORAGE
|
|
14
|
+
)
|
|
15
|
+
try:
|
|
16
|
+
from pydantic import BaseModel as PydanticBaseModel
|
|
17
|
+
except ImportError:
|
|
18
|
+
PydanticBaseModel = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ModelHandler(jsonpickle.handlers.BaseHandler):
|
|
22
|
+
"""ModelHandler.
|
|
23
|
+
This class can handle with serializable Data Models.
|
|
24
|
+
"""
|
|
25
|
+
def flatten(self, obj, data):
|
|
26
|
+
data['__dict__'] = self.context.flatten(obj.__dict__, reset=False)
|
|
27
|
+
return data
|
|
28
|
+
|
|
29
|
+
def restore(self, obj):
|
|
30
|
+
module_and_type = obj['py/object']
|
|
31
|
+
mdl = loadclass(module_and_type)
|
|
32
|
+
cls = mdl.__new__(mdl) if hasattr(mdl, '__new__') else object.__new__(mdl)
|
|
33
|
+
cls.__dict__ = self.context.restore(obj['__dict__'], reset=False)
|
|
34
|
+
return cls
|
|
35
|
+
|
|
36
|
+
jsonpickle.handlers.registry.register(BaseModel, ModelHandler, base=True)
|
|
37
|
+
|
|
38
|
+
if PydanticBaseModel:
|
|
39
|
+
class PydanticHandler(jsonpickle.handlers.BaseHandler):
|
|
40
|
+
"""PydanticHandler.
|
|
41
|
+
This class can handle with serializable Pydantic Models.
|
|
42
|
+
"""
|
|
43
|
+
def flatten(self, obj, data):
|
|
44
|
+
data['__dict__'] = self.context.flatten(obj.__dict__, reset=False)
|
|
45
|
+
return data
|
|
46
|
+
|
|
47
|
+
def restore(self, obj):
|
|
48
|
+
module_and_type = obj['py/object']
|
|
49
|
+
mdl = loadclass(module_and_type)
|
|
50
|
+
cls = mdl.__new__(mdl) if hasattr(mdl, '__new__') else object.__new__(mdl)
|
|
51
|
+
cls.__dict__ = self.context.restore(obj['__dict__'], reset=False)
|
|
52
|
+
return cls
|
|
53
|
+
|
|
54
|
+
jsonpickle.handlers.registry.register(PydanticBaseModel, PydanticHandler, base=True)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SessionData(MutableMapping[str, Any]):
|
|
58
|
+
"""Session dict-like object.
|
|
59
|
+
|
|
60
|
+
Supports both serializable data (stored in _data and persisted) and
|
|
61
|
+
in-memory objects (stored in _objects, not persisted).
|
|
62
|
+
|
|
63
|
+
Non-serializable objects (class instances, etc.) are automatically
|
|
64
|
+
stored in _objects when assigned via session.key = value or session['key'] = value.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
_data: Union[str, Any] = {}
|
|
68
|
+
_objects: dict[str, Any] = {}
|
|
69
|
+
|
|
70
|
+
# Internal attributes that should not be stored in _data or _objects
|
|
71
|
+
_internal_attrs = frozenset({
|
|
72
|
+
'_data', '_objects', '_changed', '_id_', '_identity', '_new',
|
|
73
|
+
'_max_age', '_now', '__created__', '_created', '_dow', '_doy',
|
|
74
|
+
'_time', 'args'
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
*args,
|
|
80
|
+
data: Optional[Mapping[str, Any]] = None,
|
|
81
|
+
new: bool = False,
|
|
82
|
+
id: Optional[str] = None,
|
|
83
|
+
identity: Optional[Any] = None,
|
|
84
|
+
max_age: Optional[int] = None
|
|
85
|
+
) -> None:
|
|
86
|
+
# Initialize internal storage first (before any attribute access)
|
|
87
|
+
object.__setattr__(self, '_data', {})
|
|
88
|
+
object.__setattr__(self, '_objects', {})
|
|
89
|
+
object.__setattr__(self, '_changed', False)
|
|
90
|
+
# Unique ID:
|
|
91
|
+
self._id_ = (data.get(SESSION_ID, None) if data else id) or uuid.uuid4().hex
|
|
92
|
+
# Session Identity
|
|
93
|
+
self._identity = (
|
|
94
|
+
data.get(SESSION_KEY, None) if data else identity
|
|
95
|
+
) or self._id_
|
|
96
|
+
self._new = new if data != {} else True
|
|
97
|
+
self._max_age = max_age or None
|
|
98
|
+
created = data.get('created', None) if data else None
|
|
99
|
+
self._now = datetime.now(timezone.utc)
|
|
100
|
+
self.__created__ = self._now
|
|
101
|
+
now = int(self._now.timestamp())
|
|
102
|
+
self._now = now # time for this instance creation
|
|
103
|
+
age = now - created if created else now
|
|
104
|
+
if max_age is not None and age > max_age:
|
|
105
|
+
data = None
|
|
106
|
+
self._created = now if self._new or created is None else created
|
|
107
|
+
## Data updating.
|
|
108
|
+
if data is not None:
|
|
109
|
+
self._data.update(data)
|
|
110
|
+
# Other mark timestamp for this session:
|
|
111
|
+
dt = datetime.now(timezone.utc)
|
|
112
|
+
self._dow = dt.weekday()
|
|
113
|
+
self._doy = dt.timetuple().tm_yday
|
|
114
|
+
self._time = dt.time()
|
|
115
|
+
self.args = args
|
|
116
|
+
|
|
117
|
+
def __repr__(self) -> str:
|
|
118
|
+
return (
|
|
119
|
+
f'<NAV-Session [new:{self.new}, created:{self.created}] '
|
|
120
|
+
f'data={self._data!r}, objects={list(self._objects.keys())}>'
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# --- Serialization helpers ---
|
|
124
|
+
|
|
125
|
+
def _is_serializable(self, value: Any) -> bool:
|
|
126
|
+
"""Check if a value can be reliably serialized and restored with jsonpickle.
|
|
127
|
+
|
|
128
|
+
Returns True for primitive types, dicts, lists, and known serializable models.
|
|
129
|
+
Returns False for arbitrary class instances that may not restore properly
|
|
130
|
+
in a different process/context.
|
|
131
|
+
"""
|
|
132
|
+
# Primitive types are always serializable
|
|
133
|
+
if value is None or isinstance(value, (bool, int, float, str, bytes)):
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
# Dicts and lists need recursive check
|
|
137
|
+
if isinstance(value, dict):
|
|
138
|
+
return all(self._is_serializable(v) for v in value.values())
|
|
139
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
140
|
+
return all(self._is_serializable(v) for v in value)
|
|
141
|
+
|
|
142
|
+
# BaseModel (datamodel) instances are handled by ModelHandler
|
|
143
|
+
if isinstance(value, BaseModel):
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
# Pydantic models are handled by PydanticHandler
|
|
147
|
+
if PydanticBaseModel and isinstance(value, PydanticBaseModel):
|
|
148
|
+
return True
|
|
149
|
+
|
|
150
|
+
# datetime types are serializable
|
|
151
|
+
if isinstance(value, (datetime,)):
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
# Any other type (class instances, functions, etc.) is NOT reliably serializable
|
|
155
|
+
# These should be stored in-memory only
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
def _get_value(self, key: str) -> Any:
|
|
159
|
+
"""Unified getter that checks both _objects and _data."""
|
|
160
|
+
if key in self._objects:
|
|
161
|
+
return self._objects[key]
|
|
162
|
+
if key in self._data:
|
|
163
|
+
return self._data[key]
|
|
164
|
+
raise KeyError(key)
|
|
165
|
+
|
|
166
|
+
def _set_value(self, key: str, value: Any) -> None:
|
|
167
|
+
"""Unified setter that routes to _objects or _data based on serializability."""
|
|
168
|
+
if self._is_serializable(value):
|
|
169
|
+
# Remove from _objects if it was there before
|
|
170
|
+
self._objects.pop(key, None)
|
|
171
|
+
self._data[key] = value
|
|
172
|
+
self._changed = True
|
|
173
|
+
else:
|
|
174
|
+
# Store in _objects (in-memory only, not persisted)
|
|
175
|
+
# Remove from _data if it was there before
|
|
176
|
+
self._data.pop(key, None)
|
|
177
|
+
self._objects[key] = value
|
|
178
|
+
# Note: _objects changes don't set _changed since they're not persisted
|
|
179
|
+
|
|
180
|
+
def _del_value(self, key: str) -> None:
|
|
181
|
+
"""Unified delete that removes from both _objects and _data."""
|
|
182
|
+
deleted = False
|
|
183
|
+
if key in self._objects:
|
|
184
|
+
del self._objects[key]
|
|
185
|
+
deleted = True
|
|
186
|
+
if key in self._data:
|
|
187
|
+
del self._data[key]
|
|
188
|
+
self._changed = True
|
|
189
|
+
deleted = True
|
|
190
|
+
if not deleted:
|
|
191
|
+
raise KeyError(key)
|
|
192
|
+
|
|
193
|
+
def _has_value(self, key: str) -> bool:
|
|
194
|
+
"""Check if key exists in either _objects or _data."""
|
|
195
|
+
return key in self._objects or key in self._data
|
|
196
|
+
|
|
197
|
+
# --- Properties ---
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def new(self) -> bool:
|
|
201
|
+
return self._new
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def logon_time(self) -> datetime:
|
|
205
|
+
return self.__created__
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def session_id(self) -> str:
|
|
209
|
+
return self._id_
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def identity(self) -> Optional[Any]: # type: ignore[misc]
|
|
213
|
+
return self._identity
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def created(self) -> int:
|
|
217
|
+
return self._created
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def dow(self) -> int:
|
|
221
|
+
return self._dow
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def session_time(self) -> time:
|
|
225
|
+
return self._time
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def empty(self) -> bool:
|
|
229
|
+
return not bool(self._data) and not bool(self._objects)
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def max_age(self) -> Optional[int]:
|
|
233
|
+
return self._max_age
|
|
234
|
+
|
|
235
|
+
@max_age.setter
|
|
236
|
+
def max_age(self, value: Optional[int]) -> None:
|
|
237
|
+
self._max_age = value
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def is_changed(self) -> bool:
|
|
241
|
+
return self._changed
|
|
242
|
+
|
|
243
|
+
@is_changed.setter
|
|
244
|
+
def is_changed(self, value: bool) -> None:
|
|
245
|
+
self._changed = value
|
|
246
|
+
|
|
247
|
+
def changed(self) -> None:
|
|
248
|
+
self._changed = True
|
|
249
|
+
|
|
250
|
+
def session_data(self) -> dict:
|
|
251
|
+
"""Return only serializable data (for persistence)."""
|
|
252
|
+
return self._data
|
|
253
|
+
|
|
254
|
+
def session_objects(self) -> dict:
|
|
255
|
+
"""Return in-memory objects (not persisted)."""
|
|
256
|
+
return self._objects
|
|
257
|
+
|
|
258
|
+
def invalidate(self) -> None:
|
|
259
|
+
"""Clear all session data and in-memory objects."""
|
|
260
|
+
self._changed = True
|
|
261
|
+
self._data = {}
|
|
262
|
+
self._objects = {}
|
|
263
|
+
|
|
264
|
+
# --- Magic Methods ---
|
|
265
|
+
|
|
266
|
+
def __len__(self) -> int:
|
|
267
|
+
return len(self._data) + len(self._objects)
|
|
268
|
+
|
|
269
|
+
def __iter__(self) -> Iterator[str]:
|
|
270
|
+
# Iterate over both _data and _objects keys
|
|
271
|
+
seen = set()
|
|
272
|
+
for key in self._data:
|
|
273
|
+
seen.add(key)
|
|
274
|
+
yield key
|
|
275
|
+
for key in self._objects:
|
|
276
|
+
if key not in seen:
|
|
277
|
+
yield key
|
|
278
|
+
|
|
279
|
+
def __contains__(self, key: object) -> bool:
|
|
280
|
+
return self._has_value(str(key))
|
|
281
|
+
|
|
282
|
+
def __getitem__(self, key: str) -> Any:
|
|
283
|
+
return self._get_value(key)
|
|
284
|
+
|
|
285
|
+
def __setitem__(self, key: str, value: Any) -> None:
|
|
286
|
+
self._set_value(key, value)
|
|
287
|
+
|
|
288
|
+
def __delitem__(self, key: str) -> None:
|
|
289
|
+
self._del_value(key)
|
|
290
|
+
|
|
291
|
+
def __getattr__(self, key: str) -> Any:
|
|
292
|
+
# Avoid infinite recursion for internal attributes
|
|
293
|
+
if key.startswith('_'):
|
|
294
|
+
raise AttributeError(key)
|
|
295
|
+
try:
|
|
296
|
+
return self._get_value(key)
|
|
297
|
+
except KeyError:
|
|
298
|
+
raise AttributeError(key) from None
|
|
299
|
+
|
|
300
|
+
def __setattr__(self, key: str, value: Any) -> None:
|
|
301
|
+
# Handle internal attributes normally
|
|
302
|
+
if key in self._internal_attrs or key.startswith('_'):
|
|
303
|
+
object.__setattr__(self, key, value)
|
|
304
|
+
else:
|
|
305
|
+
self._set_value(key, value)
|
|
306
|
+
|
|
307
|
+
def encode(self, obj: Any) -> str:
|
|
308
|
+
"""encode
|
|
309
|
+
|
|
310
|
+
Encode an object using jsonpickle.
|
|
311
|
+
Args:
|
|
312
|
+
obj (Any): Object to be encoded using jsonpickle
|
|
313
|
+
|
|
314
|
+
Raises:
|
|
315
|
+
RuntimeError: Error converting data to json.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
str: json version of the data
|
|
319
|
+
"""
|
|
320
|
+
try:
|
|
321
|
+
return jsonpickle.encode(obj)
|
|
322
|
+
except Exception as err:
|
|
323
|
+
raise RuntimeError(err) from err
|
|
324
|
+
|
|
325
|
+
def decode(self, key: str) -> Any:
|
|
326
|
+
"""decode.
|
|
327
|
+
|
|
328
|
+
Decoding a Session Key using jsonpickle.
|
|
329
|
+
Args:
|
|
330
|
+
key (str): key name.
|
|
331
|
+
|
|
332
|
+
Raises:
|
|
333
|
+
RuntimeError: Error converting data from json.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
Any: object converted.
|
|
337
|
+
"""
|
|
338
|
+
try:
|
|
339
|
+
value = self._data[key]
|
|
340
|
+
return jsonpickle.decode(value)
|
|
341
|
+
except KeyError:
|
|
342
|
+
# key is missing
|
|
343
|
+
return None
|
|
344
|
+
except Exception as err:
|
|
345
|
+
raise RuntimeError(err) from err
|
|
346
|
+
|
|
347
|
+
async def save_encoded_data(self, request: web.Request, key: str, obj: Any) -> None:
|
|
348
|
+
storage = request[SESSION_STORAGE]
|
|
349
|
+
try:
|
|
350
|
+
data = jsonpickle.encode(obj)
|
|
351
|
+
except RuntimeError:
|
|
352
|
+
return False
|
|
353
|
+
self._data[key] = data
|
|
354
|
+
self._changed = False
|
|
355
|
+
await storage.save_session(request, None, self)
|
|
@@ -6,8 +6,9 @@ __description__ = (
|
|
|
6
6
|
'Navigator Session allows us to store user-specific data '
|
|
7
7
|
'into session object.'
|
|
8
8
|
)
|
|
9
|
-
__version__ = '0.
|
|
9
|
+
__version__ = '0.8.1'
|
|
10
10
|
__copyright__ = 'Copyright (c) 2023 Jesus Lara'
|
|
11
11
|
__author__ = 'Jesus Lara'
|
|
12
12
|
__author_email__ = 'jesuslarag@gmail.com'
|
|
13
13
|
__license__ = 'Apache-2.0'
|
|
14
|
+
__url__ = 'https://github.com/phenobarbital/navigator-session'
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: navigator-session
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.1
|
|
4
4
|
Summary: Navigator Session allows us to store user-specific data into session object.
|
|
5
5
|
Author-email: Jesus Lara Gimenez <jesuslarag@gmail.com>
|
|
6
|
-
License: Apache
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
7
|
Project-URL: Homepage, https://github.com/phenobarbital/navigator-session
|
|
8
8
|
Project-URL: Source, https://github.com/phenobarbital/navigator-session
|
|
9
9
|
Project-URL: Funding, https://paypal.me/phenobarbital
|
|
@@ -22,7 +22,6 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
22
22
|
Classifier: Programming Language :: Python :: 3.12
|
|
23
23
|
Classifier: Framework :: AsyncIO
|
|
24
24
|
Classifier: Framework :: aiohttp
|
|
25
|
-
Classifier: License :: OSI Approved :: Apache Software License
|
|
26
25
|
Requires-Python: >=3.9.13
|
|
27
26
|
Description-Content-Type: text/markdown
|
|
28
27
|
License-File: LICENSE
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
[build-system]
|
|
2
1
|
requires = [
|
|
3
2
|
'setuptools>=74.0.0',
|
|
4
3
|
'Cython>=3.0.11',
|
|
@@ -8,11 +7,11 @@ build-backend = "setuptools.build_meta"
|
|
|
8
7
|
|
|
9
8
|
[project]
|
|
10
9
|
name = "navigator-session"
|
|
11
|
-
|
|
10
|
+
dynamic = ["version"]
|
|
12
11
|
description = "Navigator Session allows us to store user-specific data into session object."
|
|
13
12
|
readme = "README.md"
|
|
14
13
|
requires-python = ">=3.9.13"
|
|
15
|
-
license =
|
|
14
|
+
license = "Apache-2.0"
|
|
16
15
|
authors = [
|
|
17
16
|
{ name = "Jesus Lara Gimenez", email = "jesuslarag@gmail.com" }
|
|
18
17
|
]
|
|
@@ -31,7 +30,6 @@ classifiers = [
|
|
|
31
30
|
"Programming Language :: Python :: 3.12",
|
|
32
31
|
"Framework :: AsyncIO",
|
|
33
32
|
"Framework :: aiohttp",
|
|
34
|
-
"License :: OSI Approved :: Apache Software License",
|
|
35
33
|
]
|
|
36
34
|
dependencies = [
|
|
37
35
|
"aiohttp>=3.9.5",
|
|
@@ -60,8 +58,13 @@ Source = "https://github.com/phenobarbital/navigator-session"
|
|
|
60
58
|
Funding = "https://paypal.me/phenobarbital"
|
|
61
59
|
Documentation = "https://github.com/phenobarbital/navigator-session"
|
|
62
60
|
|
|
63
|
-
[tool.setuptools]
|
|
64
|
-
|
|
61
|
+
[tool.setuptools.packages.find]
|
|
62
|
+
where = ["."]
|
|
63
|
+
include = ["navigator_session*"]
|
|
64
|
+
exclude = ["tests*", "docs*", "examples*"]
|
|
65
|
+
|
|
66
|
+
[tool.setuptools.dynamic]
|
|
67
|
+
version = {attr = "navigator_session.version.__version__"}
|
|
65
68
|
|
|
66
69
|
[tool.pytest.ini_options]
|
|
67
70
|
addopts = [
|
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Comprehensive tests for SessionData class.
|
|
3
|
+
|
|
4
|
+
Tests cover:
|
|
5
|
+
- Basic serializable data storage (_data)
|
|
6
|
+
- In-memory object storage (_objects)
|
|
7
|
+
- Automatic routing based on serializability
|
|
8
|
+
- Magic methods (__getitem__, __setitem__, __getattr__, __setattr__, etc.)
|
|
9
|
+
- Encode/decode functionality
|
|
10
|
+
- Session properties and lifecycle
|
|
11
|
+
"""
|
|
12
|
+
import pytest
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from datamodel import BaseModel
|
|
15
|
+
|
|
16
|
+
from navigator_session.data import SessionData
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# --- Test Fixtures ---
|
|
20
|
+
|
|
21
|
+
class DummyManager:
|
|
22
|
+
"""Non-serializable class for testing in-memory storage."""
|
|
23
|
+
def __init__(self, name: str = "default"):
|
|
24
|
+
self.name = name
|
|
25
|
+
self.data = {}
|
|
26
|
+
|
|
27
|
+
def add(self, key: str, value):
|
|
28
|
+
self.data[key] = value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UserModel(BaseModel):
|
|
32
|
+
"""Serializable datamodel for testing."""
|
|
33
|
+
username: str
|
|
34
|
+
email: str
|
|
35
|
+
age: int = 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture
|
|
39
|
+
def session():
|
|
40
|
+
"""Create a fresh SessionData instance."""
|
|
41
|
+
return SessionData()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.fixture
|
|
45
|
+
def session_with_data():
|
|
46
|
+
"""Create a SessionData instance with initial data."""
|
|
47
|
+
return SessionData(data={
|
|
48
|
+
'name': 'test_session',
|
|
49
|
+
'count': 42,
|
|
50
|
+
'active': True
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# --- Test Session Initialization ---
|
|
55
|
+
|
|
56
|
+
class TestSessionInitialization:
|
|
57
|
+
"""Tests for SessionData initialization."""
|
|
58
|
+
|
|
59
|
+
def test_empty_session_creation(self, session):
|
|
60
|
+
"""Test creating an empty session."""
|
|
61
|
+
assert session.empty is True
|
|
62
|
+
# new is False when created without explicit new=True and no data
|
|
63
|
+
assert len(session) == 0
|
|
64
|
+
|
|
65
|
+
def test_session_with_initial_data(self, session_with_data):
|
|
66
|
+
"""Test creating a session with initial data."""
|
|
67
|
+
assert session_with_data.empty is False
|
|
68
|
+
assert session_with_data['name'] == 'test_session'
|
|
69
|
+
assert session_with_data['count'] == 42
|
|
70
|
+
assert session_with_data['active'] is True
|
|
71
|
+
|
|
72
|
+
def test_session_id_is_generated(self, session):
|
|
73
|
+
"""Test that session_id is automatically generated."""
|
|
74
|
+
assert session.session_id is not None
|
|
75
|
+
assert len(session.session_id) > 0
|
|
76
|
+
|
|
77
|
+
def test_session_with_custom_id(self):
|
|
78
|
+
"""Test creating a session with a custom ID."""
|
|
79
|
+
custom_id = "my-custom-session-id"
|
|
80
|
+
session = SessionData(id=custom_id)
|
|
81
|
+
assert session.session_id == custom_id
|
|
82
|
+
|
|
83
|
+
def test_session_identity(self):
|
|
84
|
+
"""Test session identity assignment."""
|
|
85
|
+
identity = "user123"
|
|
86
|
+
session = SessionData(identity=identity)
|
|
87
|
+
assert session.identity == identity
|
|
88
|
+
|
|
89
|
+
def test_session_created_timestamp(self, session):
|
|
90
|
+
"""Test that created timestamp is set."""
|
|
91
|
+
assert session.created is not None
|
|
92
|
+
assert isinstance(session.created, int)
|
|
93
|
+
|
|
94
|
+
def test_session_logon_time(self, session):
|
|
95
|
+
"""Test that logon_time is a datetime."""
|
|
96
|
+
assert isinstance(session.logon_time, datetime)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# --- Test Serializable Data Storage (_data) ---
|
|
100
|
+
|
|
101
|
+
class TestSerializableDataStorage:
|
|
102
|
+
"""Tests for serializable data stored in _data."""
|
|
103
|
+
|
|
104
|
+
def test_store_string(self, session):
|
|
105
|
+
"""Test storing string values."""
|
|
106
|
+
session['name'] = 'test'
|
|
107
|
+
assert session['name'] == 'test'
|
|
108
|
+
assert 'name' in session._data
|
|
109
|
+
assert 'name' not in session._objects
|
|
110
|
+
|
|
111
|
+
def test_store_integer(self, session):
|
|
112
|
+
"""Test storing integer values."""
|
|
113
|
+
session['count'] = 100
|
|
114
|
+
assert session['count'] == 100
|
|
115
|
+
assert 'count' in session._data
|
|
116
|
+
|
|
117
|
+
def test_store_float(self, session):
|
|
118
|
+
"""Test storing float values."""
|
|
119
|
+
session['price'] = 19.99
|
|
120
|
+
assert session['price'] == 19.99
|
|
121
|
+
assert 'price' in session._data
|
|
122
|
+
|
|
123
|
+
def test_store_boolean(self, session):
|
|
124
|
+
"""Test storing boolean values."""
|
|
125
|
+
session['active'] = True
|
|
126
|
+
assert session['active'] is True
|
|
127
|
+
assert 'active' in session._data
|
|
128
|
+
|
|
129
|
+
def test_store_none(self, session):
|
|
130
|
+
"""Test storing None values."""
|
|
131
|
+
session['empty'] = None
|
|
132
|
+
assert session['empty'] is None
|
|
133
|
+
assert 'empty' in session._data
|
|
134
|
+
|
|
135
|
+
def test_store_list(self, session):
|
|
136
|
+
"""Test storing list values."""
|
|
137
|
+
session['items'] = [1, 2, 3, 'four']
|
|
138
|
+
assert session['items'] == [1, 2, 3, 'four']
|
|
139
|
+
assert 'items' in session._data
|
|
140
|
+
|
|
141
|
+
def test_store_dict(self, session):
|
|
142
|
+
"""Test storing dict values."""
|
|
143
|
+
session['config'] = {'key': 'value', 'nested': {'a': 1}}
|
|
144
|
+
assert session['config'] == {'key': 'value', 'nested': {'a': 1}}
|
|
145
|
+
assert 'config' in session._data
|
|
146
|
+
|
|
147
|
+
def test_store_datetime(self, session):
|
|
148
|
+
"""Test storing datetime values."""
|
|
149
|
+
now = datetime.now(timezone.utc)
|
|
150
|
+
session['timestamp'] = now
|
|
151
|
+
assert session['timestamp'] == now
|
|
152
|
+
assert 'timestamp' in session._data
|
|
153
|
+
|
|
154
|
+
def test_store_datamodel(self, session):
|
|
155
|
+
"""Test storing datamodel BaseModel instances (serializable)."""
|
|
156
|
+
user = UserModel(username='john', email='john@example.com', age=30)
|
|
157
|
+
session['user'] = user
|
|
158
|
+
assert session['user'] == user
|
|
159
|
+
assert 'user' in session._data
|
|
160
|
+
assert 'user' not in session._objects
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# --- Test In-Memory Object Storage (_objects) ---
|
|
164
|
+
|
|
165
|
+
class TestInMemoryObjectStorage:
|
|
166
|
+
"""Tests for non-serializable objects stored in _objects."""
|
|
167
|
+
|
|
168
|
+
def test_store_class_instance(self, session):
|
|
169
|
+
"""Test storing arbitrary class instances in _objects."""
|
|
170
|
+
manager = DummyManager(name='test_manager')
|
|
171
|
+
session['manager'] = manager
|
|
172
|
+
assert session['manager'] is manager
|
|
173
|
+
assert 'manager' in session._objects
|
|
174
|
+
assert 'manager' not in session._data
|
|
175
|
+
|
|
176
|
+
def test_retrieve_same_instance(self, session):
|
|
177
|
+
"""Test that retrieved object is the exact same instance."""
|
|
178
|
+
manager = DummyManager()
|
|
179
|
+
manager.add('key1', 'value1')
|
|
180
|
+
session['dm'] = manager
|
|
181
|
+
|
|
182
|
+
retrieved = session['dm']
|
|
183
|
+
assert retrieved is manager
|
|
184
|
+
assert retrieved.data == {'key1': 'value1'}
|
|
185
|
+
|
|
186
|
+
def test_modify_retrieved_object(self, session):
|
|
187
|
+
"""Test that modifications to retrieved object persist."""
|
|
188
|
+
manager = DummyManager()
|
|
189
|
+
session['dm'] = manager
|
|
190
|
+
|
|
191
|
+
# Modify via retrieved reference
|
|
192
|
+
session['dm'].add('test', 123)
|
|
193
|
+
|
|
194
|
+
# Verify modification persists
|
|
195
|
+
assert session['dm'].data == {'test': 123}
|
|
196
|
+
|
|
197
|
+
def test_in_memory_not_in_session_data(self, session):
|
|
198
|
+
"""Test that in-memory objects don't appear in session_data()."""
|
|
199
|
+
session['name'] = 'test' # Serializable
|
|
200
|
+
session['manager'] = DummyManager() # In-memory
|
|
201
|
+
|
|
202
|
+
data = session.session_data()
|
|
203
|
+
assert 'name' in data
|
|
204
|
+
assert 'manager' not in data
|
|
205
|
+
|
|
206
|
+
def test_in_memory_in_session_objects(self, session):
|
|
207
|
+
"""Test that in-memory objects appear in session_objects()."""
|
|
208
|
+
session['manager'] = DummyManager()
|
|
209
|
+
|
|
210
|
+
objects = session.session_objects()
|
|
211
|
+
assert 'manager' in objects
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# --- Test Attribute-Style Access ---
|
|
215
|
+
|
|
216
|
+
class TestAttributeStyleAccess:
|
|
217
|
+
"""Tests for attribute-style access (session.key)."""
|
|
218
|
+
|
|
219
|
+
def test_set_serializable_via_attribute(self, session):
|
|
220
|
+
"""Test setting serializable values via attribute."""
|
|
221
|
+
session.username = 'alice'
|
|
222
|
+
assert session.username == 'alice'
|
|
223
|
+
assert 'username' in session._data
|
|
224
|
+
|
|
225
|
+
def test_set_object_via_attribute(self, session):
|
|
226
|
+
"""Test setting objects via attribute."""
|
|
227
|
+
manager = DummyManager()
|
|
228
|
+
session.dm = manager
|
|
229
|
+
assert session.dm is manager
|
|
230
|
+
assert 'dm' in session._objects
|
|
231
|
+
|
|
232
|
+
def test_get_via_attribute(self, session):
|
|
233
|
+
"""Test getting values via attribute."""
|
|
234
|
+
session['name'] = 'test'
|
|
235
|
+
assert session.name == 'test'
|
|
236
|
+
|
|
237
|
+
def test_attribute_error_for_missing(self, session):
|
|
238
|
+
"""Test AttributeError for missing keys."""
|
|
239
|
+
with pytest.raises(AttributeError):
|
|
240
|
+
_ = session.nonexistent
|
|
241
|
+
|
|
242
|
+
def test_internal_attributes_work(self, session):
|
|
243
|
+
"""Test that internal attributes are not routed to storage."""
|
|
244
|
+
# These are internal and should work normally
|
|
245
|
+
assert hasattr(session, '_data')
|
|
246
|
+
assert hasattr(session, '_objects')
|
|
247
|
+
assert hasattr(session, '_changed')
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# --- Test Magic Methods ---
|
|
251
|
+
|
|
252
|
+
class TestMagicMethods:
|
|
253
|
+
"""Tests for dict-like magic methods."""
|
|
254
|
+
|
|
255
|
+
def test_getitem(self, session):
|
|
256
|
+
"""Test __getitem__ for both storage types."""
|
|
257
|
+
session['serializable'] = 'value'
|
|
258
|
+
session['object'] = DummyManager()
|
|
259
|
+
|
|
260
|
+
assert session['serializable'] == 'value'
|
|
261
|
+
assert isinstance(session['object'], DummyManager)
|
|
262
|
+
|
|
263
|
+
def test_getitem_keyerror(self, session):
|
|
264
|
+
"""Test __getitem__ raises KeyError for missing keys."""
|
|
265
|
+
with pytest.raises(KeyError):
|
|
266
|
+
_ = session['nonexistent']
|
|
267
|
+
|
|
268
|
+
def test_setitem(self, session):
|
|
269
|
+
"""Test __setitem__ routes correctly."""
|
|
270
|
+
session['str'] = 'hello'
|
|
271
|
+
session['obj'] = DummyManager()
|
|
272
|
+
|
|
273
|
+
assert 'str' in session._data
|
|
274
|
+
assert 'obj' in session._objects
|
|
275
|
+
|
|
276
|
+
def test_delitem_from_data(self, session):
|
|
277
|
+
"""Test __delitem__ for serializable data."""
|
|
278
|
+
session['name'] = 'test'
|
|
279
|
+
del session['name']
|
|
280
|
+
assert 'name' not in session
|
|
281
|
+
|
|
282
|
+
def test_delitem_from_objects(self, session):
|
|
283
|
+
"""Test __delitem__ for in-memory objects."""
|
|
284
|
+
session['dm'] = DummyManager()
|
|
285
|
+
del session['dm']
|
|
286
|
+
assert 'dm' not in session
|
|
287
|
+
|
|
288
|
+
def test_delitem_keyerror(self, session):
|
|
289
|
+
"""Test __delitem__ raises KeyError for missing keys."""
|
|
290
|
+
with pytest.raises(KeyError):
|
|
291
|
+
del session['nonexistent']
|
|
292
|
+
|
|
293
|
+
def test_contains(self, session):
|
|
294
|
+
"""Test __contains__ checks both storages."""
|
|
295
|
+
session['data'] = 'value'
|
|
296
|
+
session['obj'] = DummyManager()
|
|
297
|
+
|
|
298
|
+
assert 'data' in session
|
|
299
|
+
assert 'obj' in session
|
|
300
|
+
assert 'missing' not in session
|
|
301
|
+
|
|
302
|
+
def test_len_includes_both(self, session):
|
|
303
|
+
"""Test __len__ counts both storages."""
|
|
304
|
+
session['a'] = 1
|
|
305
|
+
session['b'] = 2
|
|
306
|
+
session['c'] = DummyManager()
|
|
307
|
+
|
|
308
|
+
assert len(session) == 3
|
|
309
|
+
|
|
310
|
+
def test_iter_includes_both(self, session):
|
|
311
|
+
"""Test __iter__ yields keys from both storages."""
|
|
312
|
+
session['a'] = 1
|
|
313
|
+
session['b'] = DummyManager()
|
|
314
|
+
session['c'] = 'three'
|
|
315
|
+
|
|
316
|
+
keys = list(session)
|
|
317
|
+
assert set(keys) == {'a', 'b', 'c'}
|
|
318
|
+
|
|
319
|
+
def test_get_method(self, session):
|
|
320
|
+
"""Test dict.get() method with default."""
|
|
321
|
+
session['exists'] = 'value'
|
|
322
|
+
|
|
323
|
+
assert session.get('exists') == 'value'
|
|
324
|
+
assert session.get('missing') is None
|
|
325
|
+
assert session.get('missing', 'default') == 'default'
|
|
326
|
+
|
|
327
|
+
def test_keys_method(self, session):
|
|
328
|
+
"""Test keys() includes both storages."""
|
|
329
|
+
session['a'] = 1
|
|
330
|
+
session['b'] = DummyManager()
|
|
331
|
+
|
|
332
|
+
keys = list(session.keys())
|
|
333
|
+
assert set(keys) == {'a', 'b'}
|
|
334
|
+
|
|
335
|
+
def test_values_method(self, session):
|
|
336
|
+
"""Test values() includes both storages."""
|
|
337
|
+
session['a'] = 1
|
|
338
|
+
manager = DummyManager()
|
|
339
|
+
session['b'] = manager
|
|
340
|
+
|
|
341
|
+
values = list(session.values())
|
|
342
|
+
assert 1 in values
|
|
343
|
+
assert manager in values
|
|
344
|
+
|
|
345
|
+
def test_items_method(self, session):
|
|
346
|
+
"""Test items() includes both storages."""
|
|
347
|
+
manager = DummyManager()
|
|
348
|
+
session['a'] = 1
|
|
349
|
+
session['b'] = manager
|
|
350
|
+
|
|
351
|
+
items = dict(session.items())
|
|
352
|
+
assert items == {'a': 1, 'b': manager}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# --- Test Session State ---
|
|
356
|
+
|
|
357
|
+
class TestSessionState:
|
|
358
|
+
"""Tests for session state management."""
|
|
359
|
+
|
|
360
|
+
def test_changed_flag_on_serializable_set(self, session):
|
|
361
|
+
"""Test that _changed is set when adding serializable data."""
|
|
362
|
+
assert session.is_changed is False
|
|
363
|
+
session['name'] = 'test'
|
|
364
|
+
assert session.is_changed is True
|
|
365
|
+
|
|
366
|
+
def test_changed_flag_not_set_on_object(self, session):
|
|
367
|
+
"""Test that _changed is NOT set for in-memory objects."""
|
|
368
|
+
assert session.is_changed is False
|
|
369
|
+
session['dm'] = DummyManager()
|
|
370
|
+
assert session.is_changed is False
|
|
371
|
+
|
|
372
|
+
def test_changed_flag_on_delete(self, session):
|
|
373
|
+
"""Test that _changed is set when deleting from _data."""
|
|
374
|
+
session['name'] = 'test'
|
|
375
|
+
session.is_changed = False
|
|
376
|
+
|
|
377
|
+
del session['name']
|
|
378
|
+
assert session.is_changed is True
|
|
379
|
+
|
|
380
|
+
def test_invalidate_clears_both(self, session):
|
|
381
|
+
"""Test invalidate() clears both _data and _objects."""
|
|
382
|
+
session['name'] = 'test'
|
|
383
|
+
session['dm'] = DummyManager()
|
|
384
|
+
|
|
385
|
+
session.invalidate()
|
|
386
|
+
|
|
387
|
+
assert session.empty is True
|
|
388
|
+
assert len(session._data) == 0
|
|
389
|
+
assert len(session._objects) == 0
|
|
390
|
+
assert session.is_changed is True
|
|
391
|
+
|
|
392
|
+
def test_empty_property(self, session):
|
|
393
|
+
"""Test empty property considers both storages."""
|
|
394
|
+
assert session.empty is True
|
|
395
|
+
|
|
396
|
+
session['dm'] = DummyManager()
|
|
397
|
+
assert session.empty is False
|
|
398
|
+
|
|
399
|
+
del session['dm']
|
|
400
|
+
assert session.empty is True
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# --- Test Encode/Decode ---
|
|
404
|
+
|
|
405
|
+
class TestEncodeDecode:
|
|
406
|
+
"""Tests for encode/decode functionality."""
|
|
407
|
+
|
|
408
|
+
def test_encode_simple_object(self, session):
|
|
409
|
+
"""Test encoding a simple object."""
|
|
410
|
+
data = {'name': 'test', 'count': 42}
|
|
411
|
+
encoded = session.encode(data)
|
|
412
|
+
|
|
413
|
+
assert isinstance(encoded, str)
|
|
414
|
+
assert 'name' in encoded
|
|
415
|
+
assert 'test' in encoded
|
|
416
|
+
|
|
417
|
+
def test_encode_datamodel(self, session):
|
|
418
|
+
"""Test encoding a datamodel object."""
|
|
419
|
+
user = UserModel(username='alice', email='alice@example.com')
|
|
420
|
+
encoded = session.encode(user)
|
|
421
|
+
|
|
422
|
+
assert isinstance(encoded, str)
|
|
423
|
+
assert 'alice' in encoded
|
|
424
|
+
|
|
425
|
+
def test_decode_from_data(self, session):
|
|
426
|
+
"""Test decoding a stored encoded value."""
|
|
427
|
+
user = UserModel(username='bob', email='bob@example.com', age=25)
|
|
428
|
+
session._data['user'] = session.encode(user)
|
|
429
|
+
|
|
430
|
+
decoded = session.decode('user')
|
|
431
|
+
|
|
432
|
+
assert isinstance(decoded, UserModel)
|
|
433
|
+
assert decoded.username == 'bob'
|
|
434
|
+
assert decoded.email == 'bob@example.com'
|
|
435
|
+
assert decoded.age == 25
|
|
436
|
+
|
|
437
|
+
def test_decode_missing_key(self, session):
|
|
438
|
+
"""Test decode returns None for missing keys."""
|
|
439
|
+
result = session.decode('nonexistent')
|
|
440
|
+
assert result is None
|
|
441
|
+
|
|
442
|
+
def test_encode_decode_roundtrip(self, session):
|
|
443
|
+
"""Test encode/decode roundtrip."""
|
|
444
|
+
original = {'items': [1, 2, 3], 'nested': {'a': 'b'}}
|
|
445
|
+
encoded = session.encode(original)
|
|
446
|
+
session._data['test'] = encoded
|
|
447
|
+
decoded = session.decode('test')
|
|
448
|
+
|
|
449
|
+
assert decoded == original
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# --- Test Value Routing ---
|
|
453
|
+
|
|
454
|
+
class TestValueRouting:
|
|
455
|
+
"""Tests for automatic routing between _data and _objects."""
|
|
456
|
+
|
|
457
|
+
def test_reassign_serializable_to_object(self, session):
|
|
458
|
+
"""Test reassigning from serializable to object moves storage."""
|
|
459
|
+
session['key'] = 'serializable'
|
|
460
|
+
assert 'key' in session._data
|
|
461
|
+
assert 'key' not in session._objects
|
|
462
|
+
|
|
463
|
+
session['key'] = DummyManager()
|
|
464
|
+
assert 'key' not in session._data
|
|
465
|
+
assert 'key' in session._objects
|
|
466
|
+
|
|
467
|
+
def test_reassign_object_to_serializable(self, session):
|
|
468
|
+
"""Test reassigning from object to serializable moves storage."""
|
|
469
|
+
session['key'] = DummyManager()
|
|
470
|
+
assert 'key' in session._objects
|
|
471
|
+
assert 'key' not in session._data
|
|
472
|
+
|
|
473
|
+
session['key'] = 'serializable'
|
|
474
|
+
assert 'key' in session._data
|
|
475
|
+
assert 'key' not in session._objects
|
|
476
|
+
|
|
477
|
+
def test_nested_dict_with_primitives_is_serializable(self, session):
|
|
478
|
+
"""Test nested dict with only primitives goes to _data."""
|
|
479
|
+
session['config'] = {
|
|
480
|
+
'level1': {
|
|
481
|
+
'level2': {
|
|
482
|
+
'value': 123
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
assert 'config' in session._data
|
|
487
|
+
|
|
488
|
+
def test_list_of_objects_goes_to_objects(self, session):
|
|
489
|
+
"""Test list containing class instances goes to _objects."""
|
|
490
|
+
session['managers'] = [DummyManager(), DummyManager()]
|
|
491
|
+
assert 'managers' in session._objects
|
|
492
|
+
assert 'managers' not in session._data
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
# --- Test Session Properties ---
|
|
496
|
+
|
|
497
|
+
class TestSessionProperties:
|
|
498
|
+
"""Tests for session property accessors."""
|
|
499
|
+
|
|
500
|
+
def test_dow_property(self, session):
|
|
501
|
+
"""Test day of week property."""
|
|
502
|
+
assert isinstance(session.dow, int)
|
|
503
|
+
assert 0 <= session.dow <= 6
|
|
504
|
+
|
|
505
|
+
def test_session_time_property(self, session):
|
|
506
|
+
"""Test session_time property."""
|
|
507
|
+
from datetime import time as time_type
|
|
508
|
+
assert isinstance(session.session_time, time_type)
|
|
509
|
+
|
|
510
|
+
def test_max_age_property(self, session):
|
|
511
|
+
"""Test max_age getter and setter."""
|
|
512
|
+
assert session.max_age is None
|
|
513
|
+
|
|
514
|
+
# max_age must be set via private attribute since it's a property
|
|
515
|
+
session._max_age = 3600
|
|
516
|
+
assert session.max_age == 3600
|
|
517
|
+
|
|
518
|
+
def test_session_with_max_age(self):
|
|
519
|
+
"""Test session created with max_age."""
|
|
520
|
+
session = SessionData(max_age=3600)
|
|
521
|
+
assert session.max_age == 3600
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
# --- Test repr ---
|
|
525
|
+
|
|
526
|
+
class TestRepr:
|
|
527
|
+
"""Tests for string representation."""
|
|
528
|
+
|
|
529
|
+
def test_repr_empty_session(self, session):
|
|
530
|
+
"""Test repr of empty session."""
|
|
531
|
+
repr_str = repr(session)
|
|
532
|
+
assert 'NAV-Session' in repr_str
|
|
533
|
+
assert 'new:' in repr_str # Could be True or False depending on init
|
|
534
|
+
|
|
535
|
+
def test_repr_with_data(self, session):
|
|
536
|
+
"""Test repr includes data and objects info."""
|
|
537
|
+
session['name'] = 'test'
|
|
538
|
+
session['dm'] = DummyManager()
|
|
539
|
+
|
|
540
|
+
repr_str = repr(session)
|
|
541
|
+
assert 'name' in repr_str
|
|
542
|
+
assert 'dm' in repr_str
|
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
import uuid
|
|
2
|
-
import time
|
|
3
|
-
from typing import Union, Optional, Any
|
|
4
|
-
from datetime import datetime, timezone
|
|
5
|
-
from collections.abc import Iterator, Mapping, MutableMapping
|
|
6
|
-
import jsonpickle
|
|
7
|
-
from jsonpickle.unpickler import loadclass
|
|
8
|
-
from aiohttp import web
|
|
9
|
-
from datamodel import BaseModel
|
|
10
|
-
from .conf import (
|
|
11
|
-
SESSION_KEY,
|
|
12
|
-
SESSION_ID,
|
|
13
|
-
SESSION_STORAGE
|
|
14
|
-
)
|
|
15
|
-
|
|
16
|
-
class ModelHandler(jsonpickle.handlers.BaseHandler):
|
|
17
|
-
"""ModelHandler.
|
|
18
|
-
This class can handle with serializable Data Models.
|
|
19
|
-
"""
|
|
20
|
-
def flatten(self, obj, data):
|
|
21
|
-
data['__dict__'] = self.context.flatten(obj.__dict__, reset=False)
|
|
22
|
-
return data
|
|
23
|
-
|
|
24
|
-
def restore(self, obj):
|
|
25
|
-
module_and_type = obj['py/object']
|
|
26
|
-
mdl = loadclass(module_and_type)
|
|
27
|
-
cls = mdl.__new__(mdl) if hasattr(mdl, '__new__') else object.__new__(mdl)
|
|
28
|
-
cls.__dict__ = self.context.restore(obj['__dict__'], reset=False)
|
|
29
|
-
return cls
|
|
30
|
-
|
|
31
|
-
jsonpickle.handlers.registry.register(BaseModel, ModelHandler, base=True)
|
|
32
|
-
|
|
33
|
-
class SessionData(MutableMapping[str, Any]):
|
|
34
|
-
"""Session dict-like object.
|
|
35
|
-
"""
|
|
36
|
-
|
|
37
|
-
_data: Union[str, Any] = {}
|
|
38
|
-
|
|
39
|
-
def __init__(
|
|
40
|
-
self,
|
|
41
|
-
*args,
|
|
42
|
-
data: Optional[Mapping[str, Any]] = None,
|
|
43
|
-
new: bool = False,
|
|
44
|
-
id: Optional[str] = None,
|
|
45
|
-
identity: Optional[Any] = None,
|
|
46
|
-
max_age: Optional[int] = None
|
|
47
|
-
) -> None:
|
|
48
|
-
self._changed = False
|
|
49
|
-
self._data = {}
|
|
50
|
-
# Unique ID:
|
|
51
|
-
self._id_ = (data.get(SESSION_ID, None) if data else id) or uuid.uuid4().hex
|
|
52
|
-
# Session Identity
|
|
53
|
-
self._identity = (
|
|
54
|
-
data.get(SESSION_KEY, None) if data else identity
|
|
55
|
-
) or self._id_
|
|
56
|
-
self._new = new if data != {} else True
|
|
57
|
-
self._max_age = max_age or None
|
|
58
|
-
created = data.get('created', None) if data else None
|
|
59
|
-
self._now = datetime.now(timezone.utc)
|
|
60
|
-
self.__created__ = self._now
|
|
61
|
-
now = int(self._now.timestamp())
|
|
62
|
-
self._now = now # time for this instance creation
|
|
63
|
-
age = now - created if created else now
|
|
64
|
-
if max_age is not None and age > max_age:
|
|
65
|
-
data = None
|
|
66
|
-
self._created = now if self._new or created is None else created
|
|
67
|
-
## Data updating.
|
|
68
|
-
if data is not None:
|
|
69
|
-
self._data.update(data)
|
|
70
|
-
# Other mark timestamp for this session:
|
|
71
|
-
dt = datetime.now(timezone.utc)
|
|
72
|
-
self._dow = dt.weekday()
|
|
73
|
-
self._doy = dt.timetuple().tm_yday
|
|
74
|
-
self._time = dt.time()
|
|
75
|
-
self.args = args
|
|
76
|
-
|
|
77
|
-
def __repr__(self) -> str:
|
|
78
|
-
return f'<NAV-Session [new:{self.new}, created:{self.created}] {self._data!r}>'
|
|
79
|
-
|
|
80
|
-
@property
|
|
81
|
-
def new(self) -> bool:
|
|
82
|
-
return self._new
|
|
83
|
-
|
|
84
|
-
@property
|
|
85
|
-
def logon_time(self) -> datetime:
|
|
86
|
-
return self.__created__
|
|
87
|
-
|
|
88
|
-
@property
|
|
89
|
-
def session_id(self) -> str:
|
|
90
|
-
return self._id_
|
|
91
|
-
|
|
92
|
-
@property
|
|
93
|
-
def identity(self) -> Optional[Any]: # type: ignore[misc]
|
|
94
|
-
return self._identity
|
|
95
|
-
|
|
96
|
-
@property
|
|
97
|
-
def created(self) -> int:
|
|
98
|
-
return self._created
|
|
99
|
-
|
|
100
|
-
@property
|
|
101
|
-
def dow(self) -> int:
|
|
102
|
-
return self._dow
|
|
103
|
-
|
|
104
|
-
@property
|
|
105
|
-
def session_time(self) -> time:
|
|
106
|
-
return self._time
|
|
107
|
-
|
|
108
|
-
@property
|
|
109
|
-
def empty(self) -> bool:
|
|
110
|
-
return not bool(self._data)
|
|
111
|
-
|
|
112
|
-
@property
|
|
113
|
-
def max_age(self) -> Optional[int]:
|
|
114
|
-
return self._max_age
|
|
115
|
-
|
|
116
|
-
@max_age.setter
|
|
117
|
-
def max_age(self, value: Optional[int]) -> None:
|
|
118
|
-
self._max_age = value
|
|
119
|
-
|
|
120
|
-
@property
|
|
121
|
-
def is_changed(self) -> bool:
|
|
122
|
-
return self._changed
|
|
123
|
-
|
|
124
|
-
@is_changed.setter
|
|
125
|
-
def is_changed(self, value: bool) -> None:
|
|
126
|
-
self._changed = value
|
|
127
|
-
|
|
128
|
-
def changed(self) -> None:
|
|
129
|
-
self._changed = True
|
|
130
|
-
|
|
131
|
-
def session_data(self) -> dict:
|
|
132
|
-
return self._data
|
|
133
|
-
|
|
134
|
-
def invalidate(self) -> None:
|
|
135
|
-
self._changed = True
|
|
136
|
-
self._data = {}
|
|
137
|
-
|
|
138
|
-
# Magic Methods
|
|
139
|
-
def __len__(self) -> int:
|
|
140
|
-
return len(self._data)
|
|
141
|
-
|
|
142
|
-
def __iter__(self) -> Iterator[str]:
|
|
143
|
-
return iter(self._data)
|
|
144
|
-
|
|
145
|
-
def __contains__(self, key: object) -> bool:
|
|
146
|
-
return key in self._data
|
|
147
|
-
|
|
148
|
-
def __getitem__(self, key: str) -> Any:
|
|
149
|
-
return self._data[key]
|
|
150
|
-
|
|
151
|
-
def __setitem__(self, key: str, value: Any) -> None:
|
|
152
|
-
self._data[key] = value
|
|
153
|
-
self._changed = True
|
|
154
|
-
# TODO: also, saved into redis automatically
|
|
155
|
-
|
|
156
|
-
def __delitem__(self, key: str) -> None:
|
|
157
|
-
del self._data[key]
|
|
158
|
-
self._changed = True
|
|
159
|
-
|
|
160
|
-
def __getattr__(self, key: str) -> Any:
|
|
161
|
-
return self._data[key]
|
|
162
|
-
|
|
163
|
-
def encode(self, obj: Any) -> str:
|
|
164
|
-
"""encode
|
|
165
|
-
|
|
166
|
-
Encode an object using jsonpickle.
|
|
167
|
-
Args:
|
|
168
|
-
obj (Any): Object to be encoded using jsonpickle
|
|
169
|
-
|
|
170
|
-
Raises:
|
|
171
|
-
RuntimeError: Error converting data to json.
|
|
172
|
-
|
|
173
|
-
Returns:
|
|
174
|
-
str: json version of the data
|
|
175
|
-
"""
|
|
176
|
-
try:
|
|
177
|
-
return jsonpickle.encode(obj)
|
|
178
|
-
except Exception as err:
|
|
179
|
-
raise RuntimeError(err) from err
|
|
180
|
-
|
|
181
|
-
def decode(self, key: str) -> Any:
|
|
182
|
-
"""decode.
|
|
183
|
-
|
|
184
|
-
Decoding a Session Key using jsonpickle.
|
|
185
|
-
Args:
|
|
186
|
-
key (str): key name.
|
|
187
|
-
|
|
188
|
-
Raises:
|
|
189
|
-
RuntimeError: Error converting data from json.
|
|
190
|
-
|
|
191
|
-
Returns:
|
|
192
|
-
Any: object converted.
|
|
193
|
-
"""
|
|
194
|
-
try:
|
|
195
|
-
value = self._data[key]
|
|
196
|
-
return jsonpickle.decode(value)
|
|
197
|
-
except KeyError:
|
|
198
|
-
# key is missing
|
|
199
|
-
return None
|
|
200
|
-
except Exception as err:
|
|
201
|
-
raise RuntimeError(err) from err
|
|
202
|
-
|
|
203
|
-
async def save_encoded_data(self, request: web.Request, key: str, obj: Any) -> None:
|
|
204
|
-
storage = request[SESSION_STORAGE]
|
|
205
|
-
try:
|
|
206
|
-
data = jsonpickle.encode(obj)
|
|
207
|
-
except RuntimeError:
|
|
208
|
-
return False
|
|
209
|
-
self._data[key] = data
|
|
210
|
-
self._changed = False
|
|
211
|
-
await storage.save_session(request, None, self)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
{navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|