2 from sqlite3 import connect as sql_connect
3 from http.server import BaseHTTPRequestHandler
4 from urllib.parse import parse_qs
5 from datetime import datetime, timedelta
6 from unittest import TestCase
7 from os.path import isfile
12 PATH_DB_SCHEMA='init.sql'
18 DATE_FORMAT = '%Y-%m-%d'
19 DATETIME_KEY_RESOLUTION = 24
23 ######## basic system stuff ############
25 class HandledException(Exception):
28 from sys import exit as sys_exit
29 print(f'ABORTING: {self}')
36 def __init__(self, path, force_creation=False):
38 if not isfile(self.path):
39 self._make_new_if_wanted_else_abort(force_creation)
40 self._validate_schema()
42 def _make_new_if_wanted_else_abort(self, force_creation):
43 if not force_creation:
44 create_question = f'Database file not found: {self.path}. Create? Y/n\n'
45 msg_on_no = 'Interpreting reply as "no", but cannot run without database file.'
46 legal_yesses = {'y', 'yes'}
47 create_reply = input(create_question)
48 if not create_reply.lower() in legal_yesses:
49 raise HandledException(msg_on_no)
50 with sql_connect(self.path) as conn:
51 with open(PATH_DB_SCHEMA, 'r') as f:
52 conn.executescript(f.read())
54 def _validate_schema(self):
55 sql_for_schema = 'SELECT sql FROM sqlite_master ORDER BY sql'
56 msg_wrong_schema = 'Database has wrong tables schema. Diff:\n'
57 with sql_connect(self.path) as conn:
58 schema = ';\n'.join([r[0] for r in conn.execute(sql_for_schema) if r[0]]) + ';'
59 with open(PATH_DB_SCHEMA, 'r') as f:
60 stored_schema = f.read().rstrip()
61 if schema != stored_schema:
62 from difflib import Differ
64 diff_msg = d.compare(schema.splitlines(), stored_schema.splitlines())
65 raise HandledException(msg_wrong_schema + '\n'.join(diff_msg))
68 from shutil import copy2
69 from random import randint
70 copy2(self.path, f'{self.path}.bak.0')
72 if 1 == randint(0, 2**i):
73 copy2(self.path, f'{self.path}.bak.{i+1}')
78 class TodoDBConnection:
80 def __init__(self, db_file):
82 self.conn = sql_connect(self.file.path)
83 self.conn.execute('PRAGMA foreign_keys = ON')
89 def exec(self, code, inputs=None):
92 return self.conn.execute(code, inputs)
99 ######## models ############
101 class VersionedAttribute:
103 def __init__(self, db_conn, parent, name, default):
106 self.default = default
108 for row in db_conn.exec(f'SELECT * FROM {self.table_name} WHERE template = ?',
110 self.history[row[1]] = row[2]
113 def table_name(self):
114 return f'versioned_{self.name}s'
116 def save(self, db_conn):
117 for date, value in self.history.items():
118 db_conn.exec(f'REPLACE INTO {self.table_name} VALUES (?, ?, ?)',
119 (self.parent.id_, date, value))
121 def delete(self, db_conn):
122 db_conn.exec(f'DELETE FROM {self.table_name} WHERE template = ?', (self.parent.id_,))
125 def _newest_datetime(self):
126 return sorted(self.history.keys())[-1]
128 def _forked_value_at(self, date_with_time):
129 return getattr(self.parent.forked_from, self.name).at(date_with_time)
133 if 0 == len(self.history):
134 if self.parent.forked_from:
135 return self._forked_value_at(self.parent.forked_at)
137 return self.history[self._newest_datetime]
139 def at(self, date_with_time):
140 sorted_datetimes = sorted(self.history.keys())
141 if self.parent.forked_from and (0 == len(sorted_datetimes) or sorted_datetimes[0] > date_with_time):
142 return self._forked_value_at(date_with_time)
143 elif 0 == len(sorted_datetimes):
145 ret = self.history[sorted_datetimes[0]]
146 for k, v in self.history.items():
147 if k > date_with_time:
152 def set(self, value):
153 if 0 == len(self.history) or value != self.history[self._newest_datetime]:
154 self.history[Day.todays_date(with_time=True)] = value
160 def __init__(self, date, comment=''):
162 self.datetime = self.__class__.date_valid(self.date)
163 if not self.datetime:
164 raise HandledException(f'Provided date is of wrong format: {date}')
165 self.comment = comment
168 def from_row(cls, row):
169 return cls(row[0], row[1])
172 def all(cls, db_conn, ensure_betweens=False, date_range=('', '')):
173 legal_day_names = {'yesterday', 'today', 'tomorrow'}
174 for val in [val for val in date_range
175 if val != '' and val not in legal_day_names
176 and not cls.date_valid(val)]:
177 raise HandledException(f'Provided date is of wrong format: {val}')
178 start_str = date_range[0] if date_range[0] else '2024-01-01'
179 end_str = date_range[1] if date_range[1] else '2030-12-31'
180 start_date = getattr(cls, f'{start_str}s_date')() if start_str in legal_day_names else start_str
181 end_date = getattr(cls, f'{end_str}s_date')() if end_str in legal_day_names else end_str
182 if start_date > end_date:
184 end_date = start_date
187 for row in db_conn.exec('SELECT * FROM days WHERE date >= ? AND date <= ?',
188 (start_date, end_date)):
189 days += [cls.from_row(row)]
191 if '' != date_range[0] and not start_date in [d.date for d in days]:
192 days += [cls(start_date)]
193 if '' != date_range[1] and not end_date in [d.date for d in days]:
194 days += [cls(end_date)]
196 if ensure_betweens and len(days) > 1:
198 for i, day in enumerate(days):
199 gapless_days += [day]
200 if i < len(days) - 1:
201 while day.next_date != days[i+1].date:
202 day = Day(day.next_date)
203 gapless_days += [day]
208 def by_date(cls, db_conn, date, make_if_none=False):
209 for row in db_conn.exec('SELECT * FROM days WHERE date = ?', (date,)):
210 return cls.from_row(row)
211 return cls(date) if make_if_none else None
213 def save(self, db_conn):
214 db_conn.exec('REPLACE INTO days VALUES (?, ?)', (self.date, self.comment))
217 def date_valid(cls, date):
219 result = datetime.strptime(date, DATE_FORMAT)
220 except (ValueError, TypeError):
225 def todays_date(cls, with_time=False):
226 cut_length = DATETIME_KEY_RESOLUTION if with_time else 10
227 return str(datetime.now())[:cut_length]
230 def yesterdays_date(cls):
231 return str(datetime.now() - timedelta(days=1))[:10]
234 def tomorrows_date(cls):
235 return str(datetime.now() + timedelta(days=1))[:10]
239 next_datetime = self.datetime + timedelta(days=1)
240 return next_datetime.strftime(DATE_FORMAT)
244 prev_datetime = self.datetime - timedelta(days=1)
245 return prev_datetime.strftime(DATE_FORMAT)
249 return self.datetime.strftime('%A')
251 def __eq__(self, other):
252 return self.date == other.date
254 def __lt__(self, other):
255 return self.date < other.date
261 def __init__(self, db_conn, id_=None, forked_from=None, forked_at=None):
263 self.forked_from = TodoTemplate.by_id(db_conn, forked_from)
264 self.forked_at = forked_at
265 self.title = VersionedAttribute(db_conn, self, 'title', 'UNNAMED')
266 self.default_effort = VersionedAttribute(db_conn, self, 'default_effort', 1.0)
267 self.description = VersionedAttribute(db_conn, self, 'description', '')
270 def from_row(cls, db_conn, row):
271 return cls(db_conn, row[0], row[1], row[2])
274 def all(cls, db_conn):
276 for row in db_conn.exec('SELECT * FROM templates'):
277 tmpls += [cls.from_row(db_conn, row)]
281 def by_id(cls, db_conn, id_, make_if_none=False):
282 for row in db_conn.exec('SELECT * FROM templates WHERE id = ?', (id_,)):
283 return cls.from_row(db_conn, row)
285 return cls(db_conn, id_, None, None)
288 def save(self, db_conn):
289 cursor = db_conn.exec('REPLACE INTO templates VALUES (?,?,?) RETURNING ID',
290 (self.id_, self.forked_from.id_ if self.forked_from else None, self.forked_at))
292 self.id_ = cursor.fetchone()[0]
293 self.title.save(db_conn)
294 self.default_effort.save(db_conn)
295 self.description.save(db_conn)
297 def delete(self, db_conn):
298 for row in db_conn.exec('SELECT * FROM templates WHERE forked_from = ?', (self.id_,)):
299 raise HandledException('cannot delete forking reference')
300 self.title.delete(db_conn)
301 self.default_effort.delete(db_conn)
302 self.description.delete(db_conn)
303 db_conn.exec('DELETE FROM templates WHERE id = ?', (self.id_,))
305 def fork(self, db_conn):
306 forked = self.__class__(db_conn, id_=None, forked_from=self.id_,
307 forked_at=Day.todays_date(with_time=True))
313 ######## web stuff ############
317 def __init__(self, url_query, site_cookie_dict):
318 self.params = parse_qs(url_query, keep_blank_values=True)
319 self.cookie = site_cookie_dict
321 def get(self, key, default):
322 return self.params.get(key, [default])[0]
324 def get_cookied(self, key, default):
325 val = self.params.get(key, [self.cookie.get(key, default)])[0]
326 self.cookie[key] = val
333 def __init__(self, site, headers):
334 from http.cookies import SimpleCookie
335 from json import loads as json_loads
338 self.full = SimpleCookie(headers['Cookie']) if 'Cookie' in headers.keys() else SimpleCookie()
339 if site in self.full.keys():
340 self.of_site = json_loads(self.full[site].value)
342 def send_headers(self):
343 from json import dumps as json_dumps
344 self.full[self.site] = json_dumps(self.of_site)
345 return [('Set-Cookie', morsel.OutputString()) for morsel in self.full.values()]
348 for morsel in self.full.values():
349 morsel['expires'] = 'Thu, 01 Jan 1970 00:00:00 GMT'
353 class TodoHandler(BaseHTTPRequestHandler):
356 from os.path import split as path_split
357 from urllib.parse import urlparse
358 parsed_url = urlparse(self.path)
359 self.site = path_split(parsed_url.path)[1]
360 if 0 == len(self.site):
361 self.site = 'calendar'
362 self.cookie = CookieDB(self.site, self.headers)
363 self.params = ParamsParser(parsed_url.query, self.cookie.of_site)
364 self.db_conn = TodoDBConnection(self.server.db_file)
366 def send_code_and_headers(self, code, headers, set_cookies=True):
367 self.send_response(code)
369 [self.send_header(h[0], h[1]) for h in self.cookie.send_headers()]
370 for fieldname, content in headers:
371 self.send_header(fieldname, content)
374 def redirect(self, url):
375 self.send_code_and_headers(302, [('Location', url)])
377 def send_HTML(self, html, code=200):
378 self.send_code_and_headers(code, [('Content-type', 'text/html')])
379 self.wfile.write(bytes(html, 'utf-8'))
381 def send_fail(self, msg, code=400):
382 html = self.server.html.get_template('msg.html').render(msg=f'Exception: {msg}')
383 self.send_code_and_headers(code, [('Content-type', 'text/html')], set_cookies=False)
384 self.wfile.write(bytes(html, 'utf-8'))
391 length = int(self.headers['content-length'])
393 self.postvars = parse_qs(self.rfile.read(length).decode(), keep_blank_values=1)
394 if self.site in {'day', 'template'}:
395 getattr(self, f'do_POST_{self.site}')()
396 self.db_conn.commit()
398 self.redirect(self.redir_url)
399 except HandledException as msg:
402 def do_POST_day(self):
403 date = self.postvars['date'][0]
404 day = Day(date, self.postvars['comment'][0])
405 day.save(self.db_conn)
407 def do_POST_template(self):
408 id_ = self.params.get('id', None)
409 tmpl = TodoTemplate.by_id(self.db_conn, id_, make_if_none=True)
410 if 'update' in self.postvars.keys():
411 tmpl.title.set(self.postvars['title'][0])
412 tmpl.default_effort.set(float(self.postvars['default_effort'][0]))
413 tmpl.description.set(self.postvars['description'][0])
414 tmpl.save(self.db_conn)
415 elif 'fork' in self.postvars.keys():
416 tmpl.fork(self.db_conn)
417 elif 'delete' in self.postvars.keys():
418 tmpl.delete(self.db_conn)
419 self.redir_url = 'templates'
426 if self.site in {'day', 'template', 'templates', 'reset_cookie', 'calendar'}:
427 page = getattr(self, f'do_GET_{self.site}')()
429 page = self.do_GET_calendar()
432 except HandledException as msg:
435 def do_GET_reset_cookie(self):
437 msg = 'Cookie has been re-set!'
438 return self.server.html.get_template('msg.html').render(msg=msg)
440 def do_GET_day(self):
441 date = self.params.get_cookied('id', Day.todays_date())
442 day = Day.by_date(self.db_conn, date, make_if_none=True)
443 return self.server.html.get_template('day.html').render(day=day)
445 def do_GET_calendar(self):
446 from_ = self.params.get_cookied('from', 'yesterday')
447 to = self.params.get_cookied('to', '')
448 days = Day.all(self.db_conn, ensure_betweens=True, date_range=(from_, to))
449 return self.server.html.get_template('calendar.html').render(
450 days=days, from_=from_, to=to)
452 def do_GET_template(self):
453 id_ = self.params.get('id', None)
454 template = TodoTemplate.by_id(self.db_conn, id_, make_if_none=True)
455 return self.server.html.get_template('template.html').render(tmpl=template)
457 def do_GET_templates(self):
458 templates = TodoTemplate.all(self.db_conn)
459 return self.server.html.get_template('templates.html').render(templates=templates)
466 from http.server import HTTPServer
467 from os import environ
468 from jinja2 import Environment as JinjaEnv, FileSystemLoader as JinjaFSLoader
469 path_todo_db = environ.get('TODO_DB')
471 raise HandledException('TODO_DB environment variable not set.')
472 db_file = TodoDBFile(path_todo_db)
473 server = HTTPServer(('localhost', HTTP_PORT), TodoHandler)
474 server.db_file = db_file
475 server.html = JinjaEnv(loader=JinjaFSLoader(HTML_DIR))
476 print(f'running at http://localhost:{HTTP_PORT}')
478 server.serve_forever()
479 except KeyboardInterrupt:
480 print('\ncaught KeyboardInterrupt, stopping server')
481 server.server_close()
485 if __name__ == '__main__':
488 except HandledException as e:
493 # testing – run with: python3 -m unittest todo.py
495 class TestWithDB(TestCase):
498 self.db_file = TodoDBFile(f'test_db:{datetime.now().timestamp()}', force_creation=True)
499 self.db_conn = TodoDBConnection(self.db_file)
500 self.bak_0_path = f'{self.db_file.path}.bak.0'
503 from os import remove
504 remove(self.db_file.path)
505 for i in range(0, 9):
506 bak_path = f'{self.db_file.path}.bak.{i}'
507 if isfile(f'{self.db_file.path}.bak.{i}'):
510 def test_backup_bak_0_file_content(self):
511 day = Day('2024-01-01')
512 day.save(self.db_conn)
513 self.db_conn.commit() # backups before writing, so expect different file contents
514 with open(self.db_file.path, 'rb') as f1:
515 original_content = f1.read()
516 with open(self.bak_0_path, 'rb') as f2:
517 backup_content = f2.read()
518 self.assertNotEqual(original_content, backup_content)
519 self.db_conn.commit() # this time commit without changes, so expect equal file contents
520 with open(self.db_file.path, 'rb') as f1:
521 original_content = f1.read()
522 with open(self.bak_0_path, 'rb') as f2:
523 backup_content = f2.read()
524 self.assertEqual(original_content, backup_content)
526 def test_backup_bak_0_file_attributes(self):
528 sleep(0.1) # so mtime would change if not copied
529 self.db_conn.commit()
530 original_stat = stat(self.db_file.path)
531 backup_stat = stat(self.bak_0_path)
532 for name in {'st_mode', 'st_dev', 'st_nlink', 'st_uid', 'st_gid', 'st_size', 'st_atime', 'st_mtime'}:
533 self.assertEqual(getattr(original_stat, name), getattr(backup_stat, name))
535 def test_Day_by_date(self):
536 test_date_1 = '2024-02-28'
537 test_date_2 = '2024-02-29'
539 day1 = Day(test_date_1, test_comment)
540 day1.save(self.db_conn)
541 retrieved_day = Day.by_date(self.db_conn, test_date_1)
542 self.assertEqual(day1, retrieved_day)
543 self.assertEqual(day1.comment, retrieved_day.comment)
544 self.assertEqual(None, Day.by_date(self.db_conn, test_date_2))
545 self.assertEqual(Day(test_date_2), Day.by_date(self.db_conn, test_date_2, make_if_none=True))
547 def test_Day_all(self):
548 with self.assertRaises(HandledException):
549 Day.all(self.db_conn, date_range=(None, None))
550 Day.all(self.db_conn, date_range=('foo', ''))
551 Day.all(self.db_conn, date_range=('', '2024-02-30'))
552 test_date_1 = str(datetime.now() - timedelta(days=2))[:10]
553 test_date_2 = Day.todays_date()
554 test_date_3 = str(datetime.now() + timedelta(days=2))[:10]
555 day1 = Day(test_date_1)
556 day2 = Day(test_date_2)
557 day3 = Day(test_date_3)
558 day1.save(self.db_conn)
559 day2.save(self.db_conn)
560 day3.save(self.db_conn)
561 self.assertEqual([day1, day2, day3], Day.all(self.db_conn))
562 self.assertEqual([day2, day3], Day.all(self.db_conn, date_range=('yesterday', '')))
563 self.assertEqual([day2, day3], Day.all(self.db_conn, date_range=(Day.yesterdays_date(), '')))
564 self.assertEqual([day3], Day.all(self.db_conn, date_range=('tomorrow', '')))
565 self.assertEqual([day3], Day.all(self.db_conn, date_range=(Day.tomorrows_date(), '')))
566 self.assertEqual([day2], Day.all(self.db_conn, date_range=('today', Day.todays_date())))
567 self.assertEqual([day1], Day.all(self.db_conn, date_range=('', 'yesterday')))
568 self.assertEqual([Day(Day.yesterdays_date()), day2],
569 Day.all(self.db_conn, ensure_betweens=True, date_range=('yesterday', 'today')))
570 self.assertEqual([day2, Day(Day.tomorrows_date())],
571 Day.all(self.db_conn, ensure_betweens=True, date_range=('today', 'tomorrow')))
573 def test_TodoTemplate_by_id(self):
574 tmpl = TodoTemplate(self.db_conn)
575 tmpl.save(self.db_conn)
576 retrieved = TodoTemplate.by_id(self.db_conn, tmpl.id_)
577 self.assertEqual(tmpl.id_, retrieved.id_)
578 self.assertEqual(None, TodoTemplate.by_id(self.db_conn, tmpl.id_ + 1))
579 self.assertIsInstance(TodoTemplate.by_id(self.db_conn, tmpl.id_ + 1, make_if_none=True), TodoTemplate)
581 def test_TodoTemplate_all(self):
582 tmpl_1 = TodoTemplate(self.db_conn)
583 tmpl_1.save(self.db_conn)
584 tmpl_2 = TodoTemplate(self.db_conn)
585 tmpl_2.save(self.db_conn)
586 self.assertEqual({tmpl_1.id_, tmpl_2.id_}, set(t.id_ for t in TodoTemplate.all(self.db_conn)))
588 def test_TodoTemplate_delete(self):
589 tmpl = TodoTemplate(self.db_conn)
590 tmpl.title.set('foo')
591 tmpl.save(self.db_conn)
592 self.assertIsInstance(TodoTemplate.by_id(self.db_conn, tmpl.id_), TodoTemplate)
593 tmpl.delete(self.db_conn)
594 self.assertEqual(None, TodoTemplate.by_id(self.db_conn, tmpl.id_))
595 tmpl = TodoTemplate(self.db_conn)
596 tmpl.save(self.db_conn)
597 fork = tmpl.fork(self.db_conn)
598 with self.assertRaises(HandledException):
599 tmpl.delete(self.db_conn)
600 self.assertIsInstance(TodoTemplate.by_id(self.db_conn, tmpl.id_), TodoTemplate)
601 fork.delete(self.db_conn)
602 tmpl.delete(self.db_conn)
603 self.assertEqual(None, TodoTemplate.by_id(self.db_conn, tmpl.id_))
605 def test_versioned_attributes(self):
606 def test(name, default, values):
607 def wait_till_next_timestamp(timestamp):
608 # if we .set() to early, the timestamp key will not have changed,
609 # i.e. we'd simply over-write the previous value
610 while Day.todays_date(with_time=True) == timestamp:
612 return Day.todays_date(with_time=True)
613 tmpl = TodoTemplate(self.db_conn)
614 tmpl.save(self.db_conn)
615 tmpl_attr = getattr(tmpl, name)
616 # check we get default value on empty history
617 self.assertEqual(tmpl_attr.newest, default)
618 self.assertEqual({}, tmpl_attr.history)
619 # check we get new value when set
620 tmpl_attr.set(values[0])
621 timestamp_1 = Day.todays_date(with_time=True)
622 self.assertEqual(tmpl_attr.newest, values[0])
623 # check history remains unchanged if setting same value as .newest
624 timestamp_2 = wait_till_next_timestamp(timestamp_1)
625 tmpl_attr.set(values[0])
626 self.assertEqual(len(tmpl_attr.history), 1)
627 # check we can access different values with .newest and .at
628 tmpl_attr.set(values[1])
629 self.assertEqual(tmpl_attr.newest, values[1])
630 self.assertEqual(tmpl_attr.at(timestamp_1), values[0])
631 # check attribute history stored in DB with parent
632 tmpl.save(self.db_conn)
633 retrieved = TodoTemplate.by_id(self.db_conn, tmpl.id_)
634 self.assertEqual(getattr(retrieved, name).at(timestamp_1), values[0]) # i.e. attribute.save() works
635 # check forks can use original's history, but only up to their fork moment
636 fork = tmpl.fork(self.db_conn)
637 wait_till_next_timestamp(timestamp_2)
638 tmpl_attr.set(values[2])
639 forked_attr = getattr(fork, name)
640 self.assertEqual(forked_attr.newest, values[1])
641 self.assertEqual(forked_attr.at(timestamp_1), values[0])
642 # check original and fork bifurcate their history
643 forked_attr.set(values[0])
644 self.assertEqual(forked_attr.newest, values[0])
645 self.assertEqual(tmpl_attr.newest, values[2])
646 test('title', 'UNNAMED', ['foo', 'bar', 'baz'])
647 test('default_effort', 1.0, [0.5, 3, 9])
648 test('description', '', ['foo', 'bar', 'baz'])
652 class TestSansDB(TestCase):
654 def test_Day_date_validate(self):
655 self.assertEqual(None, Day.date_valid('foo'))
656 self.assertEqual(None, Day.date_valid('2024-02-30'))
657 self.assertEqual(None, Day.date_valid('2024-01-01 23:59:59'))
658 self.assertEqual(datetime(2024,1,1), Day.date_valid('2024-01-01'))
660 def test_Day_date_classmethods(self):
661 self.assertEqual(str(datetime.now())[:10], Day.todays_date())
662 self.assertEqual(str(datetime.now())[:DATETIME_KEY_RESOLUTION], Day.todays_date(with_time=True))
663 self.assertEqual(str(datetime.now() - timedelta(days=1))[:10], Day.yesterdays_date())
664 self.assertEqual(str(datetime.now() + timedelta(days=1))[:10], Day.tomorrows_date())
666 def test_Day_init(self):
667 test_date = '2024-02-29'
670 self.assertEqual(day.date, test_date)
671 self.assertEqual(day.comment, '')
672 day = Day(test_date, test_comment)
673 self.assertEqual(day.comment, test_comment)
674 with self.assertRaises(HandledException):
677 def test_Day_date_neighbors(self):
678 day = Day('2024-02-29')
679 self.assertEqual(day.prev_date, '2024-02-28')
680 self.assertEqual(day.next_date, '2024-03-01')
682 def test_Day_date_weekday(self):
683 day = Day('2024-02-29')
684 self.assertEqual(day.weekday, 'Thursday')
686 def test_Day_cmp(self):
687 day1 = Day('2024-01-01')
688 day2 = Day('2024-01-02')
689 day3 = Day('2024-01-03')
690 days = [day3, day1, day2]
691 self.assertEqual(sorted(days), [day1, day2, day3])
693 def test_ParamsParser(self):
694 from urllib.parse import urlencode
695 q = urlencode({'is_foo': 'foo', 'is_whitespace': '', 'is_None': None})
696 c = {'is_bar': 'bar'}
697 p = ParamsParser(q, c)
698 # check basic retrieval and default behavior
699 self.assertEqual(p.get('is_foo', None), 'foo')
700 self.assertEqual(p.get('missing', None), None)
701 self.assertEqual(p.get('missing', 'default'), 'default')
702 # check handling of empty and None values
703 self.assertEqual(p.get('missing', ''), '')
704 self.assertEqual(p.get('is_whitespace', None), '')
705 self.assertEqual(p.get('is_None', None), 'None') # TODO: unwanted behavior, or urlencode fault?
706 # check retrieval and setting of cookied values
707 self.assertEqual(p.get_cookied('missing', None), None)
708 self.assertEqual(c['missing'], None)
709 self.assertEqual(p.get_cookied('missing', 'default'), None)
710 self.assertEqual(p.get_cookied('is_bar', None), 'bar')