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.
Files changed (36) hide show
  1. {navigator_session-0.7.0 → navigator_session-0.8.1}/PKG-INFO +2 -3
  2. navigator_session-0.8.1/navigator_session/data.py +355 -0
  3. {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session/version.py +2 -1
  4. {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session.egg-info/PKG-INFO +2 -3
  5. {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session.egg-info/SOURCES.txt +2 -1
  6. {navigator_session-0.7.0 → navigator_session-0.8.1}/pyproject.toml +9 -6
  7. navigator_session-0.8.1/tests/test_session_data.py +542 -0
  8. navigator_session-0.7.0/navigator_session/data.py +0 -211
  9. {navigator_session-0.7.0 → navigator_session-0.8.1}/CHANGES.rst +0 -0
  10. {navigator_session-0.7.0 → navigator_session-0.8.1}/LICENSE +0 -0
  11. {navigator_session-0.7.0 → navigator_session-0.8.1}/MANIFEST.in +0 -0
  12. {navigator_session-0.7.0 → navigator_session-0.8.1}/Makefile +0 -0
  13. {navigator_session-0.7.0 → navigator_session-0.8.1}/README.md +0 -0
  14. {navigator_session-0.7.0 → navigator_session-0.8.1}/docs/Makefile +0 -0
  15. {navigator_session-0.7.0 → navigator_session-0.8.1}/docs/api.rst +0 -0
  16. {navigator_session-0.7.0 → navigator_session-0.8.1}/docs/authors.rst +0 -0
  17. {navigator_session-0.7.0 → navigator_session-0.8.1}/docs/conf.py +0 -0
  18. {navigator_session-0.7.0 → navigator_session-0.8.1}/docs/examples.rst +0 -0
  19. {navigator_session-0.7.0 → navigator_session-0.8.1}/docs/index.rst +0 -0
  20. {navigator_session-0.7.0 → navigator_session-0.8.1}/docs/make.bat +0 -0
  21. {navigator_session-0.7.0 → navigator_session-0.8.1}/docs/requirements-dev.txt +0 -0
  22. {navigator_session-0.7.0 → navigator_session-0.8.1}/docs/requirements.txt +0 -0
  23. {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session/__init__.py +0 -0
  24. {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session/conf.py +0 -0
  25. {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session/middleware.py +0 -0
  26. {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session/session.py +0 -0
  27. {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session/storages/__init__.py +0 -0
  28. {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session/storages/abstract.py +0 -0
  29. {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session/storages/cookie.py +0 -0
  30. {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session/storages/redis.py +0 -0
  31. {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session.egg-info/dependency_links.txt +0 -0
  32. {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session.egg-info/requires.txt +0 -0
  33. {navigator_session-0.7.0 → navigator_session-0.8.1}/navigator_session.egg-info/top_level.txt +0 -0
  34. {navigator_session-0.7.0 → navigator_session-0.8.1}/setup.cfg +0 -0
  35. {navigator_session-0.7.0 → navigator_session-0.8.1}/setup.py +0 -0
  36. {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.7.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 2.0
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.7.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.7.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 2.0
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
@@ -29,4 +29,5 @@ navigator_session/storages/__init__.py
29
29
  navigator_session/storages/abstract.py
30
30
  navigator_session/storages/cookie.py
31
31
  navigator_session/storages/redis.py
32
- tests/__init__.py
32
+ tests/__init__.py
33
+ tests/test_session_data.py
@@ -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
- version = "0.7.0"
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 = { text = "Apache 2.0" }
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
- packages = ["navigator_session"]
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)