summaryrefslogtreecommitdiff
path: root/rapid/ValidatedEntry.py
blob: cb453f4a6179edbaf1896f40f4a54441263b4075 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
# Copyright (c) 2006, Daniel J. Popowich
# 
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Send bug reports and contributions to:
#
#    dpopowich AT astro dot umass dot edu
#

'''
ValidatedEntry.py

Provides ValidatedEntry, a subclass of gtk.Entry which validates
input.

Usage: create an instance of ValidatedEntry, specifying the function
to validate input.  E.g.:

 :   def money(text):
 :       "validate input to be monetary value"
 :       ...
 :
 :   money_entry = ValidatedEntry(money)

Validation functions must accept one argument, the text to be
validated, and must return one of:

    1: the input is valid.
    0: the input is invalid and should not be displayed.
   -1: the input is partially valid and will be displayed (and by
       default with a different background color).

Three module-level variables are defined for the convenience of
validation function writers: VALID (1), INVALID (0), PARTIAL (-1).

There is one public method, isvalid(), which will return True if the
current text is valid.

Note: care should be taken when implementing validation functions to
allow empty strings to be VALID or at least PARTIAL.  An empty string
should never be INVALID.

Note: the hooks for calling the validation function are implemented by
connecting the object to handlers for the gtk.Editable "insert-text"
and "delete-text" signals.  These handlers are connected to instances
in the constructor, so will, by default, be called before other
handlers connected to the widgets for "*-text" signals.  When input is
INVALID, stop_emission() is called, so later handlers for "*-text"
signals will not be called.

See the doc string for ValidatedEntry.__init__ for more details.

'''

import pygtk
pygtk.require('2.0')

import gtk
import gtk.gdk

if gtk.gtk_version < (2, 8):
    import warnings

    msg ='''This module was developed and tested with version 2.8.9 of gtk.
You are using version %d.%d.%d.  Your milage may vary''' % gtk.gtk_version
    warnings.warn(msg)

# major, minor, patch
version = 1, 0, 4

PARTIAL, INVALID, VALID = range(-1,2)

class ValidatedEntry(gtk.Entry):

    white = gtk.gdk.color_parse('white')
    yellow = gtk.gdk.color_parse('yellow')

    def __init__(self, valid_func,
                 max=0,
                 use_bg=True, valid_bg=white, partial_bg=yellow,
                 error_func=None):
        '''
        Create instance of validating gtk.Entry.

        valid_func: the function to validate input.  See module doc
                    string for details.

        max: passed to gtk.Entry constructor. (default: 0)

        use_bg: if True (the default) set the base color of the
                widget to indicate validity; see valid_bg and partial_bg.

        valid_bg: a gtk.gdk.Color; the base color of the widget when
                  the input is valid. (default: white)

        partial_bg: a gtk.gdk.Color; the base color of the widget when
                    the input is partially valid. (default: yellow)

        error_func: a function to call (with no arguments) when
                    valid_func returns INVALID.  If None (the default)
                    the default action will be to emit a short beep.
        '''

        assert valid_func('') != INVALID, 'valid_func cannot return INVALID for an empty string'
        
        gtk.Entry.__init__(self, max)

        self.__valid_func = valid_func
        self.__use_bg = use_bg
        self.__valid_bg = valid_bg
        self.__partial_bg = partial_bg
        self.__error_func = (error_func or
                             gtk.gdk.display_get_default().beep)

        self.connect('insert-text', self.__insert_text_cb)
        self.connect('delete-text', self.__delete_text_cb)

        # bootstrap with an empty string (so the box will appear with
        # the partial_bg if an empty string is PARTIAL)
        self.insert_text('')

    def isvalid(self):
        return self.__isvalid

    def __insert_text_cb(self, entry, text, length, position):
        'callback for "insert-text" signal'

        # generate what the new text will be
        text = text[:length]
        pos = self.get_position()
        old = self.get_text()
        new = old[:pos] + text + old[pos:]

        # validate the new text
        self.__validate(new, 'insert-text')
        
    def __delete_text_cb(self, entry, start, end):
        'callback for "delete-text" signal'

        # generate what the new text will be
        old = self.get_text()
        new = old[:start] + old[end:]
        
        # validate the new text
        self.__validate(new, 'delete-text')

    def __validate(self, text, signal):
        'calls the user-provided validation function'
        
        # validate
        r = self.__valid_func(text)
        if r == VALID:
            self.__isvalid = True
            if self.__use_bg:
                self.modify_base(gtk.STATE_NORMAL, self.__valid_bg)
        elif r == PARTIAL:
            self.__isvalid = False
            if self.__use_bg:
                self.modify_base(gtk.STATE_NORMAL, self.__partial_bg)
        else:
            # don't set self.__isvalid: since we're not displaying the
            # new value, the validity should be whatever it was before
            self.stop_emission(signal)
            self.__error_func()


######################################################################
#            
# Sample validation functions to use with ValidatedEntry
#
######################################################################

import re


# STRING (non-empty after stripping)
def v_nonemptystring(value):
    '''
    VALID: non-empty string after stripping whitespace
    PARTAL: empty or all whitespace
    INVALID: N/A
    '''
    if value.strip():
        return VALID
    return PARTIAL

# INT
def v_int(value):
    '''
    VALID: any postive or negative integer
    PARTAL: empty or leading "-"
    INVALID: non-numeral
    '''
    v = value.strip()
    if not v or v == '-':
        return PARTIAL
    try:
        int(value)
        return VALID
    except:
        return INVALID

# FLOAT
def v_float(value):
    '''
    VALID: any postive or negative floating point
    PARTAL: empty or leading "-", "."
    INVALID: non-numeral
    '''
    v = value.strip()
    if not v or v in ('-', '.', '-.'):
        return PARTIAL
    try:
        float(value)
        return VALID
    except:
        return INVALID


# ISBN
_isbnpartial = re.compile('[0-9]{0,9}[0-9xX]?$')
def v_isbn(v):

    '''Validate ISBN input.

    From the isbn manual, section 4.4:
     
    The check digit is the last digit of an ISBN. It is calculated on
    a modulus 11 with weights 10-2, using X in lieu of 10 where ten
    would occur as a check digit.  This means that each of the first
    nine digits of the ISBN -- excluding the check digit itself -- is
    multiplied by a number ranging from 10 to 2 and that the resulting
    sum of the products, plus the check digit, must be divisible by 11
    without a remainder.'''

    
    if _isbnpartial.match(v):
        # isbn is ten characters in length
        if len(v) < 10:
            return PARTIAL

        s = 0

        for i, c in enumerate(v):
            s += (c in 'xX' and 10 or int(c)) * (10 - i)

        if s % 11 == 0:
            return VALID

    return INVALID

# MONEY
# re for (possibly negative) money
_money_re = re.compile('-?\d*(\.\d{1,2})?$')
# validation function for money
def v_money(value):
    '''
    VALID: any postive or negative floating point with at most two
           digits after the decimal point.
    PARTAL: empty or leading "-", "."
    INVALID: non-numeral or more than two digits after the decimal
             point.
    '''
    if not value or value == '-' or value[-1] == '.':
        return PARTIAL

    if _money_re.match(value):
        return VALID

    return INVALID

# PHONE
# the characters in a phone number
_phonechars = re.compile('[- 0-9]*$')
# valid phone number: [AC +]EXT-LINE
_phone = re.compile('([2-9][0-8][0-9]\s+)?[2-9][0-9]{2}-[0-9]{4}$')
def v_phone(value):
    '''
    VALID: any phone number of the form: EXT-LINE -or- AC EXT-LINE.
    PARTAL: any characters that make up a valid #.
    INVALID: characters that are not used in a phone #.
    '''
    if _phone.match(value):
	return VALID
    if _phonechars.match(value):
        return PARTIAL
    return INVALID

def empty_valid(vfunc):

    '''
    empty_valid is a factory function returning a validation function.
    All of the validation functions in this module return PARTIAL for
    empty strings which, in effect, forces non-empty input.  There may
    be a case where, e.g., you want money input to be optional, but
    v_money will not consider empty input VALID.  Instead of writing
    another validation function you can instead use empty_valid().  By
    wrapping a validation function with empty_valid(), input (after
    stripping), if empty, will be considered VALID.  E.g.:

        ventry = ValidatedEntry(empty_valid(v_money))

    It is recommended that all your validation functions treat empty
    input as PARTIAL, for consistency across all validation functions
    and for use with empty_valid().
    '''

    def validate(value):
        if not value.strip():
            return VALID
        return vfunc(value)

    return validate


def bounded(vfunc, conv, minv=None, maxv=None):

    '''
    bounded is a factory function returning a validation function
    providing bounded input.  E.g., you may want an entry that accepts
    integers, but within a range, say, a score on a test graded in
    whole numbers from 0 to 100:

        score_entry = ValidatedEntry(bounded(v_int, int, 0, 100))

    Arguments:

        vfunc: A validation function.
        conv: A callable that accepts a string argument (the text in
              the entry) and returns a value to be compared to minv
              and maxv.
        minv: None or a value of the same type returned by conv.  If
              None, there is no minimum value enforced.  If a value,
              it will be the minimum value considered VALID.
        maxv: None or a value of the same type returned by conv.  If
              None, there is no maximum value enforced.  If a value,
              it will be the maximum value considered VALID.

    One or both of minv/maxv must be specified.

    The function returned will call vfunc on entry input and if vfunc
    returns VALID, the input will be converted by conv and compared to
    minv/maxv.  If the converted value is within the bounds of
    minv/maxv then VALID will be returned, else PARTIAL will be
    returned.

    '''

    assert minv is not None or maxv is not None, \
           'One of minv/maxv must be specified'

    def F(value):

        r = vfunc(value)
        if r == VALID:
            v = conv(value)
            if minv is not None and v < minv:
                return PARTIAL
            if maxv is not None and v > maxv:
                return PARTIAL
        return r

    return F