Download Install Tutorial Docs FAQ Tools WikiLicense Team IRC Planet Involvement Shop Book

root/branches/cherrypy-3.0.x/cherrypy/lib/sessions.py

Revision 1838 (checked in by fumanchu, 8 months ago)

3.0/3.1 fix for #760 (Session tool cleanup frequency).

  • Property svn:eol-style set to native
Line 
1 """Session implementation for CherryPy.
2
3 We use cherrypy.request to store some convenient variables as
4 well as data about the session for the current request. Instead of
5 polluting cherrypy.request we use a Session object bound to
6 cherrypy.session to store these variables.
7 """
8
9 import datetime
10 import os
11 try:
12     import cPickle as pickle
13 except ImportError:
14     import pickle
15 import random
16 import sha
17 import time
18 import threading
19 import types
20 from warnings import warn
21
22 import cherrypy
23 from cherrypy.lib import http
24
25
26 class PerpetualTimer(threading._Timer):
27    
28     def run(self):
29         while True:
30             self.finished.wait(self.interval)
31             if self.finished.isSet():
32                 return
33             self.function(*self.args, **self.kwargs)
34
35
36 missing = object()
37
38 class Session(object):
39     """A CherryPy dict-like Session object (one per request)."""
40    
41     __metaclass__ = cherrypy._AttributeDocstrings
42    
43     id = None
44     id__doc = "The current session ID."
45    
46     timeout = 60
47     timeout__doc = "Number of minutes after which to delete session data."
48    
49     locked = False
50     locked__doc = """
51     If True, this session instance has exclusive read/write access
52     to session data."""
53    
54     loaded = False
55     loaded__doc = """
56     If True, data has been retrieved from storage. This should happen
57     automatically on the first attempt to access session data."""
58    
59     clean_thread = None
60     clean_thread__doc = "Class-level PerpetualTimer which calls self.clean_up."
61    
62     clean_freq = 5
63     clean_freq__doc = "The poll rate for expired session cleanup in minutes."
64    
65     def __init__(self, id=None, **kwargs):
66         self._data = {}
67        
68         for k, v in kwargs.iteritems():
69             setattr(self, k, v)
70        
71         self.id = id
72         while self.id is None:
73             self.id = self.generate_id()
74             # Assert that the generated id is not already stored.
75             if self._load() is not None:
76                 self.id = None
77    
78     def clean_interrupt(cls):
79         """Stop the expired-session cleaning timer."""
80         if cls.clean_thread:
81             cls.clean_thread.cancel()
82             cls.clean_thread.join()
83             cls.clean_thread = None
84     clean_interrupt = classmethod(clean_interrupt)
85    
86     def clean_up(self):
87         """Clean up expired sessions."""
88         pass
89    
90     try:
91         os.urandom(20)
92     except (AttributeError, NotImplementedError):
93         # os.urandom not available until Python 2.4. Fall back to random.random.
94         def generate_id(self):
95             """Return a new session id."""
96             return sha.new('%s' % random.random()).hexdigest()
97     else:
98         def generate_id(self):
99             """Return a new session id."""
100             return os.urandom(20).encode('hex')
101    
102     def save(self):
103         """Save session data."""
104         try:
105             # If session data has never been loaded then it's never been
106             #   accessed: no need to delete it
107             if self.loaded:
108                 t = datetime.timedelta(seconds = self.timeout * 60)
109                 expiration_time = datetime.datetime.now() + t
110                 self._save(expiration_time)
111            
112         finally:
113             if self.locked:
114                 # Always release the lock if the user didn't release it
115                 self.release_lock()
116    
117     def load(self):
118         """Copy stored session data into this session instance."""
119         data = self._load()
120         # data is either None or a tuple (session_data, expiration_time)
121         if data is None or data[1] < datetime.datetime.now():
122             # Expired session: flush session data (but keep the same id)
123             self._data = {}
124         else:
125             self._data = data[0]
126         self.loaded = True
127        
128         # Stick the clean_thread in the class, not the instance.
129         # The instances are created and destroyed per-request.
130         cls = self.__class__
131         if not cls.clean_thread:
132             cherrypy.engine.on_stop_engine_list.append(cls.clean_interrupt)
133             # clean_up is in instancemethod and not a classmethod,
134             # so tool config can be accessed inside the method.
135             t = PerpetualTimer(self.clean_freq * 60, self.clean_up)
136             t.setName("CP Session Cleanup")
137             cls.clean_thread = t
138             t.start()
139    
140     def delete(self):
141         """Delete stored session data."""
142         self._delete()
143    
144     def __getitem__(self, key):
145         if not self.loaded: self.load()
146         return self._data[key]
147    
148     def __setitem__(self, key, value):
149         if not self.loaded: self.load()
150         self._data[key] = value
151    
152     def __delitem__(self, key):
153         if not self.loaded: self.load()
154         del self._data[key]
155    
156     def pop(self, key, default=missing):
157         if not self.loaded: self.load()
158         if default is missing:
159             return self._data.pop(key)
160         else:
161             return self._data.pop(key, default)
162    
163     def __contains__(self, key):
164         if not self.loaded: self.load()
165         return key in self._data
166    
167     def has_key(self, key):
168         if not self.loaded: self.load()
169         return self._data.has_key(key)
170    
171     def get(self, key, default=None):
172         if not self.loaded: self.load()
173         return self._data.get(key, default)
174    
175     def update(self, d):
176         if not self.loaded: self.load()
177         self._data.update(d)
178    
179     def setdefault(self, key, default=None):
180         if not self.loaded: self.load()
181         return self._data.setdefault(key, default)
182    
183     def clear(self):
184         if not self.loaded: self.load()
185         self._data.clear()
186    
187     def keys(self):
188         if not self.loaded: self.load()
189         return self._data.keys()
190    
191     def items(self):
192         if not self.loaded: self.load()
193         return self._data.items()
194    
195     def values(self):
196         if not self.loaded: self.load()
197         return self._data.values()
198
199
200 class RamSession(Session):
201    
202     # Class-level objects. Don't rebind these!
203     cache = {}
204     locks = {}
205    
206     def clean_up(self):
207         """Clean up expired sessions."""
208         now = datetime.datetime.now()
209         for id, (data, expiration_time) in self.cache.items():
210             if expiration_time < now:
211                 try:
212                     del self.cache[id]
213                 except KeyError:
214                     pass
215                 try:
216                     del self.locks[id]
217                 except KeyError:
218                     pass
219    
220     def _load(self):
221         return self.cache.get(self.id)
222    
223     def _save(self, expiration_time):
224         self.cache[self.id] = (self._data, expiration_time)
225    
226     def _delete(self):
227         del self.cache[self.id]
228    
229     def acquire_lock(self):
230         self.locked = True
231         self.locks.setdefault(self.id, threading.RLock()).acquire()
232    
233     def release_lock(self):
234         self.locks[self.id].release()
235         self.locked = False
236
237
238 class FileSession(Session):
239     """ Implementation of the File backend for sessions
240     
241     storage_path: the folder where session data will be saved. Each session
242         will be saved as pickle.dump(data, expiration_time) in its own file;
243         the filename will be self.SESSION_PREFIX + self.id.
244     """
245    
246     SESSION_PREFIX = 'session-'
247     LOCK_SUFFIX = '.lock'
248    
249     def setup(self):
250         # Warn if any lock files exist at startup.
251         lockfiles = [fname for fname in os.listdir(self.storage_path)
252                      if (fname.startswith(self.SESSION_PREFIX)
253                          and fname.endswith(self.LOCK_SUFFIX))]
254         if lockfiles:
255             plural = ('', 's')[len(lockfiles) > 1]
256             warn("%s session lockfile%s found at startup. If you are "
257                  "only running one process, then you may need to "
258                  "manually delete the lockfiles found at %r."
259                  % (len(lockfiles), plural,
260                     os.path.abspath(self.storage_path)))
261    
262     def _get_file_path(self):
263         f = os.path.join(self.storage_path, self.SESSION_PREFIX + self.id)
264         if not os.path.normpath(f).startswith(self.storage_path):
265             raise cherrypy.HTTPError(400, "Invalid session id in cookie.")
266         return f
267    
268     def _load(self, path=None):
269         if path is None:
270             path = self._get_file_path()
271         try:
272             f = open(path, "rb")
273             try:
274                 return pickle.load(f)
275             finally:
276                 f.close()
277         except (IOError, EOFError):
278             return None
279    
280     def _save(self, expiration_time):
281         f = open(self._get_file_path(), "wb")
282         try:
283             pickle.dump((self._data, expiration_time), f)
284         finally:
285             f.close()
286    
287     def _delete(self):
288         try:
289             os.unlink(self._get_file_path())
290         except OSError:
291             pass
292    
293     def acquire_lock(self, path=None):
294         if path is None:
295             path = self._get_file_path()
296         path += self.LOCK_SUFFIX
297         while True:
298             try:
299                 lockfd = os.open(path, os.O_CREAT|os.O_WRONLY|os.O_EXCL)
300             except OSError:
301                 time.sleep(0.1)
302             else:
303                 os.close(lockfd)
304                 break
305         self.locked = True
306    
307     def release_lock(self, path=None):
308         if path is None:
309             path = self._get_file_path()
310         os.unlink(path + self.LOCK_SUFFIX)
311         self.locked = False
312    
313     def clean_up(self):
314         """Clean up expired sessions."""
315         now = datetime.datetime.now()
316         # Iterate over all session files in self.storage_path
317         for fname in os.listdir(self.storage_path):
318             if (fname.startswith(self.SESSION_PREFIX)
319                 and not fname.endswith(self.LOCK_SUFFIX)):
320                 # We have a session file: lock and load it and check
321                 #   if it's expired. If it fails, nevermind.
322                 path = os.path.join(self.storage_path, fname)
323                 self.acquire_lock(path)
324                 try:
325                     contents = self._load(path)
326                     # _load returns None on IOError
327                     if contents is not None:
328                         data, expiration_time = contents
329                         if expiration_time < now:
330                             # Session expired: deleting it
331                             os.unlink(path)
332                 finally:
333                     self.release_lock(path)
334
335
336 class PostgresqlSession(Session):
337     """ Implementation of the PostgreSQL backend for sessions. It assumes
338         a table like this:
339
340             create table session (
341                 id varchar(40),
342                 data text,
343                 expiration_time timestamp
344             )
345     
346     You must provide your own get_db function.
347     """
348    
349     def __init__(self, id=None, **kwargs):
350         Session.__init__(self, id, **kwargs)
351         self.db = self.get_db()
352         self.cursor = self.db.cursor()
353    
354     def __del__(self):
355         if self.cursor:
356             self.cursor.close()
357         self.db.commit()
358    
359     def _load(self):
360         # Select session data from table
361         self.cursor.execute('select data, expiration_time from session '
362                             'where id=%s', (self.id,))
363         rows = self.cursor.fetchall()
364         if not rows:
365             return None
366        
367         pickled_data, expiration_time = rows[0]
368         data = pickle.loads(pickled_data)
369         return data, expiration_time
370    
371     def _save(self, expiration_time):
372         pickled_data = pickle.dumps(self._data)
373         self.cursor.execute('update session set data = %s, '
374                             'expiration_time = %s where id = %s',
375                             (pickled_data, expiration_time, self.id))
376    
377     def _delete(self):
378         self.cursor.execute('delete from session where id=%s', (self.id,))
379    
380     def acquire_lock(self):
381         # We use the "for update" clause to lock the row
382         self.locked = True
383         self.cursor.execute('select id from session where id=%s for update',
384                             (self.id,))
385    
386     def release_lock(self):
387         # We just close the cursor and that will remove the lock
388         #   introduced by the "for update" clause
389         self.cursor.close()
390         self.locked = False
391    
392     def clean_up(self):
393         """Clean up expired sessions."""
394         self.cursor.execute('delete from session where expiration_time < %s',
395                             (datetime.datetime.now(),))
396
397
398 # Hook functions (for CherryPy tools)
399
400 def save():
401     """Save any changed session data."""
402    
403     if not hasattr(cherrypy._serving, "session"):
404         return
405    
406     # Guard against running twice
407     if hasattr(cherrypy.request, "_sessionsaved"):
408         return
409     cherrypy.request._sessionsaved = True
410    
411     if cherrypy.response.stream:
412         # If the body is being streamed, we have to save the data
413         #   *after* the response has been written out
414         cherrypy.request.hooks.attach('on_end_request', cherrypy.session.save)
415     else:
416         # If the body is not being streamed, we save the data now
417         # (so we can release the lock).
418         if isinstance(cherrypy.response.body, types.GeneratorType):
419             cherrypy.response.collapse_body()
420         cherrypy.session.save()
421 save.failsafe = True
422
423 def close():
424     """Close the session object for this request."""
425     sess = getattr(cherrypy._serving, "session", None)
426     if sess and sess.locked:
427         # If the session is still locked we release the lock
428         sess.release_lock()
429 close.failsafe = True
430 close.priority = 90
431
432
433 def init(storage_type='ram', path=None, path_header=None, name='session_id',
434          timeout=60, domain=None, secure=False, clean_freq=5, **kwargs):
435     """Initialize session object (using cookies).
436     
437     storage_type: one of 'ram', 'file', 'postgresql'. This will be used
438         to look up the corresponding class in cherrypy.lib.sessions
439         globals. For example, 'file' will use the FileSession class.
440     path: the 'path' value to stick in the response cookie metadata.
441     path_header: if 'path' is None (the default), then the response
442         cookie 'path' will be pulled from request.headers[path_header].
443     name: the name of the cookie.
444     timeout: the expiration timeout for the cookie.
445     domain: the cookie domain.
446     secure: if False (the default) the cookie 'secure' value will not
447         be set. If True, the cookie 'secure' value will be set (to 1).
448     clean_freq (minutes): the poll rate for expired session cleanup.
449     
450     Any additional kwargs will be bound to the new Session instance,
451     and may be specific to the storage type. See the subclass of Session
452     you're using for more information.
453     """
454    
455     request = cherrypy.request
456    
457     # Guard against running twice
458     if hasattr(request, "_session_init_flag"):
459         return
460     request._session_init_flag = True
461    
462     # Check if request came with a session ID
463     id = None
464     if name in request.cookie:
465         id = request.cookie[name].value
466    
467     # Create and attach a new Session instance to cherrypy._serving.
468     # It will possess a reference to (and lock, and lazily load)
469     # the requested session data.
470     storage_class = storage_type.title() + 'Session'
471     kwargs['timeout'] = timeout
472     kwargs['clean_freq'] = clean_freq
473     cherrypy._serving.session = sess = globals()[storage_class](id, **kwargs)
474    
475     if not hasattr(cherrypy, "session"):
476         cherrypy.session = cherrypy._ThreadLocalProxy('session')
477         if hasattr(sess, "setup"):
478             sess.setup()
479    
480     # Set response cookie
481     cookie = cherrypy.response.cookie
482     cookie[name] = sess.id
483     cookie[name]['path'] = path or request.headers.get(path_header) or '/'
484    
485     # We'd like to use the "max-age" param as indicated in
486     # http://www.faqs.org/rfcs/rfc2109.html but IE doesn't
487     # save it to disk and the session is lost if people close
488     # the browser. So we have to use the old "expires" ... sigh ...
489 ##    cookie[name]['max-age'] = timeout * 60
490     if timeout:
491         cookie[name]['expires'] = http.HTTPDate(time.time() + (timeout * 60))
492     if domain is not None:
493         cookie[name]['domain'] = domain
494     if secure:
495         cookie[name]['secure'] = 1
496
497 def expire():
498     """Expire the current session cookie."""
499     name = cherrypy.request.config.get('tools.sessions.name', 'session_id')
500     one_year = 60 * 60 * 24 * 365
501     exp = time.gmtime(time.time() - one_year)
502     t = time.strftime("