Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(37)

Side by Side Diff: appengine/monorail/framework/monorailrequest.py

Issue 1868553004: Open Source Monorail (Closed) Base URL: https://chromium.googlesource.com/infra/infra.git@master
Patch Set: Rebase Created 4 years, 8 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
« no previous file with comments | « appengine/monorail/framework/jsonfeed.py ('k') | appengine/monorail/framework/paginate.py » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
(Empty)
1 # Copyright 2016 The Chromium Authors. All rights reserved.
2 # Use of this source code is govered by a BSD-style
3 # license that can be found in the LICENSE file or at
4 # https://developers.google.com/open-source/licenses/bsd
5
6 """Classes to hold information parsed from a request.
7
8 To simplify our servlets and avoid duplication of code, we parse some
9 info out of the request as soon as we get it and then pass a MonorailRequest
10 object to the servlet-specific request handler methods.
11 """
12
13 import endpoints
14 import logging
15 import re
16 import urllib
17
18 from third_party import ezt
19
20 from google.appengine.api import app_identity
21 from google.appengine.api import oauth
22 from google.appengine.api import users
23
24 import webapp2
25
26 import settings
27 from framework import framework_constants
28 from framework import framework_views
29 from framework import permissions
30 from framework import sql
31 from framework import template_helpers
32 from proto import api_pb2_v1
33 from proto import user_pb2
34 from services import user_svc
35 from tracker import tracker_bizobj
36 from tracker import tracker_constants
37
38
39 _HOSTPORT_RE = re.compile('^[-a-z0-9.]+(:\d+)?$', re.I)
40
41
42 class AuthData(object):
43 """This object holds authentication data about a user.
44
45 This is used by MonorailRequest as it determines which user the
46 requester is authenticated as and fetches the user's data. It can
47 also be used to lookup perms for user IDs specified in issue fields.
48
49 Attributes:
50 user_id: The user ID of the user (or 0 if not signed in).
51 effective_ids: A set of user IDs that includes the signed in user's
52 direct user ID and the user IDs of all their user groups.
53 This set will be empty for anonymous users.
54 user_view: UserView object for the signed-in user.
55 user_pb: User object for the signed-in user.
56 email: email address for the user, or None.
57 """
58
59 def __init__(self):
60 self.user_id = 0
61 self.effective_ids = set()
62 self.user_view = None
63 self.user_pb = user_pb2.MakeUser()
64 self.email = None
65
66 @classmethod
67 def FromRequest(cls, cnxn, services):
68 """Determine auth information from the request and fetches user data.
69
70 If everything works and the user is signed in, then all of the public
71 attributes of the AuthData instance will be filled in appropriately.
72
73 Args:
74 cnxn: connection to the SQL database.
75 services: Interface to all persistence storage backends.
76
77 Returns:
78 A new AuthData object.
79 """
80 user = users.get_current_user()
81 if user is None:
82 return cls()
83 else:
84 # We create a User row for each user who visits the site.
85 # TODO(jrobbins): we should really only do it when they take action.
86 return cls.FromEmail(cnxn, user.email(), services, autocreate=True)
87
88 @classmethod
89 def FromEmail(cls, cnxn, email, services, autocreate=False):
90 """Determine auth information for the given user email address.
91
92 Args:
93 cnxn: monorail connection to the database.
94 email: string email address of the user.
95 services: connections to backend servers.
96 autocreate: set to True to create a new row in the Users table if needed.
97
98 Returns:
99 A new AuthData object.
100
101 Raises:
102 user_svc.NoSuchUserException: If the user of the email does not exist.
103 """
104 auth = cls()
105 auth.email = email
106 if email:
107 auth.user_id = services.user.LookupUserID(
108 cnxn, email, autocreate=autocreate)
109 assert auth.user_id
110
111 cls._FinishInitialization(cnxn, auth, services)
112 return auth
113
114 @classmethod
115 def FromUserID(cls, cnxn, user_id, services):
116 """Determine auth information for the given user ID.
117
118 Args:
119 cnxn: monorail connection to the database.
120 user_id: int user ID of the user.
121 services: connections to backend servers.
122
123 Returns:
124 A new AuthData object.
125 """
126 auth = cls()
127 auth.user_id = user_id
128 if auth.user_id:
129 auth.email = services.user.LookupUserEmail(cnxn, user_id)
130
131 cls._FinishInitialization(cnxn, auth, services)
132 return auth
133
134 @classmethod
135 def _FinishInitialization(cls, cnxn, auth, services):
136 """Fill in the test of the fields based on the user_id."""
137 # TODO(jrobbins): re-implement same_org
138 if auth.user_id:
139 auth.effective_ids = services.usergroup.LookupMemberships(
140 cnxn, auth.user_id)
141 auth.effective_ids.add(auth.user_id)
142 auth.user_pb = services.user.GetUser(cnxn, auth.user_id)
143 if auth.user_pb:
144 auth.user_view = framework_views.UserView(
145 auth.user_id, auth.email,
146 auth.user_pb.obscure_email)
147
148
149 class MonorailApiRequest(object):
150 """A class to hold information parsed from the Endpoints API request."""
151
152 # pylint: disable=attribute-defined-outside-init
153 def __init__(self, request, services):
154 requester = (
155 endpoints.get_current_user() or
156 oauth.get_current_user(
157 framework_constants.OAUTH_SCOPE))
158 requester_email = requester.email().lower()
159 self.cnxn = sql.MonorailConnection()
160 self.auth = AuthData.FromEmail(
161 self.cnxn, requester_email, services)
162 self.me_user_id = self.auth.user_id
163 self.viewed_username = None
164 self.viewed_user_auth = None
165 self.project_name = None
166 self.project = None
167 self.issue = None
168 self.config = None
169 self.granted_perms = set()
170
171 # query parameters
172 self.params = {
173 'can': 1,
174 'start': 0,
175 'num': 100,
176 'q': '',
177 'sort': '',
178 'groupby': '',
179 'projects': []}
180 self.use_cached_searches = True
181 self.warnings = []
182 self.errors = template_helpers.EZTError()
183 self.mode = None
184
185 if hasattr(request, 'projectId'):
186 self.project_name = request.projectId
187 self.project = services.project.GetProjectByName(
188 self.cnxn, self.project_name)
189 self.params['projects'].append(self.project_name)
190 self.config = services.config.GetProjectConfig(
191 self.cnxn, self.project_id)
192 if hasattr(request, 'additionalProject'):
193 self.params['projects'].extend(request.additionalProject)
194 self.params['projects'] = list(set(self.params['projects']))
195 if hasattr(request, 'issueId'):
196 self.issue = services.issue.GetIssueByLocalID(
197 self.cnxn, self.project_id, request.issueId)
198 self.granted_perms = tracker_bizobj.GetGrantedPerms(
199 self.issue, self.auth.effective_ids, self.config)
200 if hasattr(request, 'userId'):
201 self.viewed_username = request.userId.lower()
202 if self.viewed_username == 'me':
203 self.viewed_username = requester_email
204 self.viewed_user_auth = AuthData.FromEmail(
205 self.cnxn, self.viewed_username, services)
206 elif hasattr(request, 'groupName'):
207 self.viewed_username = request.groupName.lower()
208 try:
209 self.viewed_user_auth = AuthData.FromEmail(
210 self.cnxn, self.viewed_username, services)
211 except user_svc.NoSuchUserException:
212 self.viewed_user_auth = None
213 self.perms = permissions.GetPermissions(
214 self.auth.user_pb, self.auth.effective_ids, self.project)
215
216 # Build q.
217 if hasattr(request, 'q') and request.q:
218 self.params['q'] = request.q
219 if hasattr(request, 'publishedMax') and request.publishedMax:
220 self.params['q'] += ' opened<=%d' % request.publishedMax
221 if hasattr(request, 'publishedMin') and request.publishedMin:
222 self.params['q'] += ' opened>=%d' % request.publishedMin
223 if hasattr(request, 'updatedMax') and request.updatedMax:
224 self.params['q'] += ' modified<=%d' % request.updatedMax
225 if hasattr(request, 'updatedMin') and request.updatedMin:
226 self.params['q'] += ' modified>=%d' % request.updatedMin
227 if hasattr(request, 'owner') and request.owner:
228 self.params['q'] += ' owner:%s' % request.owner
229 if hasattr(request, 'status') and request.status:
230 self.params['q'] += ' status:%s' % request.status
231 if hasattr(request, 'label') and request.label:
232 self.params['q'] += ' label:%s' % request.label
233
234 if hasattr(request, 'can') and request.can:
235 if request.can == api_pb2_v1.CannedQuery.all:
236 self.params['can'] = 1
237 elif request.can == api_pb2_v1.CannedQuery.new:
238 self.params['can'] = 6
239 elif request.can == api_pb2_v1.CannedQuery.open:
240 self.params['can'] = 2
241 elif request.can == api_pb2_v1.CannedQuery.owned:
242 self.params['can'] = 3
243 elif request.can == api_pb2_v1.CannedQuery.reported:
244 self.params['can'] = 4
245 elif request.can == api_pb2_v1.CannedQuery.starred:
246 self.params['can'] = 5
247 elif request.can == api_pb2_v1.CannedQuery.to_verify:
248 self.params['can'] = 7
249 else: # Endpoints should have caught this.
250 raise InputException(
251 'Canned query %s is not supported.', request.can)
252 if hasattr(request, 'startIndex') and request.startIndex:
253 self.params['start'] = request.startIndex
254 if hasattr(request, 'maxResults') and request.maxResults:
255 self.params['num'] = request.maxResults
256 if hasattr(request, 'sort') and request.sort:
257 self.params['sort'] = request.sort
258
259 self.query_project_names = self.GetParam('projects')
260 self.group_by_spec = self.GetParam('groupby')
261 self.sort_spec = self.GetParam('sort')
262 self.query = self.GetParam('q')
263 self.can = self.GetParam('can')
264 self.start = self.GetParam('start')
265 self.num = self.GetParam('num')
266
267 @property
268 def project_id(self):
269 return self.project.project_id if self.project else None
270
271 def GetParam(self, query_param_name, default_value=None,
272 _antitamper_re=None):
273 return self.params.get(query_param_name, default_value)
274
275 def GetPositiveIntParam(self, query_param_name, default_value=None):
276 """Returns 0 if the user-provided value is less than 0."""
277 return max(self.GetParam(query_param_name, default_value=default_value),
278 0)
279
280
281 class MonorailRequest(object):
282 """A class to hold information parsed from the HTTP request.
283
284 The goal of MonorailRequest is to do almost all URL path and query string
285 procesing in one place, which makes the servlet code simpler.
286
287 Attributes:
288 cnxn: connection to the SQL databases.
289 logged_in_user_id: int user ID of the signed-in user, or None.
290 effective_ids: set of signed-in user ID and all their user group IDs.
291 user_pb: User object for the signed in user.
292 project_name: string name of the current project.
293 project_id: int ID of the current projet.
294 viewed_username: string username of the user whose profile is being viewed.
295 can: int "canned query" number to scope the user's search.
296 num: int number of results to show per pagination page.
297 start: int position in result set to show on this pagination page.
298 etc: there are many more, all read-only.
299 """
300
301 # pylint: disable=attribute-defined-outside-init
302 def __init__(self, params=None):
303 """Initialize the MonorailRequest object."""
304 self.form_overrides = {}
305 if params:
306 self.form_overrides.update(params)
307 self.warnings = []
308 self.errors = template_helpers.EZTError()
309 self.debug_enabled = False
310 self.use_cached_searches = True
311 self.cnxn = sql.MonorailConnection()
312
313 self.auth = AuthData() # Authentication info for logged-in user
314
315 self.project_name = None
316 self.project = None
317
318 self.viewed_username = None
319 self.viewed_user_auth = AuthData()
320
321 @property
322 def project_id(self):
323 return self.project.project_id if self.project else None
324
325 def CleanUp(self):
326 """Close the database connection so that the app does not run out."""
327 if self.cnxn:
328 self.cnxn.Close()
329 self.cnxn = None
330
331 def ParseRequest(self, request, services, prof, do_user_lookups=True):
332 """Parse tons of useful info from the given request object.
333
334 Args:
335 request: webapp2 Request object w/ path and query params.
336 services: connections to backend servers including DB.
337 prof: Profiler instance.
338 do_user_lookups: Set to False to disable lookups during testing.
339 """
340 with prof.Phase('basic parsing'):
341 self.request = request
342 self.current_page_url = request.url
343 self.current_page_url_encoded = urllib.quote_plus(self.current_page_url)
344
345 # Only accept a hostport from the request that looks valid.
346 if not _HOSTPORT_RE.match(request.host):
347 raise InputException('request.host looks funny: %r', request.host)
348
349 logging.info('Request: %s', self.current_page_url)
350
351 with prof.Phase('path parsing'):
352 viewed_user_val, self.project_name = _ParsePathIdentifiers(
353 self.request.path)
354 self.viewed_username = _GetViewedEmail(
355 viewed_user_val, self.cnxn, services)
356 with prof.Phase('qs parsing'):
357 self._ParseQueryParameters()
358 with prof.Phase('overrides parsing'):
359 self._ParseFormOverrides()
360
361 if not self.project: # It can be already set in unit tests.
362 self._LookupProject(services, prof)
363 if do_user_lookups:
364 if self.viewed_username:
365 self._LookupViewedUser(services, prof)
366 self._LookupLoggedInUser(services, prof)
367 # TODO(jrobbins): re-implement HandleLurkerViewingSelf()
368
369 prod_debug_allowed = self.perms.HasPerm(
370 permissions.VIEW_DEBUG, self.auth.user_id, None)
371 self.debug_enabled = (request.params.get('debug') and
372 (settings.dev_mode or prod_debug_allowed))
373 # temporary option for perf testing on staging instance.
374 if request.params.get('disable_cache'):
375 if settings.dev_mode or 'staging' in request.host:
376 self.use_cached_searches = False
377
378 def _ParseQueryParameters(self):
379 """Parse and convert all the query string params used in any servlet."""
380 self.start = self.GetPositiveIntParam('start', default_value=0)
381 self.num = self.GetPositiveIntParam('num', default_value=100)
382 # Prevent DoS attacks that try to make us serve really huge result pages.
383 self.num = min(self.num, settings.max_artifact_search_results_per_page)
384
385 self.invalidation_timestep = self.GetIntParam(
386 'invalidation_timestep', default_value=0)
387
388 self.continue_issue_id = self.GetIntParam(
389 'continue_issue_id', default_value=0)
390 self.redir = self.GetParam('redir')
391
392 # Search scope, a.k.a., canned query ID
393 # TODO(jrobbins): make configurable
394 self.can = self.GetIntParam(
395 'can', default_value=tracker_constants.OPEN_ISSUES_CAN)
396
397 # Search query
398 self.query = self.GetParam('q', default_value='').strip()
399
400 # Sorting of search results (needed for result list and flipper)
401 self.sort_spec = self.GetParam(
402 'sort', default_value='',
403 antitamper_re=framework_constants.SORTSPEC_RE)
404
405 # Note: This is set later in request handling by ComputeColSpec().
406 self.col_spec = None
407
408 # Grouping of search results (needed for result list and flipper)
409 self.group_by_spec = self.GetParam(
410 'groupby', default_value='',
411 antitamper_re=framework_constants.SORTSPEC_RE)
412
413 # For issue list and grid mode.
414 self.cursor = self.GetParam('cursor')
415 self.preview = self.GetParam('preview')
416 self.mode = self.GetParam('mode', default_value='list')
417 self.x = self.GetParam('x', default_value='')
418 self.y = self.GetParam('y', default_value='')
419 self.cells = self.GetParam('cells', default_value='ids')
420
421 # For the dashboard and issue lists included in the dashboard.
422 self.ajah = self.GetParam('ajah') # AJAH = Asychronous Javascript And HTML
423 self.table_title = self.GetParam('table_title')
424 self.panel_id = self.GetIntParam('panel')
425
426 # For pagination of updates lists
427 self.before = self.GetPositiveIntParam('before')
428 self.after = self.GetPositiveIntParam('after')
429
430 # For cron tasks and backend calls
431 self.lower_bound = self.GetIntParam('lower_bound')
432 self.upper_bound = self.GetIntParam('upper_bound')
433 self.shard_id = self.GetIntParam('shard_id')
434
435 # For specifying which objects to operate on
436 self.local_id = self.GetIntParam('id')
437 self.local_id_list = self.GetIntListParam('ids')
438 self.seq = self.GetIntParam('seq')
439 self.aid = self.GetIntParam('aid')
440 self.specified_user_id = self.GetIntParam('u', default_value=0)
441 self.specified_logged_in_user_id = self.GetIntParam(
442 'logged_in_user_id', default_value=0)
443 self.specified_me_user_id = self.GetIntParam(
444 'me_user_id', default_value=0)
445 self.specified_project = self.GetParam('project')
446 self.specified_project_id = self.GetIntParam('project_id')
447 self.query_project_names = self.GetListParam('projects', default_value=[])
448 self.template_name = self.GetParam('template')
449 self.component_path = self.GetParam('component')
450 self.field_name = self.GetParam('field')
451
452 # For image attachments
453 self.inline = bool(self.GetParam('inline'))
454 self.thumb = bool(self.GetParam('thumb'))
455
456 # For JS callbacks
457 self.token = self.GetParam('token')
458 self.starred = bool(self.GetIntParam('starred'))
459
460 # For issue reindexing utility servlet
461 self.auto_submit = self.GetParam('auto_submit')
462
463 def _ParseFormOverrides(self):
464 """Support deep linking by allowing the user to set form fields via QS."""
465 allowed_overrides = {
466 'template_name': self.GetParam('template_name'),
467 'initial_summary': self.GetParam('summary'),
468 'initial_description': (self.GetParam('description') or
469 self.GetParam('comment')),
470 'initial_comment': self.GetParam('comment'),
471 'initial_status': self.GetParam('status'),
472 'initial_owner': self.GetParam('owner'),
473 'initial_cc': self.GetParam('cc'),
474 'initial_blocked_on': self.GetParam('blockedon'),
475 'initial_blocking': self.GetParam('blocking'),
476 'initial_merge_into': self.GetIntParam('mergeinto'),
477 'initial_components': self.GetParam('components'),
478
479 # For the people pages
480 'initial_add_members': self.GetParam('add_members'),
481 'initially_expanded_form': ezt.boolean(self.GetParam('expand_form')),
482
483 # For user group admin pages
484 'initial_name': (self.GetParam('group_name') or
485 self.GetParam('proposed_project_name')),
486 }
487
488 # Only keep the overrides that were actually provided in the query string.
489 self.form_overrides.update(
490 (k, v) for (k, v) in allowed_overrides.iteritems()
491 if v is not None)
492
493 def _LookupViewedUser(self, services, prof):
494 """Get information about the viewed user (if any) from the request."""
495 try:
496 with prof.Phase('get viewed user, if any'):
497 self.viewed_user_auth = AuthData.FromEmail(
498 self.cnxn, self.viewed_username, services, autocreate=False)
499 except user_svc.NoSuchUserException:
500 logging.info('could not find user %r', self.viewed_username)
501 webapp2.abort(404, 'user not found')
502
503 if not self.viewed_user_auth.user_id:
504 webapp2.abort(404, 'user not found')
505
506 def _LookupProject(self, services, prof):
507 """Get information about the current project (if any) from the request."""
508 with prof.Phase('get current project, if any'):
509 if not self.project_name:
510 logging.info('no project_name, so no project')
511 else:
512 self.project = services.project.GetProjectByName(
513 self.cnxn, self.project_name)
514 if not self.project:
515 webapp2.abort(404, 'invalid project')
516
517 def _LookupLoggedInUser(self, services, prof):
518 """Get information about the signed-in user (if any) from the request."""
519 with prof.Phase('get user info, if any'):
520 self.auth = AuthData.FromRequest(self.cnxn, services)
521 self.me_user_id = (self.GetIntParam('me') or
522 self.viewed_user_auth.user_id or self.auth.user_id)
523
524 with prof.Phase('looking up signed in user permissions'):
525 self.perms = permissions.GetPermissions(
526 self.auth.user_pb, self.auth.effective_ids, self.project)
527
528 def ComputeColSpec(self, config):
529 """Set col_spec based on param, default in the config, or site default."""
530 if self.col_spec is not None:
531 return # Already set.
532 default_col_spec = ''
533 if config:
534 default_col_spec = config.default_col_spec
535
536 col_spec = self.GetParam(
537 'colspec', default_value=default_col_spec,
538 antitamper_re=framework_constants.COLSPEC_RE)
539
540 if not col_spec:
541 # If col spec is still empty then default to the global col spec.
542 col_spec = tracker_constants.DEFAULT_COL_SPEC
543
544 self.col_spec = ' '.join(ParseColSpec(col_spec))
545
546 def PrepareForReentry(self, echo_data):
547 """Expose the results of form processing as if it was a new GET.
548
549 This method is called only when the user submits a form with invalid
550 information which they are being asked to correct it. Updating the MR
551 object allows the normal servlet get() method to populate the form with
552 the entered values and error messages.
553
554 Args:
555 echo_data: dict of {page_data_key: value_to_reoffer, ...} that will
556 override whatever HTML form values are nomally shown to the
557 user when they initially view the form. This allows them to
558 fix user input that was not valid.
559 """
560 self.form_overrides.update(echo_data)
561
562 def GetParam(self, query_param_name, default_value=None,
563 antitamper_re=None):
564 """Get a query parameter from the URL as a utf8 string."""
565 value = self.request.params.get(query_param_name)
566 assert value is None or isinstance(value, unicode)
567 using_default = value is None
568 if using_default:
569 value = default_value
570
571 if antitamper_re and not antitamper_re.match(value):
572 if using_default:
573 logging.error('Default value fails antitamper for %s field: %s',
574 query_param_name, value)
575 else:
576 logging.info('User seems to have tampered with %s field: %s',
577 query_param_name, value)
578 raise InputException()
579
580 return value
581
582 def GetIntParam(self, query_param_name, default_value=None):
583 """Get an integer param from the URL or default."""
584 value = self.request.params.get(query_param_name)
585 if value is None:
586 return default_value
587
588 try:
589 return int(value)
590 except (TypeError, ValueError):
591 return default_value
592
593 def GetPositiveIntParam(self, query_param_name, default_value=None):
594 """Returns 0 if the user-provided value is less than 0."""
595 return max(self.GetIntParam(query_param_name, default_value=default_value),
596 0)
597
598 def GetListParam(self, query_param_name, default_value=None):
599 """Get a list of strings from the URL or default."""
600 params = self.request.params.get(query_param_name)
601 if params is None:
602 return default_value
603 if not params:
604 return []
605 return params.split(',')
606
607 def GetIntListParam(self, query_param_name, default_value=None):
608 """Get a list of ints from the URL or default."""
609 param_list = self.GetListParam(query_param_name)
610 if param_list is None:
611 return default_value
612
613 try:
614 return [int(p) for p in param_list]
615 except (TypeError, ValueError):
616 return default_value
617
618
619 def _ParsePathIdentifiers(path):
620 """Parse out the workspace being requested (if any).
621
622 Args:
623 path: A string beginning with the request's path info.
624
625 Returns:
626 (viewed_user_val, project_name).
627 """
628 viewed_user_val = None
629 project_name = None
630
631 # Strip off any query params
632 split_path = path.lstrip('/').split('?')[0].split('/')
633
634 if len(split_path) >= 2:
635 if split_path[0] == 'p':
636 project_name = split_path[1]
637 if split_path[0] == 'u':
638 viewed_user_val = urllib.unquote(split_path[1])
639 if split_path[0] == 'g':
640 viewed_user_val = urllib.unquote(split_path[1])
641
642 return viewed_user_val, project_name
643
644
645 def _GetViewedEmail(viewed_user_val, cnxn, services):
646 """Returns the viewed user's email.
647
648 Args:
649 viewed_user_val: Could be either int (user_id) or str (email).
650 cnxn: connection to the SQL database.
651 services: Interface to all persistence storage backends.
652
653 Returns:
654 viewed_email
655 """
656 if not viewed_user_val:
657 return None
658
659 try:
660 viewed_userid = int(viewed_user_val)
661 viewed_email = services.user.LookupUserEmail(cnxn, viewed_userid)
662 if not viewed_email:
663 logging.info('userID %s not found', viewed_userid)
664 webapp2.abort(404, 'user not found')
665 except ValueError:
666 viewed_email = viewed_user_val
667
668 return viewed_email
669
670
671 def ParseColSpec(col_spec):
672 """Split a string column spec into a list of column names.
673
674 Args:
675 col_spec: a unicode string containing a list of labels.
676
677 Returns:
678 A list of the extracted labels. Non-alphanumeric
679 characters other than the period will be stripped from the text.
680 """
681 return framework_constants.COLSPEC_COL_RE.findall(col_spec)
682
683
684 class Error(Exception):
685 """Base class for errors from this module."""
686 pass
687
688
689 class InputException(Error):
690 """Error in user input processing."""
691 pass
OLDNEW
« no previous file with comments | « appengine/monorail/framework/jsonfeed.py ('k') | appengine/monorail/framework/paginate.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698