1# -*- test-case-name: formless.test -*- 2# Copyright (c) 2004 Divmod. 3# See LICENSE for details. 4 5 6"""And the earth was without form, and void; and darkness was upon the face of the deep. 7""" 8 9import os 10import sys 11import inspect 12import warnings 13from zope.interface import implements 14from zope.interface.interface import InterfaceClass, Attribute 15 16from nevow import util 17 18 19from formless import iformless 20 21 22class count(object): 23 def __init__(self): 24 self.id = 0 25 def next(self): 26 self.id += 1 27 return self.id 28 29nextId = count().next 30 31 32class InputError(Exception): 33 """A Typed instance was unable to coerce from a string to the 34 appropriate type. 35 """ 36 def __init__(self, reason): 37 self.reason = reason 38 39 def __str__(self): 40 return self.reason 41 42 43class ValidateError(Exception): 44 """A Binding instance was unable to coerce all it's arguments from a 45 dictionary of lists of strings to the appropriate types. 46 47 One use of this is to raise from an autocallable if an input is invalid. 48 For example, a password is incorrect. 49 50 errors must be a dictionary mapping argument names to error messages 51 to display next to the arguments on the form. 52 53 formErrorMessage is a string to display at the top of the form, not tied to 54 any specific argument on the form. 55 56 partialForm is a dict mapping argument name to argument value, allowing 57 you to have the form save values that were already entered in the form. 58 """ 59 def __init__(self, errors, formErrorMessage=None, partialForm=None): 60 self.errors = errors 61 self.formErrorMessage = formErrorMessage 62 if partialForm is None: 63 self.partialForm = {} 64 else: 65 self.partialForm = partialForm 66 67 def __str__(self): 68 return self.formErrorMessage 69 70 71 72class Typed(Attribute): 73 """A typed value. Subclasses of Typed are constructed inside of 74 TypedInterface class definitions to describe the types of properties, 75 the parameter types to method calls, and method return types. 76 77 @ivar label: The short label which will describe this 78 parameter/proerties purpose to the user. 79 80 @ivar description: A long description which further describes the 81 sort of input the user is expected to provide. 82 83 @ivar default: A default value that may be used as an initial 84 value in the form. 85 86 @ivar required: Whether the user is required to provide a value 87 88 @ivar null: The value which will be produced if required is False 89 and the user does not provide a value 90 91 @ivar unicode: Iff true, try to determine the character encoding 92 of the data from the browser and pass unicode strings to 93 coerce. 94 """ 95 implements(iformless.ITyped) 96 97 complexType = False 98 strip = False 99 label = None 100 description = None 101 default = '' 102 required = False 103 requiredFailMessage = 'Please enter a value' 104 null = None 105 unicode = False 106 107 __name__ = '' 108 109 def __init__( 110 self, 111 label=None, 112 description=None, 113 default=None, 114 required=None, 115 requiredFailMessage=None, 116 null=None, 117 unicode=None, 118 **attributes): 119 120 self.id = nextId() 121 if label is not None: 122 self.label = label 123 if description is not None: 124 self.description = description 125 if default is not None: 126 self.default = default 127 if required is not None: 128 self.required = required 129 if requiredFailMessage is not None: 130 self.requiredFailMessage = requiredFailMessage 131 if null is not None: 132 self.null = null 133 if unicode is not None: 134 self.unicode = unicode 135 self.attributes = attributes 136 137 def getAttribute(self, name, default=None): 138 return self.attributes.get(name, default) 139 140 def coerce(self, val, configurable): 141 raise NotImplementedError, "Implement in %s" % util.qual(self.__class__) 142 143 144####################################### 145## External API; your code will create instances of these objects 146####################################### 147 148class String(Typed): 149 """A string that is expected to be reasonably short and contain no 150 newlines or tabs. 151 152 strip: remove leading and trailing whitespace. 153 """ 154 155 requiredFailMessage = 'Please enter a string.' 156 # iff true, return the stripped value. 157 strip = False 158 159 def __init__(self, *args, **kwargs): 160 try: 161 self.strip = kwargs['strip'] 162 del kwargs['strip'] 163 except KeyError: 164 pass 165 Typed.__init__(self, *args, **kwargs) 166 167 def coerce(self, val, configurable): 168 if self.strip: 169 val = val.strip() 170 return val 171 172 173class Text(String): 174 """A string that is likely to be of a significant length and 175 probably contain newlines and tabs. 176 """ 177 178 179class Password(String): 180 """Password is used when asking a user for a new password. The renderer 181 user interface will possibly ask for the password multiple times to 182 ensure it has been entered correctly. Typical use would be for 183 registration of a new user.""" 184 requiredFailMessage = 'Please enter a password.' 185 186 187class PasswordEntry(String): 188 """PasswordEntry is used to ask for an existing password. Typical use 189 would be for login to an existing account.""" 190 requiredFailMessage = 'Please enter a password.' 191 192 193class FileUpload(Typed): 194 requiredFailMessage = 'Please enter a file name.' 195 196 def coerce(self, val, configurable): 197 return val.filename 198 199 200class Integer(Typed): 201 202 requiredFailMessage = 'Please enter an integer.' 203 204 def coerce(self, val, configurable): 205 if val is None: 206 return None 207 try: 208 return int(val) 209 except ValueError: 210 if sys.version_info < (2,3): # Long/Int aren't integrated 211 try: 212 return long(val) 213 except ValueError: 214 raise InputError("'%s' is not an integer." % val) 215 216 raise InputError("'%s' is not an integer." % val) 217 218 219class Real(Typed): 220 221 requiredFailMessage = 'Please enter a real number.' 222 223 def coerce(self, val, configurable): 224 # TODO: This shouldn't be required; check. 225 # val should never be None, but always a string. 226 if val is None: 227 return None 228 try: 229 return float(val) 230 except ValueError: 231 raise InputError("'%s' is not a real number." % val) 232 233 234class Boolean(Typed): 235 def coerce(self, val, configurable): 236 if val == 'False': 237 return False 238 elif val == 'True': 239 return True 240 raise InputError("'%s' is not a boolean" % val) 241 242 243class FixedDigitInteger(Integer): 244 245 def __init__(self, digits = 1, *args, **kw): 246 Integer.__init__(self, *args, **kw) 247 self.digits = digits 248 self.requiredFailMessage = \ 249 'Please enter a %d digit integer.' % self.digits 250 251 def coerce(self, val, configurable): 252 v = Integer.coerce(self, val, configurable) 253 if len(str(v)) != self.digits: 254 raise InputError("Number must be %s digits." % self.digits) 255 return v 256 257 258class Directory(Typed): 259 260 requiredFailMessage = 'Please enter a directory name.' 261 262 def coerce(self, val, configurable): 263 # TODO: This shouldn't be required; check. 264 # val should never be None, but always a string. 265 if val is None: 266 return None 267 if not os.path.exists(val): 268 raise InputError("The directory '%s' does not exist." % val) 269 return val 270 271 272class Choice(Typed): 273 """Allow the user to pick from a list of "choices", presented in a drop-down 274 menu. The elements of the list will be rendered by calling the function 275 passed to stringify, which is by default "str". 276 """ 277 278 requiredFailMessage = 'Please choose an option.' 279 280 def __init__(self, choices=None, choicesAttribute=None, stringify=str, 281 valueToKey=str, keyToValue=None, keyAndConfigurableToValue=None, 282 *args, **kw): 283 """ 284 Create a Choice. 285 286 @param choices: an object adaptable to IGettable for an iterator (such 287 as a function which takes (ctx, data) and returns a list, a list 288 itself, a tuple, a generator...) 289 290 @param stringify: a pretty-printer. a function which takes an object 291 in the list of choices and returns a label for it. 292 293 @param valueToKey: a function which converts an object in the list of 294 choices to a string that can be sent to a client. 295 296 @param keyToValue: a 1-argument convenience version of 297 keyAndConfigurableToValue 298 299 @param keyAndConfigurableToValue: a 2-argument function which takes a string such as 300 one returned from valueToKey and a configurable, and returns an object 301 such as one from the list of choices. 302 """ 303 304 Typed.__init__(self, *args, **kw) 305 self.choices = choices 306 if choicesAttribute: 307 self.choicesAttribute = choicesAttribute 308 if getattr(self, 'choicesAttribute', None): 309 warnings.warn( 310 "Choice.choicesAttribute is deprecated. Please pass a function to choices instead.", 311 DeprecationWarning, 312 stacklevel=2) 313 def findTheChoices(ctx, data): 314 return getattr(iformless.IConfigurable(ctx).original, self.choicesAttribute) 315 self.choices = findTheChoices 316 317 self.stringify = stringify 318 self.valueToKey=valueToKey 319 320 if keyAndConfigurableToValue is not None: 321 assert keyToValue is None, 'This should be *obvious*' 322 self.keyAndConfigurableToValue = keyAndConfigurableToValue 323 elif keyToValue is not None: 324 self.keyAndConfigurableToValue = lambda x,y: keyToValue(x) 325 else: 326 self.keyAndConfigurableToValue = lambda x,y: str(x) 327 328 329 def coerce(self, val, configurable): 330 """Coerce a value with the help of an object, which is the object 331 we are configuring. 332 """ 333 return self.keyAndConfigurableToValue(val, configurable) 334 335 336class Radio(Choice): 337 """Type influencing presentation! horray! 338 339 Show the user radio button choices instead of a picklist. 340 """ 341 342 343class Any(object): 344 """Marker which indicates any object type. 345 """ 346 347 348class Object(Typed): 349 complexType = True 350 def __init__(self, interface=Any, *args, **kw): 351 Typed.__init__(self, *args, **kw) 352 self.iface = interface 353 354 def __repr__(self): 355 if self.iface is not None: 356 return "%s(interface=%s)" % (self.__class__.__name__, util.qual(self.iface)) 357 return "%s(None)" % (self.__class__.__name__,) 358 359 360 361class List(Object): 362 implements(iformless.IActionableType) 363 364 complexType = True 365 def __init__(self, actions=None, header='', footer='', separator='', *args, **kw): 366 """Actions is a list of action methods which may be invoked on one 367 or more of the elements of this list. Action methods are defined 368 on a TypedInterface and declare that they take one parameter 369 of type List. They do not declare themselves to be autocallable 370 in the traditional manner. Instead, they are passed in the actions 371 list of a list Property to declare that the action may be taken on 372 one or more of the list elements. 373 """ 374 if actions is None: 375 actions = [] 376 self.actions = actions 377 self.header = header 378 self.footer = footer 379 self.separator = separator 380 Object.__init__(self, *args, **kw) 381 382 def coerce(self, data, configurable): 383 return data 384 385 def __repr__(self): 386 if self.iface is not None: 387 return "%s(interface=%s)" % (self.__class__.__name__, util.qual(self.iface)) 388 return self.__class__.__name__ + "()" 389 390 def attachActionBindings(self, possibleActions): 391 ## Go through and replace self.actions, which is a list of method 392 ## references, with the MethodBinding instance which holds 393 ## metadata about this function. 394 act = self.actions 395 for method, binding in possibleActions: 396 if method in act: 397 act[act.index(method)] = binding 398 399 def getActionBindings(self): 400 return self.actions 401 402class Dictionary(List): 403 pass 404 405 406class Table(Object): 407 pass 408 409 410class Request(Typed): 411 """Marker that indicates that an autocallable should be passed the 412 request when called. Including a Request arg will not affect the 413 appearance of the rendered form. 414 415 >>> def doSomething(request=formless.Request(), name=formless.String()): 416 ... pass 417 >>> doSomething = formless.autocallable(doSomething) 418 """ 419 complexType = True ## Don't use the regular form 420 421 422class Context(Typed): 423 """Marker that indicates that an autocallable should be passed the 424 context when called. Including a Context arg will not affect the 425 appearance of the rendered form. 426 427 >>> def doSomething(context=formless.Context(), name=formless.String()): 428 ... pass 429 >>> doSomething = formless.autocallable(doSomething) 430 """ 431 complexType = True ## Don't use the regular form 432 433 434class Button(Typed): 435 def coerce(self, data, configurable): 436 return data 437 438 439class Compound(Typed): 440 complexType = True 441 def __init__(self, elements=None, *args, **kw): 442 assert elements, "What is the sound of a Compound type with no elements?" 443 self.elements = elements 444 Typed.__init__(self, *args, **kw) 445 446 def __len__(self): 447 return len(self.elements) 448 449 def coerce(self, data, configurable): 450 return data 451 452 453class Method(Typed): 454 def __init__(self, returnValue=None, arguments=(), *args, **kw): 455 Typed.__init__(self, *args, **kw) 456 self.returnValue = returnValue 457 self.arguments = arguments 458 459 460class Group(Object): 461 pass 462 463 464def autocallable(method, action=None, visible=False, **kw): 465 """Describe a method in a TypedInterface as being callable through the 466 UI. The "action" paramter will be used to label the action button, or the 467 user interface element which performs the method call. 468 469 Use this like a method adapter around a method in a TypedInterface: 470 471 >>> class IFoo(TypedInterface): 472 ... def doSomething(): 473 ... '''Do Something 474 ... 475 ... Do some action bla bla''' 476 ... return None 477 ... doSomething = autocallable(doSomething, action="Do it!!") 478 """ 479 method.autocallable = True 480 method.id = nextId() 481 method.action = action 482 method.attributes = kw 483 return method 484 485 486####################################### 487## Internal API; formless uses these objects to keep track of 488## what names are bound to what types 489####################################### 490 491 492class Binding(object): 493 """Bindings bind a Typed instance to a name. When TypedInterface is subclassed, 494 the metaclass looks through the dict looking for all properties and methods. 495 496 If a properties is a Typed instance, a Property Binding is constructed, passing 497 the name of the binding and the Typed instance. 498 499 If a method has been wrapped with the "autocallable" function adapter, 500 a Method Binding is constructed, passing the name of the binding and the 501 Typed instance. Then, getargspec is called. For each keyword argument 502 in the method definition, an Argument is constructed, passing the name 503 of the keyword argument as the binding name, and the value of the 504 keyword argument, a Typed instance, as the binding typeValue. 505 506 One more thing. When an autocallable method is found, it is called with 507 None as the self argument. The return value is passed the Method 508 Binding when it is constructed to keep track of what the method is 509 supposed to return. 510 """ 511 implements(iformless.IBinding) 512 513 label = None 514 description = '' 515 516 def __init__(self, name, typedValue, id=0): 517 self.id = id 518 self.name = name 519 self.typedValue = iformless.ITyped(typedValue) 520 521 # pull these out to remove one level of indirection... 522 if typedValue.description is not None: 523 self.description = typedValue.description 524 if typedValue.label is not None: 525 self.label = typedValue.label 526 if self.label is None: 527 self.label = nameToLabel(name) 528 self.default = typedValue.default 529 self.complexType = typedValue.complexType 530 531 def __repr__(self): 532 return "<%s %s=%s at 0x%x>" % (self.__class__.__name__, self.name, self.typedValue.__class__.__name__, id(self)) 533 534 def getArgs(self): 535 """Return a *copy* of this Binding. 536 """ 537 return (Binding(self.name, self.original, self.id), ) 538 539 def getViewName(self): 540 return self.original.__class__.__name__.lower() 541 542 def configure(self, boundTo, results): 543 raise NotImplementedError, "Implement in %s" % util.qual(self.__class__) 544 545 def coerce(self, val, configurable): 546 if hasattr(self.original, 'coerce'): 547 return self.original.coerce(val) 548 return val 549 550class Argument(Binding): 551 pass 552 553 554class Property(Binding): 555 action = 'Change' 556 def configure(self, boundTo, results): 557 ## set the property! 558 setattr(boundTo, self.name, results[self.name]) 559 560 561class MethodBinding(Binding): 562 typedValue = None 563 def __init__(self, name, typeValue, id=0, action="Call", attributes = {}): 564 Binding.__init__(self, name, typeValue, id) 565 self.action = action 566 self.arguments = typeValue.arguments 567 self.returnValue = typeValue.returnValue 568 self.attributes = attributes 569 570 def getAttribute(self, name): 571 return self.attributes.get(name, None) 572 573 def configure(self, boundTo, results): 574 bound = getattr(boundTo, self.name) 575 return bound(**results) 576 577 def getArgs(self): 578 """Make sure each form post gets a unique copy of the argument list which it can use to keep 579 track of values given in partially-filled forms 580 """ 581 return self.typedValue.arguments[:] 582 583 584class ElementBinding(Binding): 585 """An ElementBinding binds a key to an element of a container. 586 For example, ElementBinding('0', Object()) indicates the 0th element 587 of a container of Objects. When this ElementBinding is bound to 588 the list [1, 2, 3], resolving the binding will result in the 0th element, 589 the object 1. 590 """ 591 pass 592 593 594class GroupBinding(Binding): 595 """A GroupBinding is a way of naming a group of other Bindings. 596 The typedValue of a GroupBinding should be a Configurable. 597 The Bindings returned from this Configurable (usually a TypedInterface) 598 will be rendered such that all fields must/may be filled out, and all 599 fields will be changed at once upon form submission. 600 """ 601 def __init__(self, name, typedValue, id=0): 602 """Hack to prevent adaption to ITyped while the adapters are still 603 being registered, because we know that the typedValue should be 604 a Group when we are constructing a GroupBinding. 605 """ 606 self.id = id 607 self.name = name 608 self.typedValue = Group(typedValue) 609 610 # pull these out to remove one level of indirection... 611 self.description = typedValue.description 612 if typedValue.label: 613 self.label = typedValue.label 614 else: 615 self.label = nameToLabel(name) 616 self.default = typedValue.default 617 self.complexType = typedValue.complexType 618 619 def configure(self, boundTo, group): 620 print "CONFIGURING GROUP BINDING", boundTo, group 621 622 623def _sorter(x, y): 624 return cmp(x.id, y.id) 625 626 627class _Marker(object): 628 pass 629 630 631def caps(c): 632 return c.upper() == c 633 634 635def nameToLabel(mname): 636 labelList = [] 637 word = '' 638 lastWasUpper = False 639 for letter in mname: 640 if caps(letter) == lastWasUpper: 641 # Continuing a word. 642 word += letter 643 else: 644 # breaking a word OR beginning a word 645 if lastWasUpper: 646 # could be either 647 if len(word) == 1: 648 # keep going 649 word += letter 650 else: 651 # acronym 652 # we're processing the lowercase letter after the acronym-then-capital 653 lastWord = word[:-1] 654 firstLetter = word[-1] 655 labelList.append(lastWord) 656 word = firstLetter + letter 657 else: 658 # definitely breaking: lower to upper 659 labelList.append(word) 660 word = letter 661 lastWasUpper = caps(letter) 662 if labelList: 663 labelList[0] = labelList[0].capitalize() 664 else: 665 return mname.capitalize() 666 labelList.append(word) 667 return ' '.join(labelList) 668 669 670def labelAndDescriptionFromDocstring(docstring): 671 if docstring is None: 672 docstring = '' 673 docs = filter(lambda x: x, [x.strip() for x in docstring.split('\n')]) 674 if len(docs) > 1: 675 return docs[0], '\n'.join(docs[1:]) 676 else: 677 return None, '\n'.join(docs) 678 679 680class MetaTypedInterface(InterfaceClass): 681 """The metaclass for TypedInterface. When TypedInterface is subclassed, 682 this metaclass' __new__ method is invoked. The Typed Binding introspection 683 described in the Binding docstring occurs, and when it is all done, there will 684 be three attributes on the TypedInterface class: 685 686 - __methods__: An ordered list of all the MethodBinding instances 687 produced by introspecting all autocallable methods on this 688 TypedInterface 689 690 - __properties__: An ordered list of all the Property Binding 691 instances produced by introspecting all properties which have 692 Typed values on this TypedInterface 693 694 - __spec__: An ordered list of all methods and properties 695 696 These lists are sorted in the order that the methods and properties appear 697 in the TypedInterface definition. 698 699 For example: 700 701 >>> class Foo(TypedInterface): 702 ... bar = String() 703 ... baz = Integer() 704 ... 705 ... def frotz(): pass 706 ... frotz = autocallable(frotz) 707 ... 708 ... xyzzy = Float() 709 ... 710 ... def blam(): pass 711 ... blam = autocallable(blam) 712 713 Once the metaclass __new__ is done, the Foo class instance will have three 714 properties, __methods__, __properties__, and __spec__, 715 """ 716 717 def __new__(cls, name, bases, dct): 718 rv = cls = InterfaceClass.__new__(cls) 719 cls.__id__ = nextId() 720 cls.__methods__ = methods = [] 721 cls.__properties__ = properties = [] 722 cls.default = 'DEFAULT' 723 cls.complexType = True 724 possibleActions = [] 725 actionAttachers = [] 726 for key, value in dct.items(): 727 if key[0] == '_': continue 728 729 if isinstance(value, MetaTypedInterface): 730 ## A Nested TypedInterface indicates a GroupBinding 731 properties.append(GroupBinding(key, value, value.__id__)) 732 733 ## zope.interface doesn't like these 734 del dct[key] 735 setattr(cls, key, value) 736 elif callable(value): 737 names, _, _, typeList = inspect.getargspec(value) 738 739 _testCallArgs = () 740 741 if typeList is None: 742 typeList = [] 743 744 if len(names) == len(typeList) + 1: 745 warnings.warn( 746 "TypeInterface method declarations should not have a 'self' parameter", 747 DeprecationWarning, 748 stacklevel=2) 749 del names[0] 750 _testCallArgs = (_Marker,) 751 752 if len(names) != len(typeList): 753 ## Allow non-autocallable methods in the interface; ignore them 754 continue 755 756 argumentTypes = [ 757 Argument(n, argtype, argtype.id) for n, argtype in zip(names[-len(typeList):], typeList) 758 ] 759 760 result = value(*_testCallArgs) 761 762 label = None 763 description = None 764 if getattr(value, 'autocallable', None): 765 # autocallables have attributes that can set label and description 766 label = value.attributes.get('label', None) 767 description = value.attributes.get('description', None) 768 769 adapted = iformless.ITyped(result, None) 770 if adapted is None: 771 adapted = Object(result) 772 773 # ITyped has label and description we can use 774 if label is None: 775 label = adapted.label 776 if description is None: 777 description = adapted.description 778 779 defaultLabel, defaultDescription = labelAndDescriptionFromDocstring(value.__doc__) 780 if defaultLabel is None: 781 # docstring had no label, try the action if it is an autocallable 782 if getattr(value, 'autocallable', None): 783 if label is None and value.action is not None: 784 # no explicit label, but autocallable has action we can use 785 defaultLabel = value.action 786 787 if defaultLabel is None: 788 # final fallback: use the function name as label 789 defaultLabel = nameToLabel(key) 790 791 if label is None: 792 label = defaultLabel 793 if description is None: 794 description = defaultDescription 795 796 theMethod = Method( 797 adapted, argumentTypes, label=label, description=description 798 ) 799 800 if getattr(value, 'autocallable', None): 801 methods.append( 802 MethodBinding( 803 key, theMethod, value.id, value.action, value.attributes)) 804 else: 805 possibleActions.append((value, MethodBinding(key, theMethod))) 806 else: 807 if not value.label: 808 value.label = nameToLabel(key) 809 if iformless.IActionableType.providedBy(value): 810 actionAttachers.append(value) 811 properties.append( 812 Property(key, value, value.id) 813 ) 814 for attacher in actionAttachers: 815 attacher.attachActionBindings(possibleActions) 816 methods.sort(_sorter) 817 properties.sort(_sorter) 818 cls.__spec__ = spec = methods + properties 819 spec.sort(_sorter) 820 cls.name = name 821 822 # because attributes "label" and "description" would become Properties, 823 # check for ones with an underscore prefix. 824 cls.label = dct.get('_label', None) 825 cls.description = dct.get('_description', None) 826 defaultLabel, defaultDescription = labelAndDescriptionFromDocstring(dct.get('__doc__')) 827 if defaultLabel is None: 828 defaultLabel = nameToLabel(name) 829 if cls.label is None: 830 cls.label = defaultLabel 831 if cls.description is None: 832 cls.description = defaultDescription 833 834 return rv 835 836 837####################################### 838## External API; subclass this to create a TypedInterface 839####################################### 840 841TypedInterface = MetaTypedInterface('TypedInterface', (InterfaceClass('TypedInterface'), ), {}) 842 843