pybald 0.5.8__py3-none-any.whl

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 (43) hide show
  1. pybald/__init__.py +230 -0
  2. pybald/config/__init__.py +0 -0
  3. pybald/config/constants.py +40 -0
  4. pybald/core/__init__.py +0 -0
  5. pybald/core/controllers.py +313 -0
  6. pybald/core/default_templates/forms/fieldset.form.template +9 -0
  7. pybald/core/default_templates/forms/grid.form.template +24 -0
  8. pybald/core/default_templates/stack_trace.html.template +137 -0
  9. pybald/core/django/__init__.py +0 -0
  10. pybald/core/django/paginator.py +148 -0
  11. pybald/core/forms.py +34 -0
  12. pybald/core/helpers.py +279 -0
  13. pybald/core/logs.py +100 -0
  14. pybald/core/middleware/__init__.py +0 -0
  15. pybald/core/middleware/db_middleware.py +51 -0
  16. pybald/core/middleware/errors.py +95 -0
  17. pybald/core/middleware/sessions.py +34 -0
  18. pybald/core/middleware/users.py +43 -0
  19. pybald/core/models.py +285 -0
  20. pybald/core/page.py +60 -0
  21. pybald/core/router.py +213 -0
  22. pybald/core/templates.py +153 -0
  23. pybald/db/__init__.py +1 -0
  24. pybald/db/cache.py +99 -0
  25. pybald/db/db_engine.py +22 -0
  26. pybald/db/ext.py +158 -0
  27. pybald/db/instrumented_proxy.py +50 -0
  28. pybald/db/magic.py +115 -0
  29. pybald/default.py +37 -0
  30. pybald/project_template/readme.rst +0 -0
  31. pybald/test.py +56 -0
  32. pybald/util/__init__.py +8 -0
  33. pybald/util/command_line.py +24 -0
  34. pybald/util/console.py +92 -0
  35. pybald/util/context.py +147 -0
  36. pybald/util/dev_server.py +29 -0
  37. pybald/util/static_serve.py +67 -0
  38. pybald/util/text.py +80 -0
  39. pybald/watch.py +45 -0
  40. pybald-0.5.8.dist-info/METADATA +60 -0
  41. pybald-0.5.8.dist-info/RECORD +43 -0
  42. pybald-0.5.8.dist-info/WHEEL +4 -0
  43. pybald-0.5.8.dist-info/licenses/LICENSE +54 -0
pybald/__init__.py ADDED
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env python
2
+ # encoding: utf-8
3
+ # __init__.py
4
+ '''
5
+ Pybald
6
+ ======
7
+
8
+ An MVC-like framework using many best-of-breed components (i.e. webob,
9
+ sqlalchemy).
10
+
11
+ (c) 2015 Michael Kowalchik
12
+ MIT License, see LICENSE file
13
+ '''
14
+ import types
15
+ import sys
16
+ import os
17
+ from pybald.util.context import AppContext
18
+ import logging
19
+ from .default import default_config
20
+ from collections import namedtuple
21
+ log = logging.getLogger(__name__)
22
+
23
+ __version__ = '0.5.8'
24
+
25
+
26
+ def read_config_module(root_path='.', filename='project.py'):
27
+ '''Read a configuration module and append it's path to sys.path'''
28
+ # since the config module may have relative imports
29
+ # add the _config files_ path to root before executing the config
30
+ if root_path not in sys.path:
31
+ sys.path.insert(1, root_path)
32
+ filename = os.path.join(root_path, filename)
33
+ config_module = types.ModuleType("config")
34
+ config_module.__dict__['__file__'] = filename
35
+ config_module.__dict__['path'] = root_path
36
+ with open(filename) as config_file:
37
+ exec(compile(config_file.read(), filename, 'exec'),
38
+ config_module.__dict__)
39
+ return config_module
40
+
41
+
42
+ class Unconfigured(object):
43
+ def __getattr__(self, key):
44
+ raise RuntimeError("Pybald is not configured, you must run "
45
+ "pybald.configure() before context.{0} can"
46
+ " be used".format(key))
47
+
48
+
49
+ class DefaultApp(dict):
50
+ def __init__(self, *pargs, **kargs):
51
+ self.register('config', {})
52
+ self.register('controller_registry', [])
53
+ # self.register('model_registry', [])
54
+ self.unconfigured = True
55
+ super(DefaultApp, self).__init__(*pargs, **kargs)
56
+
57
+ # def __getattr__(self, key):
58
+ # if key in ('models', 'engine', 'metdata'):
59
+ # raise RuntimeError("Pybald is not configured, you must run "
60
+ # "pybald.configure() before context.{0} can"
61
+ # " be used".format(key))
62
+ # else:
63
+ # return super(DefaultApp, self).__getattr__(key)
64
+
65
+ def register(self, key, value):
66
+ self[key] = value
67
+ setattr(self, key, self[key])
68
+
69
+ # unconfigured application stack context
70
+ context = AppContext()
71
+ # register the context as a module
72
+ sys.modules['pybald.context'] = context
73
+ # push the default placeholder app onto the context stack
74
+ # the default allows for temporary storage of elements required
75
+ # for bootstrapping (and eliminates some sequencing requirements
76
+ # for configuration)
77
+ context._push(DefaultApp())
78
+
79
+ # the template engine and database session are built at config time
80
+ context_template = '''
81
+ from pybald.core.templates import TemplateEngine
82
+ from pybald.util.command_line import start
83
+ from pybald.core.models import ContextBoundModels
84
+ from pybald.db.db_engine import create_dump_engine
85
+
86
+ render = TemplateEngine()
87
+ dump_engine = create_dump_engine()
88
+ if config.database_engine_uri:
89
+ models = ContextBoundModels()
90
+ db = models.db
91
+ else:
92
+ models = Unconfigured()
93
+ db = Unconfigured()
94
+
95
+ def register(key, value):
96
+ globals()[key] = value
97
+
98
+ '''
99
+
100
+
101
+ def create_config_dict_from_module(module):
102
+ '''Converts a module into a dictionary filtering out common module
103
+ level attributes'''
104
+ return {key: getattr(module, key) for key in module.__dict__.keys() if
105
+ not key.startswith('_') and key not in ('sys', 'os')}
106
+
107
+
108
+ def create_config_object(config_object):
109
+ '''Convert a config dictionary to a config object named tuple
110
+ and combines it with the default configuration dictionary.'''
111
+ keys = set(default_config.keys()) | set(config_object.keys())
112
+ ConfigObject = namedtuple("ConfigObject", keys)
113
+ return ConfigObject(**dict(list(default_config.items()) +
114
+ list(config_object.items())))
115
+
116
+
117
+ def bootstrap(bootstrap_file=None):
118
+ '''Execute the python file passed in. It's assumed that the bootstrap
119
+ python module file will execute configure() before returning although
120
+ not strictly necessary
121
+
122
+ This is intended for complicated projects that need to do a lot of
123
+ dynamic stuff before configuring the project. It's primarily a convenience
124
+ hook that sets the sys.path to the current working directory before
125
+ executing the module import.
126
+
127
+ Use of this method should be rare, ask yourself if you really need it :)
128
+ '''
129
+ # since bootstrap can be anywhere, add bootstrap to sys.path as well
130
+ if os.getcwd() not in sys.path:
131
+ sys.path.insert(1, os.getcwd())
132
+ possible_root_path = os.getcwd()
133
+ root_path, filename = os.path.split(os.path.join(possible_root_path,
134
+ bootstrap_file))
135
+ if root_path not in sys.path:
136
+ sys.path.insert(1, root_path)
137
+ filename = os.path.join(root_path, filename)
138
+ bootstrap_module = types.ModuleType("bootstrap")
139
+ bootstrap_module.__dict__['__file__'] = filename
140
+ with open(filename) as bootstrap_file:
141
+ exec(compile(bootstrap_file.read(), filename, 'exec'),
142
+ bootstrap_module.__dict__)
143
+ return bootstrap_module
144
+
145
+
146
+ def configure(name=None, config_file=None, config_object=None, **kargs):
147
+ '''
148
+ Generate a dynamic context module that's pushed / popped on
149
+ the application context stack.
150
+ '''
151
+ script_name = sys.argv[0]
152
+ # always add current working directory to the path regardless
153
+ # was trying to avoid this but too much path munging happening
154
+ if os.getcwd() not in sys.path:
155
+ sys.path.insert(1, os.getcwd())
156
+
157
+ if config_object:
158
+ if 'project_name' not in config_object:
159
+ config_object['project_name'] = script_name
160
+ # create a named tuple that's the combo of default plus input dict
161
+ root_path = config_object['path'] = config_object.get('path',
162
+ os.getcwd())
163
+ config = create_config_object(config_object)
164
+ # add root path to config?
165
+ elif config_file:
166
+ # possible root path if relative filename specified
167
+ possible_root_path = os.getcwd()
168
+ root_path, filename = os.path.split(os.path.join(possible_root_path,
169
+ config_file))
170
+ try:
171
+ config_module = read_config_module(root_path=root_path,
172
+ filename=filename)
173
+ except IOError:
174
+ log.exception("Config Error:\n"
175
+ "File Error or File not "
176
+ "found\n{0}".format(config_file))
177
+ sys.exit(1)
178
+ config = create_config_object(create_config_dict_from_module(config_module))
179
+ elif kargs:
180
+ if 'project_name' not in kargs:
181
+ kargs['project_name'] = script_name
182
+
183
+ root_path = kargs['path'] = kargs.get('path',
184
+ os.getcwd())
185
+ config = create_config_object(kargs)
186
+ else:
187
+ root_path = os.getcwd()
188
+ log.warning("Warning: Using current path for the config "
189
+ "file {0}".format(root_path))
190
+ if root_path not in sys.path:
191
+ sys.path.insert(1, root_path)
192
+ try:
193
+ config_module = read_config_module(root_path=root_path,
194
+ filename='project.py')
195
+ log.warning("Found project.py in default path, "
196
+ "using for the config file")
197
+ except IOError:
198
+ config = create_config_object(default_config)
199
+ log.warning("No config file, using default pybald configuration")
200
+ else:
201
+ config = create_config_object(create_config_dict_from_module(config_module))
202
+
203
+ # if the discovered root_path is not in sys.path, add it in the least ugly
204
+ # way possible
205
+ if root_path not in sys.path:
206
+ sys.path.insert(1, root_path)
207
+
208
+ if config.debug:
209
+ from pybald.core.logs import default_debug_log
210
+ default_debug_log()
211
+
212
+ new_context = types.ModuleType("context")
213
+
214
+ if context._proxied() and hasattr(context._proxied(), 'unconfigured'):
215
+ # if we're at the root, consume any placeholder values
216
+ placeholder = context._pop()
217
+ new_context.__dict__.update(placeholder)
218
+ else:
219
+ new_context.__dict__['controller_registry'] = []
220
+ # always set the runtime config
221
+ new_context.__dict__['path'] = root_path
222
+ new_context.__dict__['config'] = config
223
+ new_context.__dict__['name'] = name
224
+ new_context.__dict__['__file__'] = None
225
+ new_context.__dict__['Unconfigured'] = Unconfigured
226
+ # new_context.__dict__['__path__'] = None
227
+ # now execute the app context with this config
228
+ context._push(new_context)
229
+ exec(compile(context_template, '<string>', 'exec'), new_context.__dict__)
230
+ return new_context
File without changes
@@ -0,0 +1,40 @@
1
+ '''The constants helper module is to allow for creating 'safer' ini files for
2
+ values that will be injected into templates. The basic rationale is that there
3
+ are often values that you want non-technical people to control (phone numbers,
4
+ marketing claims, addresses) and similar values. Using constants allows these
5
+ individuals to use friendlier ini format but still have the values available
6
+ for page templates.
7
+ '''
8
+ # TODO: The constants helper object should probably do something 'safe' when
9
+ # constants are deleted, maybe issuing a warning. Just a saftey net to avoid
10
+ # breaking templates if sections or values are accidently deleted.
11
+ try:
12
+ from ConfigParser import ConfigParser as configparser
13
+ except ImportError:
14
+ from configparser import ConfigParser as configparser
15
+ from collections import namedtuple
16
+
17
+ # generate constants for templates
18
+ # this is a little squirrley looking, but all it does is use the config
19
+ # parser to read an INI file and then generates a two level set of named
20
+ # tuples to surface the data. Inside the template the section/data of the
21
+ # constants ini is dereferenced using constants.SECTION.KEY
22
+
23
+
24
+ def read(filename='constants.ini', constants_file=None):
25
+ '''Read a constants ini file and return the constants helper object.
26
+
27
+ When added to the page_options config, these constants can be used inside
28
+ the templates where the section/data of the constants ini is dereferenced
29
+ using constants.SECTION.KEY.
30
+ '''
31
+ config = configparser()
32
+ if constants_file:
33
+ config.read_file(constants_file)
34
+ else:
35
+ config.read(filename)
36
+ constants = namedtuple("Constant", config.sections())._make([
37
+ namedtuple('Field', dict(config.items(section)).keys()
38
+ )._make(dict(config.items(section)).values())
39
+ for section in config.sections() ])
40
+ return constants
File without changes
@@ -0,0 +1,313 @@
1
+ #!/usr/bin/env python
2
+ # encoding: utf-8
3
+ from six import with_metaclass
4
+ from functools import wraps
5
+ from webob import Request, Response, exc
6
+ import re
7
+ from pybald.util import camel_to_underscore
8
+ from routes import redirect_to
9
+ from pybald import context
10
+
11
+ import json
12
+ import random
13
+
14
+ import uuid
15
+ import logging
16
+ console = logging.getLogger(__name__)
17
+
18
+ controller_pattern = re.compile(r'(\w+)Controller')
19
+
20
+
21
+ class CSRFValidationFailure(exc.HTTPForbidden):
22
+ pass
23
+
24
+
25
+ def csrf_protected(action_func):
26
+ """
27
+ Decorator to add CSRF (cross-site request forgery) protection to POST
28
+ requests on an action. To use, include this decorator and provide the
29
+ token in any submitted forms.
30
+
31
+ For example, in the controller, do:
32
+
33
+ @action
34
+ @csrf_protected
35
+ def my_action(self, req):
36
+ ...
37
+
38
+ And in the template:
39
+
40
+ <form action='my_action' method='post'>
41
+ ${csrf_input}
42
+ ...
43
+ </form>
44
+ """
45
+ CSRF_TOKEN_POST_VARIABLE = '__csrf_token__'
46
+
47
+ @wraps(action_func)
48
+ def replacement(self, req):
49
+ if req.method == 'POST':
50
+ try:
51
+ if CSRF_TOKEN_POST_VARIABLE not in req.POST:
52
+ raise CSRFValidationFailure(
53
+ ("No %r value found in POST data. Please make sure "
54
+ "that a ${csrf_input} is used in the form template.")
55
+ % CSRF_TOKEN_POST_VARIABLE)
56
+
57
+ if not self.session.stash.get("csrf_token"):
58
+ raise CSRFValidationFailure(
59
+ "CSRF validation failed: no validation token available "
60
+ "in this session.")
61
+
62
+ provided_csrf_token = req.POST.get(CSRF_TOKEN_POST_VARIABLE)
63
+
64
+ if provided_csrf_token != self.session.stash.get("csrf_token"):
65
+ raise CSRFValidationFailure(
66
+ "CSRF validation failed: token mismatch.")
67
+
68
+ else:
69
+ # success! wipe out the used token
70
+ self.session.stash(csrf_token=None)
71
+ #del self.session.csrf_token
72
+
73
+ except CSRFValidationFailure:
74
+ # gentle mode, redirect to GET version of the page
75
+ # return self._redirect_to(req.path_qs)
76
+ raise
77
+
78
+ # always stash a new token
79
+ new_token = str(uuid.uuid4()).replace("-", "")
80
+ self.session.stash(csrf_token=new_token)
81
+
82
+ self.csrf_input = ("<input type='hidden' name='%s' value='%s' />" % (
83
+ CSRF_TOKEN_POST_VARIABLE, new_token))
84
+
85
+ return action_func(self, req)
86
+ return replacement
87
+
88
+
89
+ # a no-op placeholder
90
+ def noop_func(*pargs, **kargs):
91
+ '''Do nothing'''
92
+ pass
93
+
94
+
95
+ def get_template_name(instance, method_name):
96
+ '''
97
+ Defines the template id to match against.
98
+
99
+ :param instance: the instance to generate a template for
100
+ :param method_name: the method to combine with the instance class
101
+
102
+ template path = controller name + '/' + action name, except in the case
103
+ of index. If the template is specified as part of the processed object
104
+ return that, short circuiting any other template name processing
105
+
106
+ This form may removed later, considered a candidate for deprecation
107
+ '''
108
+ template_id = getattr(instance, 'template_id', None)
109
+ if template_id:
110
+ return template_id
111
+ # build a default template name if one isn't explicitly set
112
+ try:
113
+ template_root_name = camel_to_underscore(
114
+ controller_pattern.search(instance.__class__.__name__
115
+ ).group(1))
116
+ except AttributeError:
117
+ template_root_name = ''
118
+ return "/".join(filter(None, [template_root_name, method_name]))
119
+
120
+
121
+ # action / method decorator
122
+ def action(method):
123
+ '''
124
+ Decorates methods that are WSGI apps to turn them into pybald-style actions.
125
+
126
+ :param method: A method to turn into a pybald-style action.
127
+
128
+ This decorator is usually used to take the method of a controller instance
129
+ and add some syntactic sugar around it to allow the method to use WebOb
130
+ Request and Response objects. It will work with any method that
131
+ implements the WSGI spec.
132
+
133
+ It allows actions to work with WebOb request / response objects and handles
134
+ default behaviors, such as displaying the view when nothing is returned,
135
+ or setting up a plain text Response if a string is returned. It also
136
+ assigns instance variables from the ``pybald.extension`` environ variables
137
+ that can be set from other parts of the WSGI pipeline.
138
+
139
+ This decorator is optional but recommended for making working
140
+ with requests and responses easier.
141
+ '''
142
+ # the default template name is the controller class + method name
143
+ # the method name is pulled during decoration and stored for use
144
+ # in template lookups
145
+ template_name = method.__name__
146
+ # special case where 'call' or 'index' use the base class name
147
+ # for the template otherwise use the base name
148
+ if template_name in ('index', '__call__'):
149
+ template_name = ''
150
+
151
+ @wraps(method)
152
+ def action_wrapper(self, environ, start_response):
153
+ req = Request(environ)
154
+ # add any url variables as members of the controller
155
+ for varname, value in req.urlvars.items():
156
+ # Set the controller object to contain the url variables
157
+ # parsed from the dispatcher / router
158
+ setattr(self, varname, value)
159
+
160
+ # add the pybald extension dict to the controller
161
+ # object
162
+ for key, value in req.environ.setdefault('pybald.extension', {}).items():
163
+ setattr(self, key, value)
164
+
165
+ # TODO: fixme this is a hack
166
+ setattr(self, 'request', req)
167
+ setattr(self, 'request_url', req.url)
168
+
169
+ # set pre/post/view to a no-op if they don't exist
170
+ pre = getattr(self, '_pre', noop_func)
171
+ post = getattr(self, '_post', noop_func)
172
+
173
+ # set the template_id for this request
174
+ self.template_id = get_template_name(self, template_name)
175
+
176
+ # The response is either the controllers _pre code, whatever
177
+ # is returned from the controller
178
+ # or the view. So pre has precedence over
179
+ # the return which has precedence over the view
180
+ resp = (pre(req) or
181
+ method(self, req) or
182
+ context.render(template=self.template_id,
183
+ data=self.__dict__ or {}))
184
+ # if the response is currently a string
185
+ # wrap it in a response object
186
+ if isinstance(resp, str) or isinstance(resp, bytes):
187
+ resp = Response(body=resp, charset="utf-8")
188
+ # run the controllers post code
189
+ post(req, resp)
190
+ return resp(environ, start_response)
191
+ return action_wrapper
192
+
193
+
194
+ # def caching_pre(keys, method_name, prefix=''):
195
+ # '''Decorator for pybald _pre to return cached responses if available.'''
196
+ # if keys is None:
197
+ # keys = []
198
+
199
+ # def pre_wrapper(pre):
200
+ # def replacement(self, req):
201
+ # val = ":".join([prefix] + [str(getattr(self, k, '')) for
202
+ # k in keys] + [method_name])
203
+ # self.cache_key = base64.urlsafe_b64encode(hashlib.md5(val).digest())
204
+ # resp = project.mc.get(self.cache_key)
205
+ # if resp:
206
+ # return resp
207
+ # return pre(req)
208
+ # return replacement
209
+ # return pre_wrapper
210
+
211
+
212
+ # def caching_post(time=0):
213
+ # '''Decorator for pybald _post to cache/store responses.'''
214
+ # def post_wrapper(post):
215
+ # def replacement(self, req, resp):
216
+ # post(req, resp)
217
+ # # only cache 2XX or 4XX responses
218
+ # if (200 <= resp.status_code < 300) or (400 <= resp.status_code < 500):
219
+ # if 'X-Cache' not in resp.headers:
220
+ # resp.headerlist.append(('X-Cache', 'MISS'))
221
+ # project.mc.set(self.cache_key, resp, time)
222
+ # else:
223
+ # resp.headers['X-Cache'] = 'HIT'
224
+ # return replacement
225
+ # return post_wrapper
226
+
227
+ # regenerate a content_cache_prefix on every reload so that content will
228
+ # be force loaded after any full application restart
229
+ # This provides a way to cache static content for the duration of the
230
+ # application lifespan.
231
+ content_cache_prefix = hex(random.randrange(0, 2 ** 32 - 1))
232
+
233
+
234
+ # # memcache for actions
235
+ # def action_cached(prefix=content_cache_prefix, keys=None, time=0):
236
+ # '''
237
+ # Wrap actions and return pre-generated responses when appropriate.
238
+ # '''
239
+ # if keys is None:
240
+ # keys = []
241
+
242
+ # # def cached_wrapper(my_action_method):
243
+ # # @wraps(my_action_method)
244
+ # # def replacement(self, environ, start_response):
245
+ # # # bind newly wrapped methods to self
246
+ # # self._pre = caching_pre(keys,
247
+ # # my_action_method.__name__,
248
+ # # prefix=prefix)(self._pre
249
+ # # ).__get__(self, self.__class__)
250
+ # # self._post = caching_post(time)(self._post
251
+ # # ).__get__(self, self.__class__)
252
+ # # return my_action_method(self, environ, start_response)
253
+ # # # don't enable caching if requested
254
+ # # if project.DISABLE_STATIC_CONTENT_CACHE:
255
+ # # return my_action_method
256
+ # # return replacement
257
+ # # return cached_wrapper
258
+
259
+ class RegistryMount(type):
260
+ '''
261
+ A registry creating metaclass that keeps track of all defined classes that
262
+ inherit from a base class using this metaclass.
263
+ '''
264
+ # lifted almost verbatim from: http://martyalchin.com/2008/jan/10/simple-plugin-framework/
265
+ def __init__(cls, name, bases, attrs):
266
+ try:
267
+ cls.registry.append(cls)
268
+ except AttributeError:
269
+ # this is processing the first class (the mount point)
270
+ cls.registry = context.controller_registry
271
+
272
+ return super(RegistryMount, cls).__init__(name, bases, attrs)
273
+
274
+
275
+ class Controller(with_metaclass(RegistryMount, object)):
276
+ '''Base Controller that provides a registry mount
277
+
278
+ The registry keeps track of all countrollers defined in the project. The
279
+ Controller class also has some minor convenience methods attached.
280
+ '''
281
+
282
+ def __init__(self, *pargs, **kargs):
283
+ for key, value in kargs.items():
284
+ setattr(self, key, value)
285
+
286
+ def _pre(self, req):
287
+ pass
288
+
289
+ def _post(self, req, resp):
290
+ pass
291
+
292
+ def _redirect_to(self, *pargs, **kargs):
293
+ '''Redirect the controller'''
294
+ return redirect_to(*pargs, **kargs)
295
+
296
+ def _not_found(self, text=None):
297
+ '''Raise the 404 http_client_error exception.'''
298
+ raise exc.HTTPNotFound(text)
299
+
300
+ def _status(self, code):
301
+ '''Raise an http_client_error exception using a specific code'''
302
+ raise exc.status_map[int(code)]
303
+
304
+ def _JSON(self, data, status=200):
305
+ '''Return JSON object with the proper-ish headers.'''
306
+ res = Response(body=json.dumps(data),
307
+ status=status,
308
+ content_type="application/json",
309
+ charset='UTF-8')
310
+ return res
311
+
312
+ # alias for backwards copatibility
313
+ BaseController = Controller
@@ -0,0 +1,9 @@
1
+ % for field in fieldset:
2
+ <div${literal(" class=\"input_errors\"") if field.errors else ""}>
3
+ ${field.label()}
4
+ % for error in field.errors:
5
+ <span class="field_error">${error}</span>
6
+ % endfor
7
+ ${field}
8
+ </div>
9
+ % endfor
@@ -0,0 +1,24 @@
1
+ <thead>
2
+ <tr>
3
+ %for field in collection.render_fields.itervalues():
4
+ <th>${field.label()|h}</th>
5
+ %endfor
6
+ </tr>
7
+ </thead>
8
+
9
+ <tbody>
10
+ %for i, row in enumerate(collection.rows):
11
+ <% collection._set_active(row) %>
12
+ <% row_errors = collection.get_errors(row) %>
13
+ <tr class="${i % 2 and 'odd' or 'even'}">
14
+ %for field in collection.render_fields.itervalues():
15
+ <td>
16
+ ${field.render()|n}
17
+ %for error in row_errors.get(field, []):
18
+ <span class="grid_error">${error}</span>
19
+ %endfor
20
+ </td>
21
+ %endfor
22
+ </tr>
23
+ %endfor
24
+ </tbody>