1#!/usr/bin/env python
2from __future__ import division, print_function
3"""String utilities, with an emphasis on support for sexagesimal numbers
4(e.g. degrees:minutes:seconds).
5
6To do:
7- Add tests for dmsStrFromDeg with omitExtraFields true,
8  dmsStrFromSec, quoteStr etc.
9
10History:
112001-03-12 ROwen    strToFloat modified to handle ".".
122001-03-14 ROwen    Added prettyDict; still need to add a test for it.
132001-04-13 ROwen    Fixed a bug in splitDMSStr: -0:... was not retaining the minus sign;
14    added dmsStrToSec, which may be useful for handling durations;
15    modified splitDMSStr to allow an arbitrary number digits in the first field
16    (use range checking of the equivalent numeric value to limit this),
17    to return sing as a separate entity, and to return all strings;
18    improved the test suite, including adding a nearly silent test.
192001-04-20 ROwen    Fixed a bug in degToDMSStr: borderline cases could print
20    as 60 instead of 00 and incrementing the next field.
212001-07-22 ROwen    Made DegStr work on unix as well as Mac
22    (I still don't know the Windows character).
232001-08-29 ROwen    Made DegStr and DMSStr unicode strings, a reasonable choice
24    now that one can print unicode characters.
252001-11-01 ROwen    Added nFields argument to degToDMSStr and secToDMSStr;
26    changed degToDMSStr, secToDMSStr and neatenDMSStr to eliminate leading spaces.
272002-08-08 ROwen    Moved to RO and renamed from ROStringUtil. Renamed
28     xToY functions to yFromX. Hid private constants with leading underscore.
292002-08-21 ROwen    Modified splitDMSStr to require all but the first field be <60.
302003-04-04 ROwen    Bug fix: dmsStrFromDeg and dmsStrFromSec mis-handled
31    some negative values.
322003-06-19 ROwen    Fixed two tests which broke in upgrades listed above.
332003-07-15 ROwen    Added omitExtraFields to degFromDMSStr and secFromDMSStr;
34                    added quoteStr function.
352003-10-27 ROwen    Added plural function.
362004-01-09 ROwen    Added AngstromStr, LambdaStr and MuStr constants.
372004-05-18 ROwen    Stopped importing sys since it was not being used.
382005-06-27 ROwen    Fixed a nonfunctional assert statement in splitDMSStr.
392006-01-09 ROwen    Fixed a bug in dmsStrFromDeg: dmsStrFromDeg(-50.650002) = "-50:38:60.0"
40                    and a related bug in dmsStrFroMSec. Thanks to John Lucey.
41                    (Note: the test code had a case for it, but expected the wrong value.)
422007-06-04 ROwen    Bug fix: dmsStrFromSec gave bad results if nFields != 3.
432008-01-30 ROwen    Removed unused variable signNum (found by pychecker).
442008-04-29 ROwen    Added strFromException, a unicode-safe replacement for str(exception).
452008-05-02 ROwen    Made prettyDict unicode-safe by using repr.
462008-11-14 ROwen    Added unquoteStr.
472014-05-07 ROwen    Changed is str test to use basestring.
482014-09-17 ROwen    Modified to test for Exception instead of StandardError
492015-01-06 ROwen    Improved dmsStrFromDeg and dmsStrFromSec to handle non-finite values.
502015-09-24 ROwen    Replace "== None" with "is None" to modernize the code.
512015-11-03 ROwen    Replace "!= None" with "is not None" to modernize the code.
522015-11-05 ROwen    Stop using dangerous bare "except:".
53"""
54import re
55import numpy
56
57AngstromStr = u"\N{ANGSTROM SIGN}"
58DegStr = u"\N{DEGREE SIGN}"
59DMSStr = DegStr + u"'\""
60LambdaStr = u"\u00c5" # for some reason this fails: u"\N{GREEK SMALL LETTER LAMBDA}"
61MuStr = u"\N{GREEK SMALL LETTER MU}"
62
63def dmsStrFromDeg (decDeg, nFields=3, precision=1, omitExtraFields = False):
64    """Convert a number to a sexagesimal string with 1-3 fields.
65
66    Inputs:
67    - decDeg: value in decimal degrees or hours
68    - nFields: number of fields; <=1 for dddd.ddd, 2 for dddd:mm.mmm, >=3 for dddd:mm:ss.sss
69    - precision: number of digits after the decimal point in the last field;
70        if 0, no decimal point is printed; must be >= 0
71    - omitExtraFields: omit fields that are zero, starting from the right
72
73    Error conditions:
74    - Raises ValueError if precision < 0
75    - Returns "" if decDeg is not finite
76    """
77    if not numpy.isfinite(decDeg):
78        return ""
79    nFields = min(3, nFields)
80    signStr, fieldStrs = _getDMSFields(decDeg, nFields, precision)
81
82    if omitExtraFields:
83        while fieldStrs and float(fieldStrs[-1]) == 0.0:
84            fieldStrs.pop(-1)
85
86    return signStr + ":".join(fieldStrs)
87
88def dmsStrFromSec (decSec, nFields=3, precision=1, omitExtraFields = True):
89    """Convert a number, in seconds, to a sexagesimal string.
90    Similar to dmsStrFromDeg, but takes seconds, not degrees,
91    and omitExtraFields omits fields from the left, not the right.
92
93    Inputs:
94    - decSec: value in decimal seconds or arc seconds
95    - nFields: number of fields; <=1 for ss.sss, 2 for mm:ss.ss, >= 3 for dddd:mm:ss.sss
96    - precision: number of digits after the decimal point in the seconds field;
97        if 0, no decimal point is printed; must be >= 0
98    - omitExtraFields: omit fields that are zero, starting from the left.
99
100    Error conditions:
101    - Raises ValueError if precision < 0
102    - Returns "" if decDeg is not finite
103    """
104    if not numpy.isfinite(decSec):
105        return ""
106    nFields = min(3, nFields)
107    if nFields < 1:
108        raise ValueError("nFields=%r; must be >= 1" % (nFields,))
109    adjNum = decSec / (60.0**(nFields-1))
110    signStr, fieldStrs = _getDMSFields(adjNum, nFields, precision)
111
112    if omitExtraFields:
113        while fieldStrs and float(fieldStrs[0]) == 0.0:
114            fieldStrs.pop(0)
115
116    return signStr + ":".join(fieldStrs)
117
118def degFromDMSStr (dmsStr):
119    """Convert a string of the basic form dd[:mm[:ss]] to decimal degrees.
120    See splitDMSStr for details of the format.
121
122    Error conditions:
123    - Raises ValueError if the string cannot be parsed
124    """
125    dmsItems = splitDMSStr(dmsStr)
126
127    # extract sign
128    if dmsItems[0] == '-':
129        signMult = -1.0
130    else:
131        signMult = 1.0
132    dmsItems[0:1] = []
133
134    # combine last two elements and convert to float
135    dmsItems[-2:] = [floatFromStr(dmsItems[-2]) + floatFromStr(dmsItems[-1])]
136
137    # convert all but last item to float
138    for ind in range(len(dmsItems) - 1):
139        dmsItems[ind] = intFromStr(dmsItems[ind])
140
141    dmsItems.reverse()
142    decDeg = 0.0
143    for dmsField in dmsItems:
144        decDeg = abs(dmsField) + (decDeg / 60.0)
145    return signMult * decDeg
146
147def secFromDMSStr (dmsStr):
148    """Convert a string of the basic form [[dd:]mm:]ss to decimal degrees.
149    Note that missing fields are handled differently than degFromDMSStr!
150    See splitDMSStr for details of the format.
151
152    error conditions:
153        raises ValueError if the string cannot be parsed
154    """
155    dmsItems = splitDMSStr(dmsStr)
156
157    # extract sign
158    if dmsItems[0] == '-':
159        signMult = -1.0
160    else:
161        signMult = 1.0
162    dmsItems[0:1] = []
163
164    # combine last two elements and convert to float
165    dmsItems[-2:] = [floatFromStr(dmsItems[-2]) + floatFromStr(dmsItems[-1])]
166
167    # convert all but last item to float
168    for ind in range(len(dmsItems) - 1):
169        dmsItems[ind] = intFromStr(dmsItems[ind])
170
171    decSec = 0.0
172    for dmsField in dmsItems:
173        decSec = abs(dmsField) + (decSec * 60.0)
174    return signMult * decSec
175
176def secStrFromDMSStr(dmsStr):
177    """Convert a string of the basic form [[dd:]mm:]ss to decimal seconds
178    preserving the original accuracy of seconds
179    Note that missing fields are handled differently than degFromDMSStr!
180    See splitDMSStr for details of the format.
181
182    error conditions:
183        raises ValueError if the string cannot be parsed
184    """
185    dmsItems = splitDMSStr(dmsStr)
186
187    # extract sign and fractional seconds (includes decimal point)
188    signStr = dmsItems[0]
189    fracSecStr = dmsItems[-1]
190
191    # compute integer seconds
192    # convert all but first and last items to integers
193    intList = [intFromStr(item) for item in dmsItems[1:-1]]
194
195    intSec = 0
196    for intVal in intList:
197        intSec = abs(intVal) + (intSec * 60)
198    return "%s%s%s" % (signStr, intSec, fracSecStr)
199
200FloatChars = "0123456789+-.eE"
201
202def checkDMSStr(dmsStr):
203    """Verify a sexagesimal string; returns True if valid, False if not
204    """
205    try:
206        splitDMSStr(dmsStr)
207        return True
208    except Exception:
209        return False
210
211def dmsStrFieldsPrec(dmsStr):
212    """Return the following information about a sexagesimal string:
213    - the number of colon-separated fields
214    - the precision of the right-most field
215    """
216    if dmsStr == "":
217        return (0, 0)
218
219    precArry = dmsStr.split(".")
220    if len(precArry) > 1:
221        precision = len(precArry[1])
222    else:
223        precision = 0
224    nFields = dmsStr.count(":") + 1
225    return (nFields, precision)
226
227def findLeftNumber(astr, ind):
228    """Find the starting and ending index of the number
229    enclosing or to the left of index "ind".
230    Return (None, None) if no number found.
231
232    Warning: this is not a sophisticated routine. It looks for
233    the a run of characters that could be present
234    in a floating point number. It does not sanity checking
235    to see if they make a valid number.
236    """
237    leftInd = _findLeftOfLeftNumber(astr, ind)
238    if leftInd is None:
239        return (None, None)
240    rightInd = _findRightOfRightNumber(astr, leftInd)
241    if rightInd is None:
242        return (None, None)
243    return (leftInd, rightInd)
244
245def findRightNumber(astr, ind):
246    """Find the starting and ending index of the number
247    enclosing or to the right of index "ind".
248    Returns (None, None) if no number found.
249
250    Warning: this is not a sophisticated routine. It looks for
251    the a run of characters that could be present
252    in a floating point number. It does not sanity checking
253    to see if they make a valid number.
254    """
255    rightInd = _findRightOfRightNumber(astr, ind)
256    if rightInd is None:
257        return (None, None)
258    leftInd = _findLeftOfLeftNumber(astr, rightInd)
259    if leftInd is None:
260        return (None, None)
261    return (leftInd, rightInd)
262
263def _findLeftOfLeftNumber(astr, ind):
264    """Find the index of the first character of the number
265    enclosing or to the left of index "ind".
266    Returns None if no number found.
267
268    Warning: this is not a sophisticated routine. It looks for
269    the left-most of a run of characters that could be present
270    in a floating point number. It does not sanity checking
271    to see if they make a valid number.
272    """
273    leftInd = None
274    for tryind in range(ind, -1, -1):
275        if astr[tryind] in FloatChars:
276            leftInd = tryind
277        elif leftInd is not None:
278            break
279    return leftInd
280
281def _findRightOfRightNumber(astr, ind):
282    """Find the index of the last character of the number
283    enclosing or to the right of index "ind".
284    Returns None if no number found.
285
286    Warning: this is not a sophisticated routine. It looks for
287    the right-most of a run of characters that could be present
288    in a floating point number. It does not sanity checking
289    to see if they make a valid number.
290    """
291    rightInd = None
292    for tryind in range(ind, len(astr)):
293        if astr[tryind] in FloatChars:
294            rightInd = tryind
295        elif rightInd is not None:
296            break
297    return rightInd
298
299def neatenDMSStr (dmsStr):
300    """Convert a sexagesimal string to a neater version.
301
302    error conditions:
303        raises ValueError if the string cannot be parsed
304    """
305    if dmsStr == "":
306        return ""
307
308    precArry = dmsStr.split(".")
309    if len(precArry) > 1:
310        precision = len(precArry[1])
311    else:
312        precision = 0
313    fieldArry = dmsStr.split(":")
314    nFields = len(fieldArry)
315
316    floatValue = degFromDMSStr(dmsStr)
317    return dmsStrFromDeg(floatValue, nFields=nFields, precision=precision)
318
319def plural(num, singStr, plStr):
320    """Return singStr or plStr depending if num == 1 or not.
321    A minor convenience for formatting messages (in lieu of ?: notation)
322    """
323    if num == 1:
324        return singStr
325    return plStr
326
327def prettyDict(aDict, entrySepStr = "\n", keyValSepStr = ": "):
328    """Format a dictionary in a nice way
329
330    Inputs:
331    aDict: the dictionary to pretty-print
332    entrySepStr: string separating each dictionary entry
333    keyValSepStr: string separating key and value for each entry
334
335    Returns a string containing the pretty-printed dictionary
336    """
337    sortedKeys = aDict.keys()
338    sortedKeys.sort()
339    eltList = []
340    for aKey in sortedKeys:
341        eltList.append(repr(aKey) + keyValSepStr + repr(aDict[aKey]))
342    return entrySepStr.join(eltList)
343
344# constants used by splitDMSStr
345# DMSRE = re.compile(r"^\s*([+-]?)(\d{0,3})\s*(?:\:\s*(\d{0,2})\s*){0,2}(\.\d*)?\s*$")
346_DegRE =       re.compile(r"^\s*([+-]?)(\d*)(\.\d*)?\s*$")
347_DegMinRE =    re.compile(r"^\s*([+-]?)(\d*)\s*\:\s*([0-5]?\d?)(\.\d*)?\s*$")
348_DegMinSecRE = re.compile(r"^\s*([+-]?)(\d*)\s*\:\s*([0-5]?\d?):\s*([0-5]?\d?)(\.\d*)?\s*$")
349
350def splitDMSStr (dmsStr):
351    """Split a sexagesimal string into fields
352    returns one of the following lists:
353    [sign, int deg, frac deg]
354    [sign, int deg, int min, frac min]
355    [sign, int deg, int min, int sec, frac sec]
356    where:
357        all values are strings
358        sign is one of ('', '+' or '-')
359        frac <whatever> includes a leading decimal point
360
361    error conditions:
362        raises ValueError if the string cannot be parsed
363    """
364    assert isinstance(dmsStr, basestring)
365    m = _DegRE.match(dmsStr) or _DegMinRE.match(dmsStr) or _DegMinSecRE.match(dmsStr)
366    if m is None:
367        raise ValueError("splitDMSStr cannot parse %s as a sexagesimal string" % (dmsStr))
368    matchSet = list(m.groups())
369    if matchSet[-1] is None:
370        matchSet[-1] = ''
371    return matchSet
372
373_FloatRE = re.compile(r'^\s*[-+]?[0-9]*\.?[0-9]*(e[-+]?)?[0-9]*\s*$', re.IGNORECASE)
374_FloatNoExpRE = re.compile(r'^\s*[-+]?[0-9]*\.?[0-9]*\s*$')
375def floatFromStr(astr, allowExp=1):
376    """Convert a string representation of a number to a float;
377    unlike float(), partial representations (such as "", "-", "-.e") are taken as 0
378    and "nan" is forbidden.
379
380    error conditions:
381        raises ValueError if astr cannot be converted
382    """
383    if allowExp:
384        match = _FloatRE.match(astr)
385    else:
386        match = _FloatNoExpRE.match(astr)
387
388
389    if match is None:
390        raise ValueError("cannot convert :%s: to a float" % (astr))
391
392    try:
393        return float(astr)
394    except Exception:
395        # partial float
396        return 0.0
397
398_IntRE = re.compile(r'^\s*[-+]?[0-9]*\s*$')
399def intFromStr(astr):
400    """Convert a string representation of a number to an integer;
401    unlike int(), the blank string and "+" and "-" are treated as 0
402
403    error conditions:
404        raises ValueError if astr cannot be converted
405    """
406    if _IntRE.match(astr) is None:
407        raise ValueError("cannot convert :%s: to an integer" % (astr))
408
409    try:
410        return int(astr)
411    except Exception:
412        # partial int
413        return 0
414
415def quoteStr(astr, escChar='\\', quoteChar='"'):
416    """Escape all instances of quoteChar and escChar in astr
417    with a preceding escChar and surrounds the result with quoteChar.
418
419    Examples:
420    astr = 'foo" \bar'
421    quoteStr(astr) = '"foo\" \\bar"'
422    quoteStr(astr, escChar = '"') = '"foo"" \bar"'
423
424    This prepares a string for output.
425    """
426    if escChar != quoteChar:
427        # escape escChar
428        astr = astr.replace(escChar, escChar + escChar)
429    # escape quoteChar and surround the result in quoteChar
430    return quoteChar + astr.replace(quoteChar, escChar + quoteChar) + quoteChar
431
432def unquoteStr(astr, escChar='\\', quoteChars='"\''):
433    """Remove quotes from a string and unescapes contained escaped quotes.
434
435    Based on email.unquote.
436    """
437    if len(astr) > 1:
438        for quoteChar in quoteChars:
439            if astr.startswith(quoteChar) and astr.endswith(quoteChar):
440                return astr[1:-1].replace(escChar + escChar, escChar).replace(escChar + quoteChar, quoteChar)
441    return astr
442
443def strFromException(exc):
444    """Unicode-safe replacement for str(exception)"""
445    try:
446        return str(exc)
447    except Exception:
448        try:
449            return ",".join([unicode(s) for s in exc.args])
450        except Exception:
451            # in case exc is some unexpected type
452            return repr(exc)
453
454def _getDMSFields (decDeg, nFields=3, precision=1):
455    """Return a string representation of dms fields for decDeg.
456
457    Inputs:
458    - decDeg: value in decimal degrees or hours
459    - nFields: number of fields; must be >= 1 (and >3 is probably never used)
460    - precision: number of digits after the decimal point in the last field;
461        if 0, no decimal point is printed; must be >= 0
462
463    Returns:
464    - signStr: "" or "-"
465    - fieldStrs: string value of each field (all positive)
466
467    To compute dms a a string, use: signStr + ":".join(fieldStrs)
468    This routine doesn't take that step to allow omitting fields
469    whose value is zero (e.g. see dmsStrFromDeg and dmsStrFromSec).
470
471    Error conditions:
472    - Raises ValueError if precision < 0
473    """
474    if nFields < 1:
475        raise ValueError("nFields=%r; must be >= 1" % (nFields,))
476    if precision < 0:
477        raise ValueError("precision=%r; must be >= 0" % (precision,))
478
479    if decDeg < 0:
480        signStr = "-"
481    else:
482        signStr = ""
483
484    if nFields == 1:
485        retNum = round(abs(decDeg), precision)
486        retStr = "%.*f" % (precision, retNum)
487        return signStr, [retStr]
488
489    # compute a list of output fields; all but the last one are integer
490    remVal = abs(decDeg)
491    fieldNums = []
492    for fieldNum in range(nFields-1):
493        (intVal, remVal) = divmod (abs(remVal), 1.0)
494        intVal = int(intVal)
495        fieldNums.append(intVal)
496        remVal *= 60.0
497    fieldNums.append(round(remVal, precision))
498
499    # handle overflow
500    incrPrevField = False
501    for ind in range(nFields-1, -1, -1):
502        if incrPrevField:
503            fieldNums[ind] += 1
504        if (ind > 0) and (fieldNums[ind] >= 60):
505            fieldNums[ind] -= 60
506            incrPrevField = True
507        else:
508            incrPrevField = False
509
510    # compute fieldStrs
511    if precision > 0:
512        minFloatWidth = precision + 3
513    else:
514        minFloatWidth = 2
515    fieldStrs = ["%d" % (fieldNums[0],)]
516    for numVal in fieldNums[1:-1]:
517        fieldStrs.append("%02d" % (numVal,))
518    fieldStrs.append("%0*.*f" % (minFloatWidth, precision, fieldNums[-1]))
519
520    return signStr, fieldStrs
521
522def _assertTest():
523    """Run a test by comparing results to those expected and only failing if something is wrong.
524
525    Use _printTest to generate data for this test.
526    """
527    # format is a set of lists of:
528    # - dms string
529    # - comment
530    # - should work (True or False)
531    # - splitStr: the expected output from splitDMSStr
532    # - degVal: the expected output from degFromDMSStr
533    # - secVal: the expected output from secFromDMSStr
534    # - neatStr: the expected output from neatenDMSStr
535    # - a list of three expected outputs from dmsStrFromDeg with nFields=3 and:
536    #   - precision = 0
537    #   - precision = 1
538    #   - precision = 2
539    testSet = (
540        ['::', '', True, [''    , '', '', '', ''], 0.0, 0.0, '0:00:00', ['0:00:00', '0:00:00.0', '0:00:00.00']],
541        ['-::', '', True, ['-', '', '', '', ''], -0.0, -0.0, '0:00:00', ['0:00:00', '0:00:00.0', '0:00:00.00']],
542        ['-0:00:00.01', '', True, ['-', '0', '00', '00', '.01'], -2.7777777777777775e-06, -0.01, '-0:00:00.01', ['-0:00:00', '-0:00:00.0', '-0:00:00.01']],
543        [' +1', '', True, ['+', '1', ''], 1.0, 1.0, '1', ['1:00:00', '1:00:00.0', '1:00:00.00']],
544        ['-1.2345', '', True, ['-', '1', '.2345'], -1.2344999999999999, -1.2344999999999999, '-1.2345', ['-1:14:04', '-1:14:04.2', '-1:14:04.20']],
545        ['-123::', '', True, ['-', '123', '', '', ''], -123.0, -442800.0, '-123:00:00', ['-123:00:00', '-123:00:00.0', '-123:00:00.00']],
546        ['-123:4', 'make sure seconds field is not 60 from dmsStrFromDeg', True, ['-', '123', '4', ''], -123.06666666666666, -7384.0, '-123:04', ['-123:04:00', '-123:04:00.0', '-123:04:00.00']],
547        ['-123:45', '', True, ['-', '123', '45', ''], -123.75, -7425.0, '-123:45', ['-123:45:00', '-123:45:00.0', '-123:45:00.00']],
548        ['-123:4.56789', '', True, ['-', '123', '4', '.56789'], -123.0761315, -7384.5678900000003, '-123:04.56789', ['-123:04:34', '-123:04:34.1', '-123:04:34.07']],
549        ['-123:45.6789', '', True, ['-', '123', '45', '.6789'], -123.761315, -7425.6788999999999, '-123:45.6789', ['-123:45:41', '-123:45:40.7', '-123:45:40.73']],
550        ['1:2:', '', True, ['', '1', '2', '', ''], 1.0333333333333334, 3720.0, '1:02:00', ['1:02:00', '1:02:00.0', '1:02:00.00']],
551        ['1:2:3', '', True, ['', '1', '2', '3', ''], 1.0341666666666667, 3723.0, '1:02:03', ['1:02:03', '1:02:03.0', '1:02:03.00']],
552        ['1:2:3.456789', '', True, ['', '1', '2', '3', '.456789'], 1.0342935525000001, 3723.4567889999998, '1:02:03.456789', ['1:02:03', '1:02:03.5', '1:02:03.46']],
553        ['1:23:4', '', True, ['', '1', '23', '4', ''], 1.3844444444444444, 4984.0, '1:23:04', ['1:23:04', '1:23:04.0', '1:23:04.00']],
554        ['1:23:45', '', True, ['', '1', '23', '45', ''], 1.3958333333333333, 5025.0, '1:23:45', ['1:23:45', '1:23:45.0', '1:23:45.00']],
555        ['123:45:6.789', '', True, ['', '123', '45', '6', '.789'], 123.75188583333333, 445506.78899999999, '123:45:06.789', ['123:45:07', '123:45:06.8', '123:45:06.79']],
556        ['123:45:56.789', '', True, ['', '123', '45', '56', '.789'], 123.76577472222222, 445556.78899999999, '123:45:56.789', ['123:45:57', '123:45:56.8', '123:45:56.79']],
557        ['-0::12.34', 'bug test; the sign must be retained', True, ['-', '0', '', '12', '.34'], -0.0034277777777777779, -12.34, '-0:00:12.34', ['-0:00:12', '-0:00:12.3', '-0:00:12.34']],
558        ['-::12.34', 'a weird gray area, but it works', True, ['-', '', '', '12', '.34'], -0.0034277777777777779, -12.34, '-0:00:12.34', ['-0:00:12', '-0:00:12.3', '-0:00:12.34']],
559        ['::12.34', '', True, ['', '', '', '12', '.34'], 0.0034277777777777779, 12.34, '0:00:12.34', ['0:00:12', '0:00:12.3', '0:00:12.34']],
560        ['1:23.4567', '', True, ['', '1', '23', '.4567'], 1.3909450000000001, 83.456699999999998, '1:23.4567', ['1:23:27', '1:23:27.4', '1:23:27.40']],
561        ['-1.234567', '', True, ['-', '1', '.234567'], -1.234567, -1.234567, '-1.234567', ['-1:14:04', '-1:14:04.4', '-1:14:04.44']],
562        ['-1:abadstr', 'invalid characters', False, None, None, None, None, None],
563        ['-1:2343:24', 'too many minutes digits', False, None, None, None, None, None],
564        ['1:-1:24', 'minus sign in wrong place', False, None, None, None, None, None],
565    )
566    def locAssert(fmt, res, func, *args, **kargs):
567        assert fmt % (res,) == fmt % (func(*args, **kargs),), "%r != %r = %s(*%r, **%r)" % (res, func(*args, **kargs), func.__name__, args, kargs)
568
569    nErrors = 0
570    for testStr, commentStr, isOK, splitStr, degVal, secVal, neatStr, dmsStr02 in testSet:
571        try:
572            locAssert("%r", splitStr, splitDMSStr, testStr)
573            locAssert("%.8g", degVal, degFromDMSStr, testStr)
574            locAssert("%.8g", secVal, secFromDMSStr, testStr)
575            locAssert("%r", neatStr, neatenDMSStr, testStr)
576            locAssert("%r", dmsStr02[0], dmsStrFromDeg, degVal, 3, 0)
577            locAssert("%r", dmsStr02[1], dmsStrFromDeg, degVal, 3, 1)
578            locAssert("%r", dmsStr02[2], dmsStrFromDeg, degVal, 3, 2)
579            if not isOK:
580                print("unexpected success on %r" % testStr)
581                nErrors += 1
582        except Exception as e:
583            if isOK:
584                raise
585                print("unexpected failure on %r\n\t%s\nskipping other tests on this value" % (testStr, e))
586                nErrors += 1
587
588    if nErrors == 0:
589        print("RO.StringUtil passed")
590    else:
591        print("RO.StringUtil failed with %d errors" % nErrors)
592
593
594def _printTest(dmsSet = None):
595    """Print the results of running each routine on a set of test data.
596    Data format is a list of tuples, each containing two elements:
597        dmsStr to test, a comment
598
599    The output is in the format used by _assertTest, but please use this with great caution.
600    You must examine the output very carefully to confirm it is correct before updating _assertTest!
601    """
602    print("Exercising RO string utilities")
603    if not dmsSet:
604        dmsSet = (
605            ("::", ""),
606            ("-::", ""),
607            ('-0:00:00.01', ""),
608            (" +1", ""),
609            ('-1.2345', ''),
610            ('-123::', ''),
611            ('-123:4', 'make sure seconds field is not 60 from dmsStrFromDeg'),
612            ('-123:45', ''),
613            ('-123:4.56789', ''),
614            ('-123:45.6789', ''),
615            ('1:2:', ''),
616            ('1:2:3', ''),
617            ('1:2:3.456789', ''),
618            ('1:23:4', ''),
619            ('1:23:45', ''),
620            ('123:45:6.789', ''),
621            ('123:45:56.789', ''),
622            ('-0::12.34', 'bug test; the sign must be retained'),
623            ('-::12.34', 'a weird gray area, but it works'),
624            ('::12.34', ''),
625            ('1:23.4567', ''),
626            ('-1.234567', ''),
627            ('-1:abadstr', 'invalid characters'),
628            ('-1:2343:24', 'too many minutes digits'),
629            ('1:-1:24', 'minus sign in wrong place'),
630        )
631
632    for testStr, commentStr in dmsSet:
633        # note: if splitDMSStr succeeds, then the other calls all should succeed
634        if checkDMSStr(testStr):
635            try:
636                itemList = splitDMSStr(testStr)
637                deg = degFromDMSStr (testStr)
638                sec = secFromDMSStr (testStr)
639                neatStr = neatenDMSStr (testStr)
640                outDMSStr = []
641                for prec in range(3):
642                    outDMSStr.append(dmsStrFromDeg(deg, precision=prec))
643                print("[%r, %r, True, %r, %r, %r, %r, %r]," % (testStr, commentStr, itemList, deg, sec, neatStr, outDMSStr))
644            except Exception as e:
645                print("unexpected failure on %r (%s); error = %s" % (testStr, commentStr, e))
646        else:
647            print("[%r, %r, False, %r, %r, %r, %r, %r]," % tuple([testStr, commentStr] + [None]*5))
648
649if __name__ == "__main__":
650    doPrint = False
651    if doPrint:
652        _printTest()
653    else:
654        _assertTest()
655