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.
@@ -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,6 @@
1
+ """Compatibility error module.
2
+
3
+ This module exists to match expected package structure.
4
+ """
5
+
6
+ from .error import * # noqa: F401,F403
@@ -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