Repository: unt-libraries/edtf-validate

Issue:
Upgrade from draft specification
Upgrade from the draft specification to the [Extended Date/Time Format (EDTF) Specification released February 4, 2019](https://www.loc.gov/standards/datetime/).

Discussion:

Patch:
diff --git a/edtf_validate/valid_edtf.py b/edtf_validate/valid_edtf.py
index b4702f8..a4e0cdc 100644
--- a/edtf_validate/valid_edtf.py
+++ b/edtf_validate/valid_edtf.py
@@ -13,7 +13,7 @@ import calendar
 import datetime
 import re
 
-from pyparsing import Optional, oneOf, OneOrMore, ZeroOrMore, Word, alphas
+from pyparsing import Optional, oneOf, OneOrMore, ZeroOrMore, Empty, StringStart, StringEnd
 """
 ------------------------------------------------------------------------------
 LEVEL 0 GRAMMAR START
@@ -28,8 +28,7 @@ positive_year = (
     | digit + digit + positiveDigit + digit
     | digit + digit + digit + positiveDigit
 )
-negative_year = "-" + positive_year
-year = positive_year | negative_year | "0000"
+non_negative_year = positive_year | "0000"
 # date
 oneThru12 = oneOf("01 02 03 04 05 06 07 08 09 10 11 12")
 oneThru13 = oneThru12 | "13"
@@ -47,13 +46,13 @@ monthDay = (
     | oneOf("04 06 09 11") + "-" + oneThru30
     | "02-" + oneThru29
 )
-yearMonth = year + "-" + month
-yearMonthDay = year + "-" + monthDay
+non_negative_yearMonth = non_negative_year + "-" + month
+non_negative_yearMonthDay = non_negative_year + "-" + monthDay
 hour = zeroThru23
 minute = zeroThru59
 second = zeroThru59
 day = oneThru31
-date = yearMonthDay | yearMonth | year
+non_negative_date = non_negative_yearMonthDay | non_negative_yearMonth | non_negative_year
 baseTime = hour + ":" + minute + ":" + second | "24:00:00"
 zoneOffsetHour = oneThru13
 zoneOffset = "Z" | (
@@ -62,25 +61,33 @@ zoneOffset = "Z" | (
     | "00:" + oneThru59
 )
 time = baseTime + Optional(zoneOffset)
-dateAndTime = date + "T" + time
-L0Interval = date + "/" + date
+dateAndTime = non_negative_date + "T" + time
+L0Interval = non_negative_date + "/" + non_negative_date
 """
 ------------------------------------------------------------------------------
 LEVEL 1 GRAMMAR START
 ------------------------------------------------------------------------------
 """
+# Negative years
+negative_year = "-" + positive_year
+year = positive_year | negative_year | "0000"
+yearMonth = year + "-" + month
+yearMonthDay = year + "-" + monthDay
+date = yearMonthDay | yearMonth | year
+negative_date = negative_year + "-" + monthDay | negative_year + "-" + month | negative_year
+negative_time = negative_date + "T" + time
 # Auxiliary Assignments for Level 1
-UASymbol = oneOf("? ~ ?~")
+UASymbol = oneOf("? ~ %")
 seasonNumber = oneOf("21 22 23 24")
 season = year + "-" + seasonNumber
 dateOrSeason = season | date
 # uncertain Or Approximate Date
 uncertainOrApproxDate = date + UASymbol
 # unspecified
-yearWithOneOrTwoUnspecifedDigits = Optional("-") + digit + digit + (digit | 'u') + 'u'
-monthUnspecified = year + '-uu'
-dayUnspecified = yearMonth + '-uu'
-dayAndMonthUnspecified = year + '-uu-uu'
+yearWithOneOrTwoUnspecifedDigits = Optional("-") + digit + digit + (digit | 'X') + 'X'
+monthUnspecified = year + '-XX'
+dayUnspecified = yearMonth + '-XX'
+dayAndMonthUnspecified = year + '-XX-XX'
 unspecified = (
     dayAndMonthUnspecified
     | dayUnspecified
@@ -89,14 +96,17 @@ unspecified = (
 )
 # L1Interval
 L1Interval = (
-    (dateOrSeason + UASymbol | dateOrSeason | "unknown") + "/"
-    + (dateOrSeason + UASymbol | "open" | "unknown" | season)
-    | (dateOrSeason + UASymbol | "unknown" | season) + "/"
-    + (dateOrSeason + UASymbol | dateOrSeason | "open" | "unknown")
+    negative_date + "/" + date
+    | (dateOrSeason + UASymbol | dateOrSeason | "..") + "/"
+    + (dateOrSeason + UASymbol | ".." | season)
+    | (dateOrSeason + UASymbol | ".." | season) + "/"
+    + (dateOrSeason + UASymbol | dateOrSeason | "..")
+    | (Empty() + "/" + (dateOrSeason + UASymbol | dateOrSeason | ".."))
+    | (dateOrSeason + UASymbol | dateOrSeason | "..") + "/" + Empty()
 )
 # Long Year - Simple Form
 longYearSimple = (
-    "y" + Optional("-")
+    "Y" + Optional("-")
     + positiveDigit + digit + digit + digit + OneOrMore(digit)
 )
 """
@@ -105,60 +115,52 @@ LEVEL 2 GRAMMAR START
 ------------------------------------------------------------------------------
 """
 # Internal Uncertain or Approximate
-# this block of code could stand to be cleaned up a bit.
-# there are some cases where we could use Optional() instead of another OR
-IUABase = (
-    (
-        year + UASymbol + "-(" + month + ")"
-        + UASymbol + "-(" + day + ")" + UASymbol
+UAYear = (year + UASymbol | UASymbol + year)
+UAYearOrYear = UAYear | year
+UAMonth = (month + UASymbol | UASymbol + month)
+UAMonthOrMonth = UAMonth | month
+UADay = (day + UASymbol | UASymbol + day)
+UADayOrDay = UADay | day
+
+internalUncertainOrApproximate = (
+    ~(StringStart() + uncertainOrApproxDate + StringEnd()) + (
+        UAYear + Optional("-" + UAMonthOrMonth + "-" + UADayOrDay | "-" + UAMonthOrMonth)
+        | UAYearOrYear + "-" + UAMonth + Optional("-" + UADayOrDay)
+        | UAYearOrYear + "-" + UAMonthOrMonth + "-" + UADay
+        | season + UASymbol
     )
-    | year + UASymbol + "-(" + month + ")" + UASymbol + Optional("-" + day)
-    | year + "-(" + month + ")" + UASymbol + "-(" + day + ")" + UASymbol
-    | year + "-(" + month + ")" + UASymbol + Optional("-" + day)
-    | year + UASymbol + "-" + monthDay + UASymbol
-    | (
-        "(" + year + ")" + Optional(UASymbol)
-        + "-" + monthDay + Optional(UASymbol)
-    )
-    | year + UASymbol + "-" + monthDay
-    | year + UASymbol + "-" + month + "-(" + day + ")" + UASymbol
-    | year + UASymbol + "-(" + month + ")" + UASymbol
-    | yearMonth + UASymbol + "-(" + day + ")" + UASymbol
-    | yearMonth + "-(" + day + ")" + UASymbol
-    | year + "-(" + monthDay + ")" + UASymbol
-    | yearMonth + UASymbol + "-" + day
-    | year + UASymbol + "-" + month
-    | season + UASymbol
 )
-internalUncertainOrApproximate = IUABase | "(" + IUABase + ")" + UASymbol
 # Internal Unspecified
-positiveDigitOrU = positiveDigit | "u"
-digitOrU = positiveDigitOrU | "0"
-yearWithU = (
-    Optional("-") + "u" + digitOrU + digitOrU + digitOrU
-    | Optional("-") + digitOrU + "u" + digitOrU + digitOrU
-    | Optional("-") + digitOrU + digitOrU + "u" + digitOrU
-    | Optional("-") + digitOrU + digitOrU + digitOrU + "u"
+positiveDigitOrX = positiveDigit | "X"
+digitOrX = positiveDigitOrX | "0"
+yearWithX = (
+    Optional("-") + "X" + digitOrX + digitOrX + digitOrX
+    | Optional("-") + digitOrX + "X" + digitOrX + digitOrX
+    | Optional("-") + digitOrX + digitOrX + "X" + digitOrX
+    | Optional("-") + digitOrX + digitOrX + digitOrX + "X"
 )
-monthWithU = "u" + digitOrU | "0u" | "1u"
+monthWithX = "X" + digitOrX | "0X" | "1X"
 oneThru3 = oneOf("1 2 3")
-dayWithU = "u" + digitOrU | oneThru3 + "u" | "0u"
-monthDayWithU = (
-    monthWithU + "-" + dayWithU
-    | month + "-" + dayWithU
-    | monthWithU + "-" + day
+dayWithX = "X" + digitOrX | oneThru3 + "X" | "0X"
+monthDayWithX = (
+    monthWithX + "-" + dayWithX
+    | month + "-" + dayWithX
+    | monthWithX + "-" + day
+)
+yearMonthWithX = (
+    yearWithX + "-" + monthWithX
+    | yearWithX + "-" + month
+    | year + "-" + monthWithX
 )
-yearMonthWithU = (
-    yearWithU + "-" + monthWithU
-    | yearWithU + "-" + month
-    | year + "-" + monthWithU
+yearmonthDayWithX = (
+    yearWithX + "-" + monthDayWithX
+    | yearWithX + "-" + monthDay
+    | year + "-" + monthDayWithX
 )
-yearMonthDayWithU = (
-    yearWithU + "-" + monthDayWithU
-    | yearWithU + "-" + monthDay
-    | year + "-" + monthDayWithU
+internalUnspecified = (
+    ~(StringStart() + unspecified + StringEnd())
+    + (yearmonthDayWithX | yearMonthWithX | yearWithX)
 )
-internalUnspecified = yearMonthDayWithU | yearMonthWithU | yearWithU
 # Auxiliary Assignments for Level 2
 dateWithInternalUncertainty = (
     internalUncertainOrApproximate | internalUnspecified
@@ -172,48 +174,48 @@ consecutives = (
 earlier = ".." + date
 later = date + ".."
 listElement = (
-    dateWithInternalUncertainty
-    | uncertainOrApproxDate
-    | unspecified
-    | consecutives
-    | date
+    dateWithInternalUncertainty.leaveWhitespace()
+    | uncertainOrApproxDate.leaveWhitespace()
+    | unspecified.leaveWhitespace()
+    | consecutives.leaveWhitespace()
+    | date.leaveWhitespace()
 )
 listContent = (
-    earlier + "," + Optional(" ")
-    + ZeroOrMore(listElement + "," + Optional(" ")) + later
-    | ZeroOrMore(listElement + "," + Optional(" ")) + consecutives
-    | ZeroOrMore(listElement + "," + Optional(" ")) + later
-    | earlier + ZeroOrMore("," + Optional(" ") + listElement)
-    | listElement + OneOrMore("," + Optional(" ") + listElement)
+    earlier + "," + ZeroOrMore(listElement + ",") + later
+    | ZeroOrMore(listElement + ",") + consecutives
+    | ZeroOrMore(listElement + ",") + later
+    | earlier + ZeroOrMore("," + listElement)
+    | listElement + OneOrMore("," + listElement)
     | consecutives
 )
 choiceList = "[" + listContent + "]"
 inclusiveList = "{" + listContent + "}"
-# Masked precision
-maskedPrecision = Optional("-") + digit + digit + ((digit + "x") | "xx")
 # L2Interval
-L2Interval = (
+L2Interval = (~(StringStart() + L1Interval + StringEnd()) + (
     dateWithInternalUncertainty + "/" + dateWithInternalUncertainty
     | dateOrSeason + "/" + dateWithInternalUncertainty
     | dateWithInternalUncertainty + "/" + dateOrSeason
-)
+))
 # Long Year - Scientific Form
 positiveInteger = positiveDigit + ZeroOrMore(digit)
 longYearScientific = (
-    "y" + Optional("-") + positiveInteger + "e" + positiveInteger
-    + Optional("p" + positiveInteger)
+    "Y" + Optional("-") + positiveInteger + "E" + positiveInteger
 )
-# SeasonQualified
-qualifyingString = Word(alphas)
-seasonQualifier = qualifyingString
-seasonQualified = season + "^" + seasonQualifier
+# Significant digits
+significantDigitYear = (
+    (year | longYearScientific | longYearSimple)
+    + ("S" + positiveInteger)
+)
+# Season for sub-year grouping
+extendedSeasonNumber = oneOf("25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41")
+extendedSeason = year + "-" + extendedSeasonNumber
 """
 ------------------------------------------------------------------------------
 GLOBAL GRAMMAR START
 ------------------------------------------------------------------------------
 """
 # level 0 consists of an interval, date and time or date
-level0Expression = L0Interval | dateAndTime | date.leaveWhitespace()
+level0Expression = L0Interval | dateAndTime | non_negative_date.leaveWhitespace()
 # level 1
 level1Expression = (
     L1Interval
@@ -221,23 +223,26 @@ level1Expression = (
     | uncertainOrApproxDate
     | unspecified
     | season
+    | negative_time
+    | negative_date
 )
 # level 2
 level2Expression = (
     L2Interval
-    | longYearScientific
     | choiceList
     | inclusiveList
-    | internalUncertainOrApproximate
     | internalUnspecified
-    | maskedPrecision
-    | seasonQualified
+    | internalUncertainOrApproximate
+    | extendedSeason
+    | significantDigitYear
+    | longYearScientific
 )
 # everything resolves to a 'dateTimeString'
 dateTimeString = level2Expression | level1Expression | level0Expression
 interval_replacements = {
     '~': '',
     '?': '',
+    '%': '',
 }
 
 
@@ -271,106 +276,106 @@ def replace_season(season_date, marker):
     return '-'.join([y_part, m_part])
 
 
-U_PATTERN = re.compile(r'(-?)([\du]{4})(-[\du]{2})?(-[\du]{2})?/'
-                       r'(-?)([\du]{4})(-[\du]{2})?(-[\du]{2})?')
+X_PATTERN = re.compile(r'(-?)([\dX]{4})(-[\dX]{2})?(-[\dX]{2})?/'
+                       r'(-?)([\dX]{4})(-[\dX]{2})?(-[\dX]{2})?')
 
 
-def replace_u_start_month(month):
+def replace_X_start_month(month):
     """Find the earliest legitimate month."""
     month = month.lstrip('-')
-    if month == 'uu' or month == '0u':
+    if month == 'XX' or month == '0X':
         return '01'
-    if month == 'u0':
+    if month == 'X0':
         return '10'
-    return month.replace('u', '0')
+    return month.replace('X', '0')
 
 
-def replace_u_end_month(month):
+def replace_X_end_month(month):
     """Find the latest legitimate month."""
     month = month.lstrip('-')
-    if month == 'uu' or month == '1u':
+    if month == 'XX' or month == '1X':
         return '12'
-    if month == 'u0':
+    if month == 'X0':
         return '10'
-    if month == '0u':
+    if month == '0X':
         return '09'
     if month[1] in ['1', '2']:
-        # 'u1' or 'u2'
-        return month.replace('u', '1')
-    # Otherwise it should match r'u[3-9]'.
-    return month.replace('u', '0')
+        # 'X1' or 'X2'
+        return month.replace('X', '1')
+    # Otherwise it should match r'X[3-9]'.
+    return month.replace('X', '0')
 
 
-def replace_u_start_day(day):
+def replace_X_start_day(day):
     """Find the earliest legitimate day."""
     day = day.lstrip('-')
-    if day == 'uu' or day == '0u':
+    if day == 'XX' or day == '0X':
         return '01'
-    if day == 'u0':
+    if day == 'X0':
         return '10'
-    return day.replace('u', '0')
+    return day.replace('X', '0')
 
 
-def replace_u_end_day(day, year, month):
+def replace_X_end_day(day, year, month):
     """Find the latest legitimate day."""
     day = day.lstrip('-')
     year = int(year)
     month = int(month.lstrip('-'))
-    if day == 'uu' or day == '3u':
+    if day == 'XX' or day == '3X':
         # Use the last day of the month for a given year/month.
         return str(calendar.monthrange(year, month)[1])
-    if day == '0u' or day == '1u':
-        return day.replace('u', '9')
-    if day == '2u' or day == 'u9':
+    if day == '0X' or day == '1X':
+        return day.replace('X', '9')
+    if day == '2X' or day == 'X9':
         if month != '02' or calendar.isleap(year):
             return '29'
-        elif day == '2u':
+        elif day == '2X':
             # It is Feburary and not a leap year.
             return '28'
         else:
             # It is February, not a leap year, day ends in 9.
             return '19'
-    # 'u2' 'u3' 'u4' 'u5' 'u6' 'u7' 'u8'
+    # 'X2' 'X3' 'X4' 'X5' 'X6' 'X7' 'X8'
     if 1 < int(day[1]) < 9:
-        return day.replace('u', '2')
-    # 'u0' 'u1'
-    if day == 'u1':
+        return day.replace('X', '2')
+    # 'X0' 'X1'
+    if day == 'X1':
         if calendar.monthrange(year, month)[1] == 31:
             # See if the month has a 31st.
             return '31'
         else:
             return '21'
-    if day == 'u0':
+    if day == 'X0':
         if calendar.monthrange(year, month)[1] >= 30:
             return '30'
     else:
         return '20'
 
 
-def replace_u(matchobj):
-    """Break the interval into parts, and replace 'u's.
+def replace_X(matchobj):
+    """Break the interval into parts, and replace 'X's.
 
     pieces - [pos/neg, start_year, start_month, start_day,
               pos/neg, end_year, end_month, end_day]
     """
     pieces = list(matchobj.groups(''))
-    # Replace "u"s in start and end years.
-    if 'u' in pieces[1]:
-        pieces[1] = pieces[1].replace('u', '0')
-    if 'u' in pieces[5]:
-        pieces[5] = pieces[5].replace('u', '9')
-    # Replace "u"s in start month.
-    if 'u' in pieces[2]:
-        pieces[2] = '-' + replace_u_start_month(pieces[2])
-    # Replace "u"s in end month.
-    if 'u' in pieces[6]:
-        pieces[6] = '-' + replace_u_end_month(pieces[6])
-    # Replace "u"s in start day.
-    if 'u' in pieces[3]:
-        pieces[3] = '-' + replace_u_start_day(pieces[3])
-    # Replace "u"s in end day.
-    if 'u' in pieces[7]:
-        pieces[7] = '-' + replace_u_end_day(pieces[7], year=pieces[5],
+    # Replace "X"s in start and end years.
+    if 'X' in pieces[1]:
+        pieces[1] = pieces[1].replace('X', '0')
+    if 'X' in pieces[5]:
+        pieces[5] = pieces[5].replace('X', '9')
+    # Replace "X"s in start month.
+    if 'X' in pieces[2]:
+        pieces[2] = '-' + replace_X_start_month(pieces[2])
+    # Replace "X"s in end month.
+    if 'X' in pieces[6]:
+        pieces[6] = '-' + replace_X_end_month(pieces[6])
+    # Replace "X"s in start day.
+    if 'X' in pieces[3]:
+        pieces[3] = '-' + replace_X_start_day(pieces[3])
+    # Replace "X"s in end day.
+    if 'X' in pieces[7]:
+        pieces[7] = '-' + replace_X_end_day(pieces[7], year=pieces[5],
                                             month=pieces[6])
     return ''.join((''.join(pieces[:4]), '/', ''.join(pieces[4:])))
 
@@ -439,10 +444,10 @@ def is_valid_interval(edtf_candidate):
     # initialize interval flags for special cases, assume positive
     end, start = 'pos', 'pos'
     if edtf_candidate.count('/') == 1:
-        # replace all 'problem' cases (unspecified, 0000 date, ?~, -, y)
+        # replace all 'problem' cases (unspecified, 0000 date, %, -, y)
         # break the interval into two date strings
         edtf_candidate = replace_all(edtf_candidate, interval_replacements)
-        edtf_candidate = re.sub(U_PATTERN, replace_u, edtf_candidate)
+        edtf_candidate = re.sub(X_PATTERN, replace_X, edtf_candidate)
         parts = edtf_candidate.split('/')
         # set flag for negative start date
         if parts[0].startswith("-"):
@@ -488,15 +493,13 @@ def is_valid_interval(edtf_candidate):
         # zero '-' characters means we are matching a year
         if parts[0].count("-") == 0:
             # if from_date is unknown, we can assume the lowest possible date
-            if parts[0] == 'unknown':
+            if parts[0] in [Empty(), '..']:
                 from_date = datetime.datetime.strptime("0001", "%Y")
             else:
                 from_date = datetime.datetime.strptime(parts[0], "%Y")
         if parts[1].count("-") == 0:
             # when the to_date is open and the from_date is valid, it's valid
-            if parts[1] == 'open' or parts[1] == 'unknown':
-                to_date = 'open'
-            else:
+            if not parts[1] in [Empty(), '..']:
                 to_date = datetime.datetime.strptime(parts[1], "%Y")
         # if it starts negative and ends positive, that's always True
         if start == 'neg' and end == 'pos':
@@ -507,15 +510,18 @@ def is_valid_interval(edtf_candidate):
                 return True
         # if the to_date is unknown or open, it could be any date, therefore
         elif (
-            parts[1] == 'unknown'
-            or parts[1] == 'open'
-            or parts[0] == 'unknown'
+            parts[1] == Empty()
+            or parts[1] == '..'
+            or parts[0] == Empty()
+            or parts[0] == '..'
         ):
             return True
         # if start and end are positive, the from_date must be <= to_date
         elif start == 'pos' and end == 'pos':
             if from_date <= to_date and from_date and to_date:
                 return True
+            else:
+                return False
         else:
             return False
     else:
@@ -556,7 +562,6 @@ def isLevel2(edtf_candidate):
 
 def is_valid(edtf_candidate):
     """isValid takes a candidate date and returns if it is valid or not"""
-
     if (
         isLevel0(edtf_candidate)
         or isLevel1(edtf_candidate)


Test patch:
diff --git a/tests/test_valid_edtf.py b/tests/test_valid_edtf.py
index 1b496d0..6d7dd17 100644
--- a/tests/test_valid_edtf.py
+++ b/tests/test_valid_edtf.py
@@ -32,16 +32,19 @@ class TestIsValid(object):
         '-1000/-0090-10',
         '-1000/2000',
         '1000/2000',
-        'unknown/2000',
-        'unknown/open',
-        '2017-01-14/open',
+        '/2000',
+        '/..',
+        '../',
+        '../1985-04',
+        '2001-34',
+        '2017-01-14/..',
         '0000/0000',
         '0000-02/1111',
         '0000-01/0000-01-03',
         '0000-01-13/0000-01-23',
         '1111-01-01/1111',
         '0000-01/0000',
-        '2000-uu/2012',
+        '2000-XX/2012',
         '1952-23~/1953',
         '1923-21/1924',
         '1981/1982-23',
@@ -49,49 +52,50 @@ class TestIsValid(object):
         '2003-22/2004-22',
         '2003-22/2003-22',
         '2003-22/2003-23',
-        '2000-12-uu/2012',
-        '2000-uu-10/2012',
-        '-2000-uu-10/2012',
-        '2000-uu-uu/2012',
-        '2000/2000-uu-uu',
+        '2000-12-XX/2012',
+        '2000-XX-10/2012',
+        '-2000-XX-10/2012',
+        '2000-XX-XX/2012',
+        '2000/2000-XX-XX',
         '1712/2012',
-        '198u/199u',
-        '198u/1999',
-        '1987/199u',
-        '1984-11-2u/1999-01-01',
-        '1984-11-12/1984-11-uu',
-        '198u-11-uu/198u-11-30',
+        '198X/199X',
+        '198X/1999',
+        '1987/199X',
+        '1984-11-2X/1999-01-01',
+        '1984-11-12/1984-11-XX',
+        '198X-11-XX/198X-11-30',
         '-1980-11-01/1989-11-30',
-        '1919-uu-02/1919-uu-01',
-        '1919-0u-02/1919-01-03',
-        '1865-u2-02/1865-03-01',
+        '1919-XX-02/1919-XX-01',
+        '1919-0X-02/1919-01-03',
+        '1865-X2-02/1865-03-01',
         '-1981-11-10/1980-11-30',
-        '1930-u0-10/1930-10-30',
-        '1981-1u-10/1981-11-09',
-        '1919-12-02/1919-uu-04',
-        '1919-11-02/1919-1u-01',
-        '1919-09-02/1919-u0-01',
-        '1919-08-02/1919-0u-01',
-        '1919-10-02/1919-u1-01',
-        '1919-04-01/1919-u4-02',
-        '1602-10-0u/1602-10-02',
-        '2018-05-u0/2018-05-11',
-        '-2018-05-u0/2018-05-11',
-        '1200-01-u4/1200-01-08',
-        '1919-uu-02/1919-uu-01',
-        '1919-07-30/1919-07-3u',
-        '1908-05-02/1908-05-0u',
-        '0501-11-18/0501-11-1u',
-        '1112-08-22/1112-08-2u',
-        '2015-02-27/2015-02-u8',
-        '2016-02-28/2016-02-u9',
+        '1930-X0-10/1930-10-30',
+        '1981-1X-10/1981-11-09',
+        '1919-12-02/1919-XX-04',
+        '1919-11-02/1919-1X-01',
+        '1919-09-02/1919-X0-01',
+        '1919-08-02/1919-0X-01',
+        '1919-10-02/1919-X1-01',
+        '1919-04-01/1919-X4-02',
+        '1602-10-0X/1602-10-02',
+        '2018-05-X0/2018-05-11',
+        '-2018-05-X0/2018-05-11',
+        '1200-01-X4/1200-01-08',
+        '1919-XX-02/1919-XX-01',
+        '1919-07-30/1919-07-3X',
+        '1908-05-02/1908-05-0X',
+        '0501-11-18/0501-11-1X',
+        '1112-08-22/1112-08-2X',
+        '2015-02-27/2015-02-X8',
+        '2016-02-28/2016-02-X9',
+        '-2001-02-03',
     ])
     def test_valid_edtf_interval(self, date):
         assert is_valid(date)
 
     @pytest.mark.parametrize('date', [
         '2012/2013/1234/55BULBASAUR',
-        'NONE/unknown',
+        'NONE/',
         '2012///4444',
         '2012\\2013',
         '2000/12-12',
@@ -100,7 +104,7 @@ class TestIsValid(object):
         '0800/-0999',
         '-1000/-2000',
         '1000/-2000',
-        'y-61000/-2000',
+        'Y-61000/-2000',
         '0001/0000',
         '0000-01-03/0000-01',
         '0000/-0001',
@@ -110,12 +114,12 @@ class TestIsValid(object):
         assert not is_valid(date)
 
     @pytest.mark.parametrize('date', [
-        '+20067890?~',
-        'y2006',
+        '+20067890%',
+        'Y2006',
         '-0000',
-        '+y20067890-14-10?~',
-        '+20067890?~',
-        '+2006?~',
+        '+Y20067890-14-10%',
+        '+20067890%',
+        '+2006%',
     ])
     def test_invalid_edtf_date_match(self, date):
         # is_valid should fail if match doesn't exist
@@ -125,23 +129,18 @@ class TestIsValid(object):
 class TestLevel0(object):
     @pytest.mark.parametrize('date', [
         '2001-02-03',
-        '-2001-02-03',
         '2008-12',
         '2008',
-        '-0999',
-        '-9999',
         '0000',
         '2001-02-03T09:30:01',
         '2004-01-01T10:10:10Z',
         '2012-10-10T10:10:10Z',
-        '-2012-10-10T10:10:10Z',
         '2004-01-01T10:10:10+05:00',
         '1964/2008',
         '2004-06/2006-08',
         '2004-02-01/2005-02-08',
         '2004-02-01/2005-02',
         '2004-02-01/2005',
-        '-2004-02-01/2005',
         '2005/2006-02',
     ])
     def test_valid_level_0(self, date):
@@ -155,6 +154,7 @@ class TestLevel0(object):
         '1863-03-29 ',
         '18 63-03-29',
         '1863-0 3-29',
+        '-1959',
     ])
     def test_invalid_level_0(self, date):
         assert not isLevel0(date)
@@ -167,32 +167,41 @@ class TestLevel1(object):
         '2004-06?',
         '2004-06-11?',
         '1984~',
-        '1984?~',
-        '199u',
-        '19uu',
-        '-19uu',
-        '1999-uu',
-        '1999-01-uu',
-        '1999-uu-uu',
-        '-1999-uu-uu',
-        'unknown/2006',
-        '2004-06-01/unknown',
-        '-2004-06-01/unknown',
-        '2004-01-01/open',
+        '1984%',
+        '199X',
+        '19XX',
+        '-19XX',
+        '1999-XX',
+        '1999-01-XX',
+        '1999-XX-XX',
+        '-1999-XX-XX',
+        '/2006',
+        '2004-06-01/',
+        '-2004-06-01/',
+        '2004-01-01/..',
         '1984~/2004-06',
         '1984/2004-06~',
         '1984~/2004~',
-        '1984?/2004?~',
+        '-1984?/2004%',
         '1984-06?/2004-08?',
         '1984-06-02?/2004-08-08~',
-        '1984-06-02?/unknown',
-        'y170000002',
-        'y-170000002',
+        '1984-06-02?/',
+        'Y170000002',
+        'Y-170000002',
         '2001-21',
         '2003-22',
         '-2003-22',
         '2000-23',
         '2010-24',
+        '2004-06-11%',
+        '-1985',
+        '-1000/-0999',
+        '-2001-02-03',
+        '-2012-10-10T10:10:10Z',
+        '-2004-02-01/2005',
+        '-2004-06-01/..',
+        '../-2004-02-01',
+        '2019-12/2020%',
     ])
     def test_valid_level_1(self, date):
         assert isLevel1(date)
@@ -204,43 +213,67 @@ class TestLevel1(object):
 class TestLevel2(object):
     @pytest.mark.parametrize('date', [
         '2004?-06-11',
-        '-2004?-06-11',
         '2004-06~-11',
-        '2004-(06)?-11',
-        '2004-06-(11)~',
-        '-2004-06-(11)~',
-        '2004-(06)?~',
-        '2004-(06-11)?',
-        '2004?-06-(11)~',
-        '(2004-(06)~)?',
-        '2004?-(06)?~',
-        '(2004)?-06-04~',
-        '(2011)-06-04~',
-        '2011-(06-04)~',
+        '2004-?06-11',
+        '2004-?06-?11',
+        '2004-06-~11',
+        '?2004-06-04~',
+        '2004-%06',
+        '2004-06-?11',
+        '2004?-06-~11',
+        '?2004-06%',
+        '2004?-%06',
+        '?2004-06-04~',
+        '2011-06-~04',
         '2011-23~',
-        '-2011-23~',
-        '156u-12-25',
-        '15uu-12-25',
-        '15uu-12-uu',
-        '-15uu-12-uu',
-        '1560-uu-25',
-        '[1667,1668, 1670..1672]',
+        '-2004?-06-11',
+        '-2004-06~-11',
+        '-2004-?06-11',
+        '-2004-?06-?11',
+        '-2004-06-~11',
+        '?-2004-06-04~',
+        '-2004-%06',
+        '-2004-06-?11',
+        '-2004?-06-~11',
+        '?-2004-06%',
+        '-2004?-%06',
+        '?-2004-06',
+        '-1529-?09-11',
+        '-2004?-06-11',
+        '%-2011-06-13',
+        '156X-12-25',
+        '15XX-12-25',
+        '15XX-12-XX',
+        '-15XX-12-XX',
+        '1560-XX-25',
+        '-1X32-X1-X2',
         '[..1760-12-03]',
         '[1760-12..]',
-        '[1760-01, 1760-02, 1760-12..]',
-        '[-1760-01, 1760-02, 1760-12..]',
-        '[1667, 1760-12]',
-        '{1667,1668, 1670..1672}',
-        '{1960, 1961-12}',
-        '196x',
-        '19xx',
-        '-19xx',
-        '2004-06-(01)~/2004-06-(20)~',
-        '2004-06-uu/2004-07-03',
-        'y17e7',
-        'y-17e7',
-        'y17101e4p3',
-        '2001-21^southernHemisphere',
+        '[1760-01,1760-02,1760-12..]',
+        '[-1760-01,1760-02,1760-12..]',
+        '[1667,1760-12]',
+        '{1667,1668,1670..1672}',
+        '2004-06-~01/2004-06-~20',
+        '{1960,1961-12}',
+        '2004-06-~01/2004-06-~20',
+        '2004-06-XX/2004-07-03',
+        'Y17E7',
+        'Y-17E7',
+        'Y17101E4S3',
+        '1950S2',
+        'Y171010000S3',
+        '2001-29',
+        '2001-34',
+        '2019-12/%2020',
+        '2004-06-~01/2004-06-~20',
+        '%2001',
+        '1984?-06/2004-08?',
+        '-1984-?06-02/2004-08-08~',
+        '1984-06-?02/2004-06-11%',
+        '2019-12/~2020',
+        '2004-06-11%/2004-%06',
+        '2004-06~/2004-06-%11',
+        '1984?/2004~-06'
     ])
     def test_valid_level_2(self, date):
         assert isLevel2(date)
@@ -248,6 +281,29 @@ class TestLevel2(object):
     @pytest.mark.parametrize('date', [
         '1960-06-31',
         '[1 760-01, 1760-02, 1760-12..]',
+        '[1667,1668, 1670..1672]',
+        '196X',
+        '19XX',
+        '-19XX',
+        '2001-21^southernHemisphere',
+        'Y170000002',
+        'Y-170000002',
+        '-1998',
+        '2006%',
+        '1985-04/2020~',
+        '2019-12/2020%',
+        '-1984?/2004%',
+        '1984-06?/2004-08?',
+        '-1984-06-02?/2004-08-08~',
+        '1984-06-02?/',
+        '2019-12/2020%',
+        '2004-06-11%/2004-06~',
+        '2004-06~/2004-06-11%',
+        '1984?/2004-06~',
+        '-2012-10-10T10:10:10Z',
+        '-2001-02-03',
+        '-1985',
+        '-1000/-0999',
     ])
     def test_invalid_level_2(self, date):
         assert not isLevel2(date)