summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bitbake/lib/toaster/toastergui/querysetfilter.py7
-rw-r--r--bitbake/lib/toaster/toastergui/static/js/table.js80
-rw-r--r--bitbake/lib/toaster/toastergui/tablefilter.py119
-rw-r--r--bitbake/lib/toaster/toastergui/tables.py132
-rw-r--r--bitbake/lib/toaster/toastergui/widgets.py90
5 files changed, 310 insertions, 118 deletions
diff --git a/bitbake/lib/toaster/toastergui/querysetfilter.py b/bitbake/lib/toaster/toastergui/querysetfilter.py
index 62297e9b89..dbae239370 100644
--- a/bitbake/lib/toaster/toastergui/querysetfilter.py
+++ b/bitbake/lib/toaster/toastergui/querysetfilter.py
@@ -5,7 +5,7 @@ class QuerysetFilter(object):
5 if criteria: 5 if criteria:
6 self.set_criteria(criteria) 6 self.set_criteria(criteria)
7 7
8 def set_criteria(self, criteria): 8 def set_criteria(self, criteria = None):
9 """ 9 """
10 criteria is an instance of django.db.models.Q; 10 criteria is an instance of django.db.models.Q;
11 see https://docs.djangoproject.com/en/1.9/ref/models/querysets/#q-objects 11 see https://docs.djangoproject.com/en/1.9/ref/models/querysets/#q-objects
@@ -17,7 +17,10 @@ class QuerysetFilter(object):
17 Filter queryset according to the criteria for this filter, 17 Filter queryset according to the criteria for this filter,
18 returning the filtered queryset 18 returning the filtered queryset
19 """ 19 """
20 return queryset.filter(self.criteria) 20 if self.criteria:
21 return queryset.filter(self.criteria)
22 else:
23 return queryset
21 24
22 def count(self, queryset): 25 def count(self, queryset):
23 """ Returns a count of the elements in the filtered queryset """ 26 """ Returns a count of the elements in the filtered queryset """
diff --git a/bitbake/lib/toaster/toastergui/static/js/table.js b/bitbake/lib/toaster/toastergui/static/js/table.js
index c69c205d50..fa01ddf47e 100644
--- a/bitbake/lib/toaster/toastergui/static/js/table.js
+++ b/bitbake/lib/toaster/toastergui/static/js/table.js
@@ -415,38 +415,76 @@ function tableInit(ctx){
415 data: params, 415 data: params,
416 headers: { 'X-CSRFToken' : $.cookie('csrftoken')}, 416 headers: { 'X-CSRFToken' : $.cookie('csrftoken')},
417 success: function (filterData) { 417 success: function (filterData) {
418 var filterActionRadios = $('#filter-actions-'+ctx.tableName); 418 /*
419 filterData structure:
420
421 {
422 title: '<title for the filter popup>',
423 filter_actions: [
424 {
425 title: '<label for radio button inside the popup>',
426 name: '<name of the filter action>',
427 count: <number of items this filter will show>
428 }
429 ]
430 }
419 431
420 $('#filter-modal-title-'+ctx.tableName).text(filterData.title); 432 each filter_action gets a radio button; the value of this is
433 set to filterName + ':' + filter_action.name; e.g.
421 434
422 filterActionRadios.text(""); 435 in_current_project:in_project
423 436
424 for (var i in filterData.filter_actions){ 437 specifies the "in_project" action of the "in_current_project"
425 var filterAction = filterData.filter_actions[i]; 438 filter
426 439
427 var action = $('<label class="radio"><input type="radio" name="filter" value=""><span class="filter-title"></span></label>'); 440 the filterName is set on the column filter icon, and corresponds
428 var actionTitle = filterAction.title + ' (' + filterAction.count + ')'; 441 to a value in the table's filters property
429 442
430 var radioInput = action.children("input"); 443 when the filter popup's "Apply" button is clicked, the
444 value for the radio button which is checked is passed in the
445 querystring and applied to the queryset on the table
446 */
431 447
432 if (Number(filterAction.count) == 0){ 448 var filterActionRadios = $('#filter-actions-'+ctx.tableName);
433 radioInput.attr("disabled", "disabled");
434 }
435 449
436 action.children(".filter-title").text(actionTitle); 450 $('#filter-modal-title-'+ctx.tableName).text(filterData.title);
437 451
438 radioInput.val(filterName + ':' + filterAction.name); 452 filterActionRadios.text("");
439 453
440 /* Setup the current selected filter, default to 'all' if 454 for (var i in filterData.filter_actions) {
441 * no current filter selected. 455 var filterAction = filterData.filter_actions[i];
442 */ 456 var action = null;
443 if ((tableParams.filter && 457
444 tableParams.filter === radioInput.val()) || 458 if (filterAction.type === 'toggle') {
445 filterAction.name == 'all') { 459 var actionTitle = filterAction.title + ' (' + filterAction.count + ')';
446 radioInput.attr("checked", "checked"); 460
461 action = $('<label class="radio">' +
462 '<input type="radio" name="filter" value="">' +
463 '<span class="filter-title">' +
464 actionTitle +
465 '</span>' +
466 '</label>');
467
468 var radioInput = action.children("input");
469 if (Number(filterAction.count) == 0) {
470 radioInput.attr("disabled", "disabled");
471 }
472
473 radioInput.val(filterData.name + ':' + filterAction.action_name);
474
475 /* Setup the current selected filter, default to 'all' if
476 * no current filter selected.
477 */
478 if ((tableParams.filter &&
479 tableParams.filter === radioInput.val()) ||
480 filterAction.action_name == 'all') {
481 radioInput.attr("checked", "checked");
482 }
447 } 483 }
448 484
449 filterActionRadios.append(action); 485 if (action) {
486 filterActionRadios.append(action);
487 }
450 } 488 }
451 489
452 $('#filter-modal-'+ctx.tableName).modal('show'); 490 $('#filter-modal-'+ctx.tableName).modal('show');
diff --git a/bitbake/lib/toaster/toastergui/tablefilter.py b/bitbake/lib/toaster/toastergui/tablefilter.py
new file mode 100644
index 0000000000..b42fd52865
--- /dev/null
+++ b/bitbake/lib/toaster/toastergui/tablefilter.py
@@ -0,0 +1,119 @@
1#
2# ex:ts=4:sw=4:sts=4:et
3# -*- tab-width: 4; c-basic-offset: 4; indent-tabs-mode: nil -*-
4#
5# BitBake Toaster Implementation
6#
7# Copyright (C) 2015 Intel Corporation
8#
9# This program is free software; you can redistribute it and/or modify
10# it under the terms of the GNU General Public License version 2 as
11# published by the Free Software Foundation.
12#
13# This program is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16# GNU General Public License for more details.
17#
18# You should have received a copy of the GNU General Public License along
19# with this program; if not, write to the Free Software Foundation, Inc.,
20# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
21
22class TableFilter(object):
23 """
24 Stores a filter for a named field, and can retrieve the action
25 requested for that filter
26 """
27 def __init__(self, name, title):
28 self.name = name
29 self.title = title
30 self.__filter_action_map = {}
31
32 def add_action(self, action):
33 self.__filter_action_map[action.name] = action
34
35 def get_action(self, action_name):
36 return self.__filter_action_map[action_name]
37
38 def to_json(self, queryset):
39 """
40 Dump all filter actions as an object which can be JSON serialised;
41 this is used to generate the JSON for processing in
42 table.js / filterOpenClicked()
43 """
44 filter_actions = []
45
46 # add the "all" pseudo-filter action, which just selects the whole
47 # queryset
48 filter_actions.append({
49 'action_name' : 'all',
50 'title' : 'All',
51 'type': 'toggle',
52 'count' : queryset.count()
53 })
54
55 # add other filter actions
56 for action_name, filter_action in self.__filter_action_map.iteritems():
57 obj = filter_action.to_json(queryset)
58 obj['action_name'] = action_name
59 filter_actions.append(obj)
60
61 return {
62 'name': self.name,
63 'title': self.title,
64 'filter_actions': filter_actions
65 }
66
67class TableFilterActionToggle(object):
68 """
69 Stores a single filter action which will populate one radio button of
70 a ToasterTable filter popup; this filter can either be on or off and
71 has no other parameters
72 """
73
74 def __init__(self, name, title, queryset_filter):
75 self.name = name
76 self.title = title
77 self.__queryset_filter = queryset_filter
78 self.type = 'toggle'
79
80 def set_params(self, params):
81 """
82 params: (str) a string of extra parameters for the action;
83 the structure of this string depends on the type of action;
84 it's ignored for a toggle filter action, which is just on or off
85 """
86 pass
87
88 def filter(self, queryset):
89 return self.__queryset_filter.filter(queryset)
90
91 def to_json(self, queryset):
92 """ Dump as a JSON object """
93 return {
94 'title': self.title,
95 'type': self.type,
96 'count': self.__queryset_filter.count(queryset)
97 }
98
99class TableFilterMap(object):
100 """
101 Map from field names to Filter objects for those fields
102 """
103 def __init__(self):
104 self.__filters = {}
105
106 def add_filter(self, filter_name, table_filter):
107 """ table_filter is an instance of Filter """
108 self.__filters[filter_name] = table_filter
109
110 def get_filter(self, filter_name):
111 return self.__filters[filter_name]
112
113 def to_json(self, queryset):
114 data = {}
115
116 for filter_name, table_filter in self.__filters.iteritems():
117 data[filter_name] = table_filter.to_json()
118
119 return data
diff --git a/bitbake/lib/toaster/toastergui/tables.py b/bitbake/lib/toaster/toastergui/tables.py
index 116cff3f43..a0991ec3ea 100644
--- a/bitbake/lib/toaster/toastergui/tables.py
+++ b/bitbake/lib/toaster/toastergui/tables.py
@@ -28,6 +28,8 @@ from django.conf.urls import url
28from django.core.urlresolvers import reverse 28from django.core.urlresolvers import reverse
29from django.views.generic import TemplateView 29from django.views.generic import TemplateView
30 30
31from toastergui.tablefilter import TableFilter, TableFilterActionToggle
32
31class ProjectFilters(object): 33class ProjectFilters(object):
32 def __init__(self, project_layers): 34 def __init__(self, project_layers):
33 self.in_project = QuerysetFilter(Q(layer_version__in=project_layers)) 35 self.in_project = QuerysetFilter(Q(layer_version__in=project_layers))
@@ -53,16 +55,28 @@ class LayersTable(ToasterTable):
53 project = Project.objects.get(pk=kwargs['pid']) 55 project = Project.objects.get(pk=kwargs['pid'])
54 self.project_layers = ProjectLayer.objects.filter(project=project) 56 self.project_layers = ProjectLayer.objects.filter(project=project)
55 57
58 in_current_project_filter = TableFilter(
59 "in_current_project",
60 "Filter by project layers"
61 )
62
56 criteria = Q(projectlayer__in=self.project_layers) 63 criteria = Q(projectlayer__in=self.project_layers)
57 in_project_filter = QuerysetFilter(criteria)
58 not_in_project_filter = QuerysetFilter(~criteria)
59 64
60 self.add_filter(title="Filter by project layers", 65 in_project_filter_action = TableFilterActionToggle(
61 name="in_current_project", 66 "in_project",
62 filter_actions=[ 67 "Layers added to this project",
63 self.make_filter_action("in_project", "Layers added to this project", in_project_filter), 68 QuerysetFilter(criteria)
64 self.make_filter_action("not_in_project", "Layers not added to this project", not_in_project_filter) 69 )
65 ]) 70
71 not_in_project_filter_action = TableFilterActionToggle(
72 "not_in_project",
73 "Layers not added to this project",
74 QuerysetFilter(~criteria)
75 )
76
77 in_current_project_filter.add_action(in_project_filter_action)
78 in_current_project_filter.add_action(not_in_project_filter_action)
79 self.add_filter(in_current_project_filter)
66 80
67 def setup_queryset(self, *args, **kwargs): 81 def setup_queryset(self, *args, **kwargs):
68 prj = Project.objects.get(pk = kwargs['pid']) 82 prj = Project.objects.get(pk = kwargs['pid'])
@@ -199,12 +213,26 @@ class MachinesTable(ToasterTable):
199 213
200 project_filters = ProjectFilters(self.project_layers) 214 project_filters = ProjectFilters(self.project_layers)
201 215
202 self.add_filter(title="Filter by project machines", 216 in_current_project_filter = TableFilter(
203 name="in_current_project", 217 "in_current_project",
204 filter_actions=[ 218 "Filter by project machines"
205 self.make_filter_action("in_project", "Machines provided by layers added to this project", project_filters.in_project), 219 )
206 self.make_filter_action("not_in_project", "Machines provided by layers not added to this project", project_filters.not_in_project) 220
207 ]) 221 in_project_filter_action = TableFilterActionToggle(
222 "in_project",
223 "Machines provided by layers added to this project",
224 project_filters.in_project
225 )
226
227 not_in_project_filter_action = TableFilterActionToggle(
228 "not_in_project",
229 "Machines provided by layers not added to this project",
230 project_filters.not_in_project
231 )
232
233 in_current_project_filter.add_action(in_project_filter_action)
234 in_current_project_filter.add_action(not_in_project_filter_action)
235 self.add_filter(in_current_project_filter)
208 236
209 def setup_queryset(self, *args, **kwargs): 237 def setup_queryset(self, *args, **kwargs):
210 prj = Project.objects.get(pk = kwargs['pid']) 238 prj = Project.objects.get(pk = kwargs['pid'])
@@ -318,12 +346,26 @@ class RecipesTable(ToasterTable):
318 def setup_filters(self, *args, **kwargs): 346 def setup_filters(self, *args, **kwargs):
319 project_filters = ProjectFilters(self.project_layers) 347 project_filters = ProjectFilters(self.project_layers)
320 348
321 self.add_filter(title="Filter by project recipes", 349 table_filter = TableFilter(
322 name="in_current_project", 350 'in_current_project',
323 filter_actions=[ 351 'Filter by project recipes'
324 self.make_filter_action("in_project", "Recipes provided by layers added to this project", project_filters.in_project), 352 )
325 self.make_filter_action("not_in_project", "Recipes provided by layers not added to this project", project_filters.not_in_project) 353
326 ]) 354 in_project_filter_action = TableFilterActionToggle(
355 'in_project',
356 'Recipes provided by layers added to this project',
357 project_filters.in_project
358 )
359
360 not_in_project_filter_action = TableFilterActionToggle(
361 'not_in_project',
362 'Recipes provided by layers not added to this project',
363 project_filters.not_in_project
364 )
365
366 table_filter.add_action(in_project_filter_action)
367 table_filter.add_action(not_in_project_filter_action)
368 self.add_filter(table_filter)
327 369
328 def setup_queryset(self, *args, **kwargs): 370 def setup_queryset(self, *args, **kwargs):
329 prj = Project.objects.get(pk = kwargs['pid']) 371 prj = Project.objects.get(pk = kwargs['pid'])
@@ -1070,47 +1112,47 @@ class BuildsTable(ToasterTable):
1070 1112
1071 def setup_filters(self, *args, **kwargs): 1113 def setup_filters(self, *args, **kwargs):
1072 # outcomes 1114 # outcomes
1073 filter_only_successful_builds = QuerysetFilter(Q(outcome=Build.SUCCEEDED)) 1115 outcome_filter = TableFilter(
1074 successful_builds_filter = self.make_filter_action( 1116 'outcome_filter',
1117 'Filter builds by outcome'
1118 )
1119
1120 successful_builds_filter_action = TableFilterActionToggle(
1075 'successful_builds', 1121 'successful_builds',
1076 'Successful builds', 1122 'Successful builds',
1077 filter_only_successful_builds 1123 QuerysetFilter(Q(outcome=Build.SUCCEEDED))
1078 ) 1124 )
1079 1125
1080 filter_only_failed_builds = QuerysetFilter(Q(outcome=Build.FAILED)) 1126 failed_builds_filter_action = TableFilterActionToggle(
1081 failed_builds_filter = self.make_filter_action(
1082 'failed_builds', 1127 'failed_builds',
1083 'Failed builds', 1128 'Failed builds',
1084 filter_only_failed_builds 1129 QuerysetFilter(Q(outcome=Build.FAILED))
1085 ) 1130 )
1086 1131
1087 self.add_filter(title='Filter builds by outcome', 1132 outcome_filter.add_action(successful_builds_filter_action)
1088 name='outcome_filter', 1133 outcome_filter.add_action(failed_builds_filter_action)
1089 filter_actions = [ 1134 self.add_filter(outcome_filter)
1090 successful_builds_filter,
1091 failed_builds_filter
1092 ])
1093 1135
1094 # failed tasks 1136 # failed tasks
1137 failed_tasks_filter = TableFilter(
1138 'failed_tasks_filter',
1139 'Filter builds by failed tasks'
1140 )
1141
1095 criteria = Q(task_build__outcome=Task.OUTCOME_FAILED) 1142 criteria = Q(task_build__outcome=Task.OUTCOME_FAILED)
1096 filter_only_builds_with_failed_tasks = QuerysetFilter(criteria) 1143
1097 with_failed_tasks_filter = self.make_filter_action( 1144 with_failed_tasks_filter_action = TableFilterActionToggle(
1098 'with_failed_tasks', 1145 'with_failed_tasks',
1099 'Builds with failed tasks', 1146 'Builds with failed tasks',
1100 filter_only_builds_with_failed_tasks 1147 QuerysetFilter(criteria)
1101 ) 1148 )
1102 1149
1103 criteria = ~Q(task_build__outcome=Task.OUTCOME_FAILED) 1150 without_failed_tasks_filter_action = TableFilterActionToggle(
1104 filter_only_builds_without_failed_tasks = QuerysetFilter(criteria)
1105 without_failed_tasks_filter = self.make_filter_action(
1106 'without_failed_tasks', 1151 'without_failed_tasks',
1107 'Builds without failed tasks', 1152 'Builds without failed tasks',
1108 filter_only_builds_without_failed_tasks 1153 QuerysetFilter(~criteria)
1109 ) 1154 )
1110 1155
1111 self.add_filter(title='Filter builds by failed tasks', 1156 failed_tasks_filter.add_action(with_failed_tasks_filter_action)
1112 name='failed_tasks_filter', 1157 failed_tasks_filter.add_action(without_failed_tasks_filter_action)
1113 filter_actions = [ 1158 self.add_filter(failed_tasks_filter)
1114 with_failed_tasks_filter,
1115 without_failed_tasks_filter
1116 ])
diff --git a/bitbake/lib/toaster/toastergui/widgets.py b/bitbake/lib/toaster/toastergui/widgets.py
index 71b29eaa1e..8790340db9 100644
--- a/bitbake/lib/toaster/toastergui/widgets.py
+++ b/bitbake/lib/toaster/toastergui/widgets.py
@@ -39,11 +39,13 @@ import json
39import collections 39import collections
40import operator 40import operator
41import re 41import re
42import urllib
42 43
43import logging 44import logging
44logger = logging.getLogger("toaster") 45logger = logging.getLogger("toaster")
45 46
46from toastergui.views import objtojson 47from toastergui.views import objtojson
48from toastergui.tablefilter import TableFilterMap
47 49
48class ToasterTable(TemplateView): 50class ToasterTable(TemplateView):
49 def __init__(self, *args, **kwargs): 51 def __init__(self, *args, **kwargs):
@@ -53,7 +55,10 @@ class ToasterTable(TemplateView):
53 self.title = "Table" 55 self.title = "Table"
54 self.queryset = None 56 self.queryset = None
55 self.columns = [] 57 self.columns = []
56 self.filters = {} 58
59 # map from field names to Filter instances
60 self.filter_map = TableFilterMap()
61
57 self.total_count = 0 62 self.total_count = 0
58 self.static_context_extra = {} 63 self.static_context_extra = {}
59 self.filter_actions = {} 64 self.filter_actions = {}
@@ -66,7 +71,7 @@ class ToasterTable(TemplateView):
66 orderable=True, 71 orderable=True,
67 field_name="id") 72 field_name="id")
68 73
69 # prevent HTTP caching of table data 74 # prevent HTTP caching of table data
70 @cache_control(must_revalidate=True, max_age=0, no_store=True, no_cache=True) 75 @cache_control(must_revalidate=True, max_age=0, no_store=True, no_cache=True)
71 def dispatch(self, *args, **kwargs): 76 def dispatch(self, *args, **kwargs):
72 return super(ToasterTable, self).dispatch(*args, **kwargs) 77 return super(ToasterTable, self).dispatch(*args, **kwargs)
@@ -108,27 +113,10 @@ class ToasterTable(TemplateView):
108 self.apply_search(search) 113 self.apply_search(search)
109 114
110 name = request.GET.get("name", None) 115 name = request.GET.get("name", None)
111 if name is None: 116 table_filter = self.filter_map.get_filter(name)
112 data = json.dumps(self.filters, 117 return json.dumps(table_filter.to_json(self.queryset),
113 indent=2, 118 indent=2,
114 cls=DjangoJSONEncoder) 119 cls=DjangoJSONEncoder)
115 else:
116 for actions in self.filters[name]['filter_actions']:
117 queryset_filter = self.filter_actions[actions['name']]
118 actions['count'] = queryset_filter.count(self.queryset)
119
120 # Add the "All" items filter action
121 self.filters[name]['filter_actions'].insert(0, {
122 'name' : 'all',
123 'title' : 'All',
124 'count' : self.queryset.count(),
125 })
126
127 data = json.dumps(self.filters[name],
128 indent=2,
129 cls=DjangoJSONEncoder)
130
131 return data
132 120
133 def setup_columns(self, *args, **kwargs): 121 def setup_columns(self, *args, **kwargs):
134 """ function to implement in the subclass which sets up the columns """ 122 """ function to implement in the subclass which sets up the columns """
@@ -140,33 +128,13 @@ class ToasterTable(TemplateView):
140 """ function to implement in the subclass which sets up the queryset""" 128 """ function to implement in the subclass which sets up the queryset"""
141 pass 129 pass
142 130
143 def add_filter(self, name, title, filter_actions): 131 def add_filter(self, table_filter):
144 """Add a filter to the table. 132 """Add a filter to the table.
145 133
146 Args: 134 Args:
147 name (str): Unique identifier of the filter. 135 table_filter: Filter instance
148 title (str): Title of the filter.
149 filter_actions: Actions for all the filters.
150 """ 136 """
151 self.filters[name] = { 137 self.filter_map.add_filter(table_filter.name, table_filter)
152 'title' : title,
153 'filter_actions' : filter_actions,
154 }
155
156 def make_filter_action(self, name, title, queryset_filter):
157 """
158 Utility to make a filter_action; queryset_filter is an instance
159 of QuerysetFilter or a function
160 """
161
162 action = {
163 'title' : title,
164 'name' : name,
165 }
166
167 self.filter_actions[name] = queryset_filter
168
169 return action
170 138
171 def add_column(self, title="", help_text="", 139 def add_column(self, title="", help_text="",
172 orderable=False, hideable=True, hidden=False, 140 orderable=False, hideable=True, hidden=False,
@@ -216,19 +184,41 @@ class ToasterTable(TemplateView):
216 return template.render(context) 184 return template.render(context)
217 185
218 def apply_filter(self, filters, **kwargs): 186 def apply_filter(self, filters, **kwargs):
187 """
188 Apply a filter submitted in the querystring to the ToasterTable
189
190 filters: (str) in the format:
191 '<filter name>:<action name>!<action params>'
192 where <action params> is optional
193
194 <filter name> and <action name> are used to look up the correct filter
195 in the ToasterTable's filter map; the <action params> are set on
196 TableFilterAction* before its filter is applied and may modify the
197 queryset returned by the filter
198 """
219 self.setup_filters(**kwargs) 199 self.setup_filters(**kwargs)
220 200
221 try: 201 try:
222 filter_name, filter_action = filters.split(':') 202 filter_name, action_name_and_params = filters.split(':')
203
204 action_name = None
205 action_params = None
206 if re.search('!', action_name_and_params):
207 action_name, action_params = action_name_and_params.split('!')
208 action_params = urllib.unquote_plus(action_params)
209 else:
210 action_name = action_name_and_params
223 except ValueError: 211 except ValueError:
224 return 212 return
225 213
226 if "all" in filter_action: 214 if "all" in action_name:
227 return 215 return
228 216
229 try: 217 try:
230 queryset_filter = self.filter_actions[filter_action] 218 table_filter = self.filter_map.get_filter(filter_name)
231 self.queryset = queryset_filter.filter(self.queryset) 219 action = table_filter.get_action(action_name)
220 action.set_params(action_params)
221 self.queryset = action.filter(self.queryset)
232 except KeyError: 222 except KeyError:
233 # pass it to the user - programming error here 223 # pass it to the user - programming error here
234 raise 224 raise