chronoparse 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chronoparse/__init__.py +20 -0
- chronoparse/cli.py +68 -0
- chronoparse/error.py +19 -0
- chronoparse/errors.py +6 -0
- chronoparse/normalizer.py +79 -0
- chronoparse/parser.py +118 -0
- chronoparse/parsers/__init__.py +23 -0
- chronoparse/parsers/asian.py +100 -0
- chronoparse/parsers/iso8601.py +175 -0
- chronoparse/parsers/locale_formats.py +156 -0
- chronoparse/parsers/numeric.py +150 -0
- chronoparse/parsers/rfc2822.py +51 -0
- chronoparse/parsers/special.py +121 -0
- chronoparse/parsers/textual.py +215 -0
- chronoparse/parsers/time_only.py +160 -0
- chronoparse/parsers/timestamp.py +72 -0
- chronoparse/result.py +77 -0
- chronoparse/utils/__init__.py +1 -0
- chronoparse/utils/day_names.py +74 -0
- chronoparse/utils/month_names.py +239 -0
- chronoparse/utils/timezone_map.py +172 -0
- chronoparse-0.1.0.dist-info/METADATA +85 -0
- chronoparse-0.1.0.dist-info/RECORD +26 -0
- chronoparse-0.1.0.dist-info/WHEEL +4 -0
- chronoparse-0.1.0.dist-info/entry_points.txt +2 -0
- chronoparse-0.1.0.dist-info/licenses/LICENSE +21 -0
chronoparse/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
chronoparse — Universal datetime parser for all real-world formats.
|
|
3
|
+
|
|
4
|
+
Supports virtually any date/time format from any country or language.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .parser import parse
|
|
8
|
+
from .result import ParsedResult
|
|
9
|
+
from .errors import ParseError, AmbiguousDateError, InvalidDateError
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
__author__ = "Your Name"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"parse",
|
|
16
|
+
"ParsedResult",
|
|
17
|
+
"ParseError",
|
|
18
|
+
"AmbiguousDateError",
|
|
19
|
+
"InvalidDateError",
|
|
20
|
+
]
|
chronoparse/cli.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI interface: chronoparse "Oct 5, 2023 2:30 PM"
|
|
3
|
+
"""
|
|
4
|
+
import sys
|
|
5
|
+
import argparse
|
|
6
|
+
from . import parse
|
|
7
|
+
from .errors import ParseError, AmbiguousDateError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main():
|
|
11
|
+
parser = argparse.ArgumentParser(
|
|
12
|
+
prog="chronoparse",
|
|
13
|
+
description="Parse any date/time string"
|
|
14
|
+
)
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"text",
|
|
17
|
+
help="Date/time string to parse"
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--day-first",
|
|
21
|
+
action="store_true",
|
|
22
|
+
default=False,
|
|
23
|
+
help="Treat first number as day in ambiguous formats"
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--iso",
|
|
27
|
+
action="store_true",
|
|
28
|
+
default=False,
|
|
29
|
+
help="Output ISO 8601 format only"
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--strict",
|
|
33
|
+
action="store_true",
|
|
34
|
+
default=False,
|
|
35
|
+
help="Raise error on ambiguous dates"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
args = parser.parse_args()
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
result = parse(
|
|
42
|
+
args.text,
|
|
43
|
+
day_first=args.day_first,
|
|
44
|
+
raise_on_ambiguous=args.strict
|
|
45
|
+
)
|
|
46
|
+
if args.iso:
|
|
47
|
+
print(result.isoformat())
|
|
48
|
+
else:
|
|
49
|
+
print(f"Parsed: {result.dt}")
|
|
50
|
+
print(f"ISO: {result.isoformat()}")
|
|
51
|
+
print(f"Format: {result.format_name}")
|
|
52
|
+
print(f"Ambiguous:{result.ambiguous}")
|
|
53
|
+
if result.warnings:
|
|
54
|
+
for w in result.warnings:
|
|
55
|
+
print(f"Warning: {w}")
|
|
56
|
+
if result.assumptions:
|
|
57
|
+
for a in result.assumptions:
|
|
58
|
+
print(f"Assumed: {a}")
|
|
59
|
+
except ParseError as e:
|
|
60
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
except AmbiguousDateError as e:
|
|
63
|
+
print(f"Ambiguous: {e}", file=sys.stderr)
|
|
64
|
+
sys.exit(2)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
main()
|
chronoparse/error.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class ParseError(Exception):
|
|
2
|
+
"""Raised when no parser can handle the input."""
|
|
3
|
+
|
|
4
|
+
def __init__(self, message: str, original: str = ""):
|
|
5
|
+
super().__init__(message)
|
|
6
|
+
self.original = original
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AmbiguousDateError(Exception):
|
|
10
|
+
"""Raised when date is ambiguous and strict mode is enabled."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, message: str, candidates: list = None):
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.candidates = candidates or []
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InvalidDateError(Exception):
|
|
18
|
+
"""Raised when a parsed date is logically invalid."""
|
|
19
|
+
pass
|
chronoparse/errors.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Input normalization before parsing.
|
|
3
|
+
"""
|
|
4
|
+
import re
|
|
5
|
+
import unicodedata
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Filler words that can appear in date strings
|
|
9
|
+
FILLER_WORDS = re.compile(
|
|
10
|
+
r'\b(at|on|the|of|dated|date)\b',
|
|
11
|
+
re.IGNORECASE
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
# Ordinal suffixes
|
|
15
|
+
ORDINAL_SUFFIXES = re.compile(
|
|
16
|
+
r'(\d+)\s*(st|nd|rd|th)\b',
|
|
17
|
+
re.IGNORECASE
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Various unicode digit sets mapped to ASCII
|
|
21
|
+
UNICODE_DIGITS = str.maketrans({
|
|
22
|
+
# Arabic-Indic
|
|
23
|
+
'٠': '0', '١': '1', '٢': '2', '٣': '3', '٤': '4',
|
|
24
|
+
'٥': '5', '٦': '6', '٧': '7', '٨': '8', '٩': '9',
|
|
25
|
+
# Extended Arabic-Indic (Persian)
|
|
26
|
+
'۰': '0', '۱': '1', '۲': '2', '۳': '3', '۴': '4',
|
|
27
|
+
'۵': '5', '۶': '6', '۷': '7', '۸': '8', '۹': '9',
|
|
28
|
+
# Devanagari
|
|
29
|
+
'०': '0', '१': '1', '२': '2', '३': '3', '४': '4',
|
|
30
|
+
'५': '5', '६': '6', '७': '7', '८': '8', '९': '9',
|
|
31
|
+
# Bengali
|
|
32
|
+
'০': '0', '১': '1', '২': '2', '৩': '3', '৪': '4',
|
|
33
|
+
'৫': '5', '৬': '6', '৭': '7', '৮': '8', '৯': '9',
|
|
34
|
+
# Thai
|
|
35
|
+
'๐': '0', '๑': '1', '๒': '2', '๓': '3', '๔': '4',
|
|
36
|
+
'๕': '5', '๖': '6', '๗': '7', '๘': '8', '๙': '9',
|
|
37
|
+
# Myanmar
|
|
38
|
+
'၀': '0', '၁': '1', '၂': '2', '၃': '3', '၄': '4',
|
|
39
|
+
'၅': '5', '၆': '6', '၇': '7', '၈': '8', '၉': '9',
|
|
40
|
+
# Fullwidth
|
|
41
|
+
'0': '0', '1': '1', '2': '2', '3': '3', '4': '4',
|
|
42
|
+
'5': '5', '6': '6', '7': '7', '8': '8', '9': '9',
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def normalize(text: str) -> str:
|
|
47
|
+
"""
|
|
48
|
+
Normalize a raw datetime string before parsing.
|
|
49
|
+
|
|
50
|
+
Steps:
|
|
51
|
+
1. Strip whitespace
|
|
52
|
+
2. Normalize unicode digits to ASCII
|
|
53
|
+
3. Normalize unicode (NFC)
|
|
54
|
+
4. Remove ordinal suffixes (5th -> 5)
|
|
55
|
+
5. Remove filler words
|
|
56
|
+
6. Normalize multiple spaces
|
|
57
|
+
"""
|
|
58
|
+
if not text:
|
|
59
|
+
return text
|
|
60
|
+
|
|
61
|
+
# strip
|
|
62
|
+
text = text.strip()
|
|
63
|
+
|
|
64
|
+
# normalize unicode digits
|
|
65
|
+
text = text.translate(UNICODE_DIGITS)
|
|
66
|
+
|
|
67
|
+
# NFC normalization
|
|
68
|
+
text = unicodedata.normalize("NFC", text)
|
|
69
|
+
|
|
70
|
+
# remove ordinal suffixes
|
|
71
|
+
text = ORDINAL_SUFFIXES.sub(r'\1', text)
|
|
72
|
+
|
|
73
|
+
# remove filler words
|
|
74
|
+
text = FILLER_WORDS.sub(' ', text)
|
|
75
|
+
|
|
76
|
+
# normalize spaces
|
|
77
|
+
text = re.sub(r'\s+', ' ', text).strip()
|
|
78
|
+
|
|
79
|
+
return text
|
chronoparse/parser.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main entry point for chronoparse.
|
|
3
|
+
"""
|
|
4
|
+
from .normalizer import normalize
|
|
5
|
+
from .result import ParsedResult
|
|
6
|
+
from .errors import ParseError, AmbiguousDateError
|
|
7
|
+
|
|
8
|
+
from .parsers import (
|
|
9
|
+
timestamp,
|
|
10
|
+
iso8601,
|
|
11
|
+
rfc2822,
|
|
12
|
+
textual,
|
|
13
|
+
numeric,
|
|
14
|
+
asian,
|
|
15
|
+
special,
|
|
16
|
+
time_only,
|
|
17
|
+
locale_formats,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Parser pipeline: order determines priority
|
|
22
|
+
# Most specific / unambiguous parsers come first
|
|
23
|
+
PARSERS = [
|
|
24
|
+
timestamp, # unix timestamps (pure numeric, very specific)
|
|
25
|
+
iso8601, # ISO 8601 / RFC 3339
|
|
26
|
+
rfc2822, # RFC 2822 email headers
|
|
27
|
+
asian, # Japanese/Chinese/Korean with kanji markers
|
|
28
|
+
locale_formats, # German "5. Oktober", Japanese era
|
|
29
|
+
textual, # English and multilingual month names
|
|
30
|
+
special, # Quarter, year-month, pure numeric datetime
|
|
31
|
+
numeric, # Numeric date formats (MM/DD/YYYY etc)
|
|
32
|
+
time_only, # Time-only formats
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse(
|
|
37
|
+
text: str,
|
|
38
|
+
*,
|
|
39
|
+
day_first: bool = False,
|
|
40
|
+
raise_on_ambiguous: bool = False,
|
|
41
|
+
) -> ParsedResult:
|
|
42
|
+
"""
|
|
43
|
+
Parse any date/time string into a ParsedResult.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
text : str
|
|
48
|
+
The date/time string to parse. Can be in virtually any
|
|
49
|
+
format from any country or language.
|
|
50
|
+
day_first : bool, optional
|
|
51
|
+
For ambiguous formats like 05/10/2023, treat the first
|
|
52
|
+
number as the day (DD/MM/YYYY). Default is False (MM/DD/YYYY).
|
|
53
|
+
raise_on_ambiguous : bool, optional
|
|
54
|
+
If True, raise AmbiguousDateError for ambiguous dates
|
|
55
|
+
instead of returning a best-effort result.
|
|
56
|
+
|
|
57
|
+
Returns
|
|
58
|
+
-------
|
|
59
|
+
ParsedResult
|
|
60
|
+
Contains the parsed datetime, format name, and metadata.
|
|
61
|
+
|
|
62
|
+
Raises
|
|
63
|
+
------
|
|
64
|
+
TypeError
|
|
65
|
+
If input is not a string.
|
|
66
|
+
ParseError
|
|
67
|
+
If no parser can handle the input.
|
|
68
|
+
AmbiguousDateError
|
|
69
|
+
If raise_on_ambiguous=True and the result is ambiguous.
|
|
70
|
+
|
|
71
|
+
Examples
|
|
72
|
+
--------
|
|
73
|
+
>>> from chronoparse import parse
|
|
74
|
+
>>> parse("2023-10-05T14:30:00Z")
|
|
75
|
+
ParsedResult(dt='2023-10-05T14:30:00+00:00', format='iso8601_datetime', ambiguous=False)
|
|
76
|
+
|
|
77
|
+
>>> parse("October 5, 2023 2:30 PM")
|
|
78
|
+
ParsedResult(dt='2023-10-05T14:30:00', format='textual_mdy', ambiguous=False)
|
|
79
|
+
|
|
80
|
+
>>> parse("05/10/2023", day_first=True)
|
|
81
|
+
ParsedResult(dt='2023-10-05T00:00:00', format='numeric_dmy_mdy', ambiguous=True)
|
|
82
|
+
|
|
83
|
+
>>> parse("1696516200")
|
|
84
|
+
ParsedResult(dt='2023-10-05T18:30:00+00:00', format='unix_timestamp_seconds', ambiguous=False)
|
|
85
|
+
"""
|
|
86
|
+
if not isinstance(text, str):
|
|
87
|
+
raise TypeError(
|
|
88
|
+
f"parse() expects a str, got {type(text).__name__!r}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if not text.strip():
|
|
92
|
+
raise ParseError("Input string is empty.", original=text)
|
|
93
|
+
|
|
94
|
+
normalized = normalize(text)
|
|
95
|
+
|
|
96
|
+
for parser_module in PARSERS:
|
|
97
|
+
try:
|
|
98
|
+
result = parser_module.parse(
|
|
99
|
+
normalized,
|
|
100
|
+
day_first=day_first,
|
|
101
|
+
)
|
|
102
|
+
if result is not None:
|
|
103
|
+
if raise_on_ambiguous and result.ambiguous:
|
|
104
|
+
raise AmbiguousDateError(
|
|
105
|
+
f"Ambiguous date: '{text}'. "
|
|
106
|
+
f"Use day_first=True or day_first=False to resolve.",
|
|
107
|
+
candidates=[]
|
|
108
|
+
)
|
|
109
|
+
return result
|
|
110
|
+
except (ValueError, OverflowError, OSError):
|
|
111
|
+
continue
|
|
112
|
+
except (ParseError, AmbiguousDateError):
|
|
113
|
+
raise
|
|
114
|
+
|
|
115
|
+
raise ParseError(
|
|
116
|
+
f"Unable to parse datetime string: '{text}'",
|
|
117
|
+
original=text
|
|
118
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from . import (
|
|
2
|
+
timestamp,
|
|
3
|
+
iso8601,
|
|
4
|
+
rfc2822,
|
|
5
|
+
textual,
|
|
6
|
+
numeric,
|
|
7
|
+
asian,
|
|
8
|
+
special,
|
|
9
|
+
time_only,
|
|
10
|
+
locale_formats,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"timestamp",
|
|
15
|
+
"iso8601",
|
|
16
|
+
"rfc2822",
|
|
17
|
+
"textual",
|
|
18
|
+
"numeric",
|
|
19
|
+
"asian",
|
|
20
|
+
"special",
|
|
21
|
+
"time_only",
|
|
22
|
+
"locale_formats",
|
|
23
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Asian date/time formats.
|
|
3
|
+
|
|
4
|
+
Japanese: 2023年10月5日 / 2023年10月5日 14時30分00秒
|
|
5
|
+
Chinese: 2023年10月5日 / 2023年10月5日 14时30分
|
|
6
|
+
Korean: 2023년 10월 5일 / 2023년 10월 5일 14시 30분
|
|
7
|
+
Thai: พ.ศ. 2566 (Buddhist calendar)
|
|
8
|
+
"""
|
|
9
|
+
import re
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from ..result import ParsedResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ── Japanese / Chinese ───────────────────────────────────────────────────────
|
|
15
|
+
JA_ZH_DATE = re.compile(
|
|
16
|
+
r'(\d{4})[年]'
|
|
17
|
+
r'\s*(\d{1,2})[月]'
|
|
18
|
+
r'\s*(\d{1,2})[日号]?'
|
|
19
|
+
r'(?:\s*(\d{1,2})[时時時]'
|
|
20
|
+
r'\s*(\d{1,2})[分]'
|
|
21
|
+
r'(?:\s*(\d{1,2})[秒])?)?'
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# ── Korean ────────────────────────────────────────────────────────────────────
|
|
25
|
+
KO_DATE = re.compile(
|
|
26
|
+
r'(\d{4})[년]'
|
|
27
|
+
r'\s*(\d{1,2})[월]'
|
|
28
|
+
r'\s*(\d{1,2})[일]'
|
|
29
|
+
r'(?:\s*(\d{1,2})[시]'
|
|
30
|
+
r'\s*(\d{1,2})[분]'
|
|
31
|
+
r'(?:\s*(\d{1,2})[초])?)?'
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# ── Thai Buddhist year ────────────────────────────────────────────────────────
|
|
35
|
+
# พ.ศ. 2566 เดือน 10 วันที่ 5
|
|
36
|
+
THAI_DATE = re.compile(
|
|
37
|
+
r'(?:พ\.ศ\.\s*)?(\d{4})'
|
|
38
|
+
r'\s*(?:เดือน)?\s*(\d{1,2})'
|
|
39
|
+
r'\s*(?:วันที่)?\s*(\d{1,2})'
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _buddhist_to_gregorian(year: int) -> int:
|
|
44
|
+
"""Convert Thai Buddhist year to Gregorian."""
|
|
45
|
+
return year - 543
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse(text: str, **kwargs) -> ParsedResult | None:
|
|
49
|
+
text = text.strip()
|
|
50
|
+
|
|
51
|
+
# ── Japanese / Chinese ───────────────────────────────────────────────────
|
|
52
|
+
m = JA_ZH_DATE.search(text)
|
|
53
|
+
if m:
|
|
54
|
+
try:
|
|
55
|
+
year = int(m.group(1))
|
|
56
|
+
month = int(m.group(2))
|
|
57
|
+
day = int(m.group(3))
|
|
58
|
+
hour = int(m.group(4)) if m.group(4) else 0
|
|
59
|
+
minute = int(m.group(5)) if m.group(5) else 0
|
|
60
|
+
second = int(m.group(6)) if m.group(6) else 0
|
|
61
|
+
dt = datetime(year, month, day, hour, minute, second)
|
|
62
|
+
return ParsedResult(dt=dt, original=text, format_name="japanese_chinese")
|
|
63
|
+
except (ValueError, TypeError):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
# ── Korean ───────────────────────────────────────────────────────────────
|
|
67
|
+
m = KO_DATE.search(text)
|
|
68
|
+
if m:
|
|
69
|
+
try:
|
|
70
|
+
year = int(m.group(1))
|
|
71
|
+
month = int(m.group(2))
|
|
72
|
+
day = int(m.group(3))
|
|
73
|
+
hour = int(m.group(4)) if m.group(4) else 0
|
|
74
|
+
minute = int(m.group(5)) if m.group(5) else 0
|
|
75
|
+
second = int(m.group(6)) if m.group(6) else 0
|
|
76
|
+
dt = datetime(year, month, day, hour, minute, second)
|
|
77
|
+
return ParsedResult(dt=dt, original=text, format_name="korean")
|
|
78
|
+
except (ValueError, TypeError):
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
# ── Thai Buddhist ─────────────────────────────────────────────────────────
|
|
82
|
+
if 'พ.ศ.' in text or 'เดือน' in text or 'วันที่' in text:
|
|
83
|
+
m = THAI_DATE.search(text)
|
|
84
|
+
if m:
|
|
85
|
+
try:
|
|
86
|
+
buddhist_year = int(m.group(1))
|
|
87
|
+
year = _buddhist_to_gregorian(buddhist_year)
|
|
88
|
+
month = int(m.group(2))
|
|
89
|
+
day = int(m.group(3))
|
|
90
|
+
dt = datetime(year, month, day)
|
|
91
|
+
return ParsedResult(
|
|
92
|
+
dt=dt,
|
|
93
|
+
original=text,
|
|
94
|
+
format_name="thai_buddhist",
|
|
95
|
+
assumptions=[f"Converted Buddhist year {buddhist_year} to Gregorian {year}"]
|
|
96
|
+
)
|
|
97
|
+
except (ValueError, TypeError):
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
return None
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ISO 8601 and RFC 3339 parser.
|
|
3
|
+
Covers all standard variants including compact, week dates,
|
|
4
|
+
fractional seconds, and timezone offsets.
|
|
5
|
+
"""
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime, timezone, date
|
|
8
|
+
from ..result import ParsedResult
|
|
9
|
+
from ..utils.timezone_map import resolve_timezone
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ── Timezone pattern ─────────────────────────────────────────────────────────
|
|
13
|
+
TZ_PAT = r'(Z|UTC|GMT|[+-]\d{2}:?\d{2}|[+-]\d{2})?'
|
|
14
|
+
|
|
15
|
+
# ── All ISO patterns ──────────────────────────────────────────────────────────
|
|
16
|
+
ISO_PATTERNS = [
|
|
17
|
+
|
|
18
|
+
# ── Full datetime with fractional seconds ────────────────────────────────
|
|
19
|
+
# 2023-10-05T14:30:00.123456Z
|
|
20
|
+
(
|
|
21
|
+
re.compile(
|
|
22
|
+
r'(\d{4})-(\d{2})-(\d{2})'
|
|
23
|
+
r'[T ]'
|
|
24
|
+
r'(\d{2}):(\d{2}):(\d{2})\.(\d+)'
|
|
25
|
+
+ TZ_PAT, re.IGNORECASE
|
|
26
|
+
),
|
|
27
|
+
"iso8601_datetime_fractional"
|
|
28
|
+
),
|
|
29
|
+
|
|
30
|
+
# ── Full datetime with timezone ──────────────────────────────────────────
|
|
31
|
+
# 2023-10-05T14:30:00Z
|
|
32
|
+
(
|
|
33
|
+
re.compile(
|
|
34
|
+
r'(\d{4})-(\d{2})-(\d{2})'
|
|
35
|
+
r'[T ]'
|
|
36
|
+
r'(\d{2}):(\d{2}):(\d{2})'
|
|
37
|
+
+ TZ_PAT, re.IGNORECASE
|
|
38
|
+
),
|
|
39
|
+
"iso8601_datetime"
|
|
40
|
+
),
|
|
41
|
+
|
|
42
|
+
# ── Datetime without seconds ─────────────────────────────────────────────
|
|
43
|
+
# 2023-10-05T14:30Z
|
|
44
|
+
(
|
|
45
|
+
re.compile(
|
|
46
|
+
r'(\d{4})-(\d{2})-(\d{2})'
|
|
47
|
+
r'[T ]'
|
|
48
|
+
r'(\d{2}):(\d{2})'
|
|
49
|
+
+ TZ_PAT, re.IGNORECASE
|
|
50
|
+
),
|
|
51
|
+
"iso8601_datetime_no_seconds"
|
|
52
|
+
),
|
|
53
|
+
|
|
54
|
+
# ── Date only ────────────────────────────────────────────────────────────
|
|
55
|
+
# 2023-10-05
|
|
56
|
+
(
|
|
57
|
+
re.compile(r'(\d{4})-(\d{2})-(\d{2})$'),
|
|
58
|
+
"iso8601_date"
|
|
59
|
+
),
|
|
60
|
+
|
|
61
|
+
# ── Compact datetime ─────────────────────────────────────────────────────
|
|
62
|
+
# 20231005T143000Z
|
|
63
|
+
(
|
|
64
|
+
re.compile(
|
|
65
|
+
r'(\d{4})(\d{2})(\d{2})'
|
|
66
|
+
r'T'
|
|
67
|
+
r'(\d{2})(\d{2})(\d{2})'
|
|
68
|
+
+ TZ_PAT, re.IGNORECASE
|
|
69
|
+
),
|
|
70
|
+
"iso8601_compact_datetime"
|
|
71
|
+
),
|
|
72
|
+
|
|
73
|
+
# ── Compact date ─────────────────────────────────────────────────────────
|
|
74
|
+
# 20231005
|
|
75
|
+
(
|
|
76
|
+
re.compile(r'^(\d{4})(\d{2})(\d{2})$'),
|
|
77
|
+
"iso8601_compact_date"
|
|
78
|
+
),
|
|
79
|
+
|
|
80
|
+
# ── ISO Week date ────────────────────────────────────────────────────────
|
|
81
|
+
# 2023-W40-4
|
|
82
|
+
(
|
|
83
|
+
re.compile(r'(\d{4})-W(\d{2})(?:-([1-7]))?'),
|
|
84
|
+
"iso8601_week"
|
|
85
|
+
),
|
|
86
|
+
|
|
87
|
+
# ── Ordinal date ─────────────────────────────────────────────────────────
|
|
88
|
+
# 2023-278
|
|
89
|
+
(
|
|
90
|
+
re.compile(r'(\d{4})-(\d{3})$'),
|
|
91
|
+
"iso8601_ordinal"
|
|
92
|
+
),
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _resolve_tz(tz_str: str | None) -> timezone | None:
|
|
97
|
+
if not tz_str:
|
|
98
|
+
return None
|
|
99
|
+
return resolve_timezone(tz_str)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse(text: str, **kwargs) -> ParsedResult | None:
|
|
103
|
+
text = text.strip()
|
|
104
|
+
|
|
105
|
+
for pattern, name in ISO_PATTERNS:
|
|
106
|
+
m = pattern.fullmatch(text)
|
|
107
|
+
if not m:
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
g = m.groups()
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
# ISO week date
|
|
114
|
+
if name == "iso8601_week":
|
|
115
|
+
year = int(g[0])
|
|
116
|
+
week = int(g[1])
|
|
117
|
+
day_of_week = int(g[2]) if g[2] else 1
|
|
118
|
+
dt = datetime.fromisocalendar(year, week, day_of_week)
|
|
119
|
+
return ParsedResult(dt=dt, original=text, format_name=name)
|
|
120
|
+
|
|
121
|
+
# Ordinal date
|
|
122
|
+
if name == "iso8601_ordinal":
|
|
123
|
+
year = int(g[0])
|
|
124
|
+
ordinal = int(g[1])
|
|
125
|
+
dt = datetime(year, 1, 1) + __import__('datetime').timedelta(days=ordinal - 1)
|
|
126
|
+
return ParsedResult(dt=dt, original=text, format_name=name)
|
|
127
|
+
|
|
128
|
+
year, month, day = int(g[0]), int(g[1]), int(g[2])
|
|
129
|
+
|
|
130
|
+
# date only
|
|
131
|
+
if name in ("iso8601_date", "iso8601_compact_date"):
|
|
132
|
+
dt = datetime(year, month, day)
|
|
133
|
+
return ParsedResult(dt=dt, original=text, format_name=name)
|
|
134
|
+
|
|
135
|
+
hour = int(g[3])
|
|
136
|
+
minute = int(g[4])
|
|
137
|
+
|
|
138
|
+
# no seconds
|
|
139
|
+
if name == "iso8601_datetime_no_seconds":
|
|
140
|
+
tz = _resolve_tz(g[5])
|
|
141
|
+
dt = datetime(year, month, day, hour, minute, tzinfo=tz)
|
|
142
|
+
return ParsedResult(
|
|
143
|
+
dt=dt, original=text,
|
|
144
|
+
format_name=name,
|
|
145
|
+
timezone_str=g[5]
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
second = int(g[5])
|
|
149
|
+
|
|
150
|
+
# fractional seconds
|
|
151
|
+
if name == "iso8601_datetime_fractional":
|
|
152
|
+
frac = g[6]
|
|
153
|
+
microsecond = int(frac.ljust(6, "0")[:6])
|
|
154
|
+
tz = _resolve_tz(g[7])
|
|
155
|
+
dt = datetime(year, month, day, hour, minute,
|
|
156
|
+
second, microsecond, tzinfo=tz)
|
|
157
|
+
return ParsedResult(
|
|
158
|
+
dt=dt, original=text,
|
|
159
|
+
format_name=name,
|
|
160
|
+
timezone_str=g[7]
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# standard datetime
|
|
164
|
+
tz = _resolve_tz(g[6])
|
|
165
|
+
dt = datetime(year, month, day, hour, minute, second, tzinfo=tz)
|
|
166
|
+
return ParsedResult(
|
|
167
|
+
dt=dt, original=text,
|
|
168
|
+
format_name=name,
|
|
169
|
+
timezone_str=g[6]
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
except (ValueError, TypeError):
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
return None
|