nl2time 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.
- nl2time/__init__.py +24 -0
- nl2time/clock.py +167 -0
- nl2time/context.py +116 -0
- nl2time/data.py +73 -0
- nl2time/engine.py +809 -0
- nl2time/errors.py +21 -0
- nl2time/ir.py +209 -0
- nl2time-0.1.0.dist-info/METADATA +73 -0
- nl2time-0.1.0.dist-info/RECORD +11 -0
- nl2time-0.1.0.dist-info/WHEEL +4 -0
- nl2time-0.1.0.dist-info/licenses/LICENSE +21 -0
nl2time/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""nl2time: natural language <-> dates/times, language-neutral IR core.
|
|
2
|
+
|
|
3
|
+
Python port of the JS reference (see docs/porting.md in the repository).
|
|
4
|
+
v0: IR types + structural validator, TimeContext, and the deterministic
|
|
5
|
+
engine `resolve()`. Parsers and describe() land in later phases.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from . import errors
|
|
9
|
+
from .context import TimeContext
|
|
10
|
+
from .engine import MAX_CANDIDATES, resolve, resolve_one, value_to_json
|
|
11
|
+
from .ir import IR_VERSION, validate_expr
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"IR_VERSION",
|
|
15
|
+
"MAX_CANDIDATES",
|
|
16
|
+
"TimeContext",
|
|
17
|
+
"errors",
|
|
18
|
+
"resolve",
|
|
19
|
+
"resolve_one",
|
|
20
|
+
"validate_expr",
|
|
21
|
+
"value_to_json",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
__version__ = "0.0.1"
|
nl2time/clock.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""The clock seam: every `whenever` usage in nl2time flows through this module.
|
|
2
|
+
|
|
3
|
+
Python counterpart of src/clock/index.ts (which wraps Temporal):
|
|
4
|
+
Temporal.Instant -> whenever.Instant, Temporal.ZonedDateTime ->
|
|
5
|
+
whenever.ZonedDateTime. Semantics mirrored:
|
|
6
|
+
|
|
7
|
+
- calendar units (day and coarser) add/subtract preserving wall-clock time
|
|
8
|
+
across DST, with Temporal-'compatible' disambiguation;
|
|
9
|
+
- hour/minute/second arithmetic is exact (absolute time);
|
|
10
|
+
- month/year arithmetic clamps day-of-month (Jan 31 + 1 month = Feb 28/29).
|
|
11
|
+
|
|
12
|
+
No other module may import `whenever` directly.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
from whenever import Date, Instant, ZonedDateTime
|
|
20
|
+
|
|
21
|
+
WEEKDAY_NUM: dict[str, int] = {
|
|
22
|
+
"mon": 1,
|
|
23
|
+
"tue": 2,
|
|
24
|
+
"wed": 3,
|
|
25
|
+
"thu": 4,
|
|
26
|
+
"fri": 5,
|
|
27
|
+
"sat": 6,
|
|
28
|
+
"sun": 7,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def weekday_number(w: str) -> int:
|
|
33
|
+
return WEEKDAY_NUM[w]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ZInterval:
|
|
38
|
+
"""Half-open zoned interval [start, end) carrying its granularity."""
|
|
39
|
+
|
|
40
|
+
start: ZonedDateTime
|
|
41
|
+
end: ZonedDateTime
|
|
42
|
+
grain: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def system_now() -> Instant:
|
|
46
|
+
return Instant.now()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def system_time_zone() -> str:
|
|
50
|
+
return ZonedDateTime.now_in_system_tz().tz
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_instant(s: str) -> Instant:
|
|
54
|
+
return Instant.parse_iso(s)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def format_instant(i: Instant) -> str:
|
|
58
|
+
"""Temporal.Instant.toString()-compatible: `Z` suffix, trailing zeros trimmed."""
|
|
59
|
+
return i.format_iso()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def to_zoned(instant: Instant, time_zone: str) -> ZonedDateTime:
|
|
63
|
+
return instant.to_tz(time_zone)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def point_interval(z: ZonedDateTime) -> ZInterval:
|
|
67
|
+
"""Point interval at z (grain 'instant')."""
|
|
68
|
+
return ZInterval(z, z, "instant")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def start_of_day(z: ZonedDateTime) -> ZonedDateTime:
|
|
72
|
+
return z.start_of("day")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def with_fields(
|
|
76
|
+
z: ZonedDateTime,
|
|
77
|
+
*,
|
|
78
|
+
year: int | None = None,
|
|
79
|
+
month: int | None = None,
|
|
80
|
+
day: int | None = None,
|
|
81
|
+
) -> ZonedDateTime:
|
|
82
|
+
"""Temporal `.with({year?, month?, day?})` with 'constrain' overflow.
|
|
83
|
+
|
|
84
|
+
whenever's replace() raises on out-of-range days; Temporal clamps
|
|
85
|
+
(e.g. `.with({month: 2, day: 31})` on Jan 15 -> Feb 28). Clamp manually.
|
|
86
|
+
"""
|
|
87
|
+
y = year if year is not None else z.year
|
|
88
|
+
m = month if month is not None else z.month
|
|
89
|
+
d = day if day is not None else z.day
|
|
90
|
+
d = min(d, Date(y, m, 1).days_in_month())
|
|
91
|
+
return z.replace(year=y, month=m, day=d, disambiguate="compatible")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def add_calendar(z: ZonedDateTime, **units: int) -> ZonedDateTime:
|
|
95
|
+
"""Calendar-aware add with Temporal-'compatible' disambiguation."""
|
|
96
|
+
return z.add(**units, disambiguate="compatible")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def floor_to(z: ZonedDateTime, unit: str, week_start: str) -> ZonedDateTime:
|
|
100
|
+
if unit == "second":
|
|
101
|
+
return z.round("second", mode="floor")
|
|
102
|
+
if unit == "minute":
|
|
103
|
+
return z.round("minute", mode="floor")
|
|
104
|
+
if unit == "hour":
|
|
105
|
+
return z.round("hour", mode="floor")
|
|
106
|
+
if unit == "day":
|
|
107
|
+
return z.start_of("day")
|
|
108
|
+
if unit == "week":
|
|
109
|
+
target = WEEKDAY_NUM[week_start]
|
|
110
|
+
# day_of_week is ISO (mon=1..sun=7); step back to the week-start day.
|
|
111
|
+
delta = (z.date().day_of_week().value - target + 7) % 7
|
|
112
|
+
return z.start_of("day").subtract(days=delta, disambiguate="compatible")
|
|
113
|
+
if unit == "month":
|
|
114
|
+
return with_fields(z, day=1).start_of("day")
|
|
115
|
+
if unit == "quarter":
|
|
116
|
+
q_start_month = (z.month - 1) // 3 * 3 + 1
|
|
117
|
+
return with_fields(z, month=q_start_month, day=1).start_of("day")
|
|
118
|
+
if unit == "year":
|
|
119
|
+
return with_fields(z, month=1, day=1).start_of("day")
|
|
120
|
+
raise ValueError(f"unknown unit {unit!r}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def add_units(z: ZonedDateTime, unit: str, n: int) -> ZonedDateTime:
|
|
124
|
+
"""Calendar-aware unit addition (day+ preserves wall clock; h/m/s exact)."""
|
|
125
|
+
if unit == "second":
|
|
126
|
+
return z.add(seconds=n)
|
|
127
|
+
if unit == "minute":
|
|
128
|
+
return z.add(minutes=n)
|
|
129
|
+
if unit == "hour":
|
|
130
|
+
return z.add(hours=n)
|
|
131
|
+
if unit == "day":
|
|
132
|
+
return add_calendar(z, days=n)
|
|
133
|
+
if unit == "week":
|
|
134
|
+
return add_calendar(z, weeks=n)
|
|
135
|
+
if unit == "month":
|
|
136
|
+
return add_calendar(z, months=n)
|
|
137
|
+
if unit == "quarter":
|
|
138
|
+
return add_calendar(z, months=3 * n)
|
|
139
|
+
if unit == "year":
|
|
140
|
+
return add_calendar(z, years=n)
|
|
141
|
+
raise ValueError(f"unknown unit {unit!r}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def containing_unit(z: ZonedDateTime, unit: str, week_start: str) -> ZInterval:
|
|
145
|
+
"""The containing unit interval of z: floor, extend one unit."""
|
|
146
|
+
start = floor_to(z, unit, week_start)
|
|
147
|
+
return ZInterval(start, add_units(start, unit, 1), unit)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def calendar_days_between(from_z: ZonedDateTime, to_z: ZonedDateTime) -> int:
|
|
151
|
+
"""Whole calendar days between two zoned datetimes (calendar difference)."""
|
|
152
|
+
return int(from_z.start_of("day").date().until(to_z.start_of("day").date(), total="days"))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def compare(a: ZonedDateTime, b: ZonedDateTime) -> int:
|
|
156
|
+
"""Instant-order comparison (Temporal.ZonedDateTime.compare)."""
|
|
157
|
+
x = a.timestamp_nanos()
|
|
158
|
+
y = b.timestamp_nanos()
|
|
159
|
+
return -1 if x < y else (1 if x > y else 0)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def epoch_millis(z: ZonedDateTime) -> int:
|
|
163
|
+
return z.timestamp_millis()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def add_milliseconds(z: ZonedDateTime, ms: int) -> ZonedDateTime:
|
|
167
|
+
return z.add(milliseconds=ms)
|
nl2time/context.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""TimeContext: all resolution policy flows through here (port of src/context.ts).
|
|
2
|
+
|
|
3
|
+
Options use the same camelCase names as the JS reference and the corpus wire
|
|
4
|
+
format (`timeZone`, `weekStart`, ...); attributes are snake_case.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from whenever import Instant, ZonedDateTime
|
|
12
|
+
|
|
13
|
+
from .clock import parse_instant, system_now, system_time_zone, to_zoned
|
|
14
|
+
from .data import DayPeriodRule, first_day_for_region
|
|
15
|
+
from .errors import ConfigError
|
|
16
|
+
|
|
17
|
+
_MDY_REGIONS = frozenset(["US", "PH", "UM", "VI", "GU", "AS", "PR"])
|
|
18
|
+
_YMD_REGIONS = frozenset(["CN", "JP", "KR", "TW", "HU", "MN", "LT"])
|
|
19
|
+
|
|
20
|
+
_WEEKDAYS = frozenset(["mon", "tue", "wed", "thu", "fri", "sat", "sun"])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_locale(locale: str) -> tuple[str, str | None]:
|
|
24
|
+
"""Split a BCP-47 tag without Intl: language = first subtag; region = the
|
|
25
|
+
first 2-letter-alpha or 3-digit subtag after it (script subtags skipped)."""
|
|
26
|
+
parts = locale.replace("_", "-").split("-")
|
|
27
|
+
language = parts[0].lower()
|
|
28
|
+
if not language or not language.isascii() or not language.isalpha():
|
|
29
|
+
raise ConfigError(f"invalid locale: {locale}")
|
|
30
|
+
region: str | None = None
|
|
31
|
+
for sub in parts[1:]:
|
|
32
|
+
if (len(sub) == 2 and sub.isascii() and sub.isalpha()) or (
|
|
33
|
+
len(sub) == 3 and sub.isdigit()
|
|
34
|
+
):
|
|
35
|
+
region = sub.upper()
|
|
36
|
+
break
|
|
37
|
+
return language, region
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _normalize_day_periods(rules: list[dict[str, Any]]) -> list[DayPeriodRule]:
|
|
41
|
+
"""Accept wire-format rules ({period, from, before}) or internal from_."""
|
|
42
|
+
out: list[DayPeriodRule] = []
|
|
43
|
+
for r in rules:
|
|
44
|
+
out.append(
|
|
45
|
+
{
|
|
46
|
+
"period": r["period"],
|
|
47
|
+
"from_": r["from_"] if "from_" in r else r["from"],
|
|
48
|
+
"before": r["before"],
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TimeContext:
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
now: Instant | str | None = None,
|
|
59
|
+
timeZone: str | None = None,
|
|
60
|
+
locale: str | None = None,
|
|
61
|
+
weekStart: str | None = None,
|
|
62
|
+
dateOrder: str | None = None,
|
|
63
|
+
bias: str | None = None,
|
|
64
|
+
nextWeekday: str | None = None,
|
|
65
|
+
partialPeriod: str | None = None,
|
|
66
|
+
dayPeriods: list[dict[str, Any]] | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
raw_now = now if now is not None else system_now()
|
|
69
|
+
try:
|
|
70
|
+
self.now: Instant = (
|
|
71
|
+
parse_instant(raw_now) if isinstance(raw_now, str) else raw_now
|
|
72
|
+
)
|
|
73
|
+
except Exception as e: # noqa: BLE001 - mirror JS ConfigError wrapping
|
|
74
|
+
raise ConfigError(f"invalid now: {e}") from e
|
|
75
|
+
self.time_zone: str = timeZone if timeZone is not None else system_time_zone()
|
|
76
|
+
self.locale: str = locale if locale is not None else "en-US"
|
|
77
|
+
|
|
78
|
+
self.language, self.region = _parse_locale(self.locale)
|
|
79
|
+
|
|
80
|
+
if weekStart is not None and weekStart not in _WEEKDAYS:
|
|
81
|
+
raise ConfigError(f"invalid weekStart: {weekStart}")
|
|
82
|
+
self.week_start: str = (
|
|
83
|
+
weekStart if weekStart is not None else first_day_for_region(self.region)
|
|
84
|
+
)
|
|
85
|
+
if dateOrder is not None:
|
|
86
|
+
self.date_order = dateOrder
|
|
87
|
+
elif self.region in _MDY_REGIONS:
|
|
88
|
+
self.date_order = "MDY"
|
|
89
|
+
elif self.region in _YMD_REGIONS:
|
|
90
|
+
self.date_order = "YMD"
|
|
91
|
+
else:
|
|
92
|
+
self.date_order = "DMY"
|
|
93
|
+
self.bias: str = bias if bias is not None else "none"
|
|
94
|
+
self.next_weekday: str = nextWeekday if nextWeekday is not None else "nearest"
|
|
95
|
+
self.partial_period: str = (
|
|
96
|
+
partialPeriod if partialPeriod is not None else "include"
|
|
97
|
+
)
|
|
98
|
+
self.day_periods: list[DayPeriodRule] | None = (
|
|
99
|
+
_normalize_day_periods(dayPeriods) if dayPeriods is not None else None
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Validate the timezone eagerly so failures surface at construction.
|
|
103
|
+
try:
|
|
104
|
+
to_zoned(self.now, self.time_zone)
|
|
105
|
+
except Exception as e: # noqa: BLE001
|
|
106
|
+
raise ConfigError(f"invalid timeZone: {self.time_zone}") from e
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def make(cls, opts: dict[str, Any] | None = None, /, **kwargs: Any) -> "TimeContext":
|
|
110
|
+
merged = {**(opts or {}), **kwargs}
|
|
111
|
+
return cls(**merged)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def zoned_now(self) -> ZonedDateTime:
|
|
115
|
+
"""The reference instant projected into the context timezone."""
|
|
116
|
+
return to_zoned(self.now, self.time_zone)
|
nl2time/data.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Locale data tables: CLDR weekData slice and day-period boundary rules.
|
|
2
|
+
|
|
3
|
+
Direct port of src/data/weekData.ts and src/data/dayPeriods.ts from the JS
|
|
4
|
+
reference. Keep in sync; these are near-pure data.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TypedDict
|
|
10
|
+
|
|
11
|
+
DEFAULT_FIRST_DAY = "mon"
|
|
12
|
+
|
|
13
|
+
_SUN = frozenset([
|
|
14
|
+
"AG", "AS", "BD", "BR", "BS", "BT", "BW", "BZ", "CA", "CO", "DM", "DO", "ET",
|
|
15
|
+
"GT", "GU", "HK", "HN", "ID", "IL", "IN", "JM", "JP", "KE", "KH", "KR", "LA",
|
|
16
|
+
"MH", "MM", "MO", "MT", "MX", "MZ", "NI", "NP", "PA", "PE", "PH", "PK", "PR",
|
|
17
|
+
"PT", "PY", "SA", "SG", "SV", "TH", "TT", "TW", "UM", "US", "VE", "VI", "WS",
|
|
18
|
+
"YE", "ZA", "ZW",
|
|
19
|
+
])
|
|
20
|
+
|
|
21
|
+
_SAT = frozenset([
|
|
22
|
+
"AE", "AF", "BH", "DJ", "DZ", "EG", "IQ", "IR", "JO", "KW", "LY", "OM", "QA",
|
|
23
|
+
"SD", "SY",
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
_FRI = frozenset(["MV"])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def first_day_for_region(region: str | None) -> str:
|
|
30
|
+
if not region:
|
|
31
|
+
return DEFAULT_FIRST_DAY
|
|
32
|
+
r = region.upper()
|
|
33
|
+
if r in _SUN:
|
|
34
|
+
return "sun"
|
|
35
|
+
if r in _SAT:
|
|
36
|
+
return "sat"
|
|
37
|
+
if r in _FRI:
|
|
38
|
+
return "fri"
|
|
39
|
+
return DEFAULT_FIRST_DAY
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DayPeriodRule(TypedDict):
|
|
43
|
+
"""Boundaries are [from, before) in local hours; may wrap midnight."""
|
|
44
|
+
|
|
45
|
+
period: str
|
|
46
|
+
from_: int # start hour (inclusive); key name avoids the Python keyword
|
|
47
|
+
before: int # end hour (exclusive); may be < from_ (wraps midnight)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# CLDR flexible day-period rules, English set (v1 ships en only; other
|
|
51
|
+
# languages fall back to these boundaries, mirroring the JS reference).
|
|
52
|
+
EN_RULES: list[DayPeriodRule] = [
|
|
53
|
+
{"period": "morning", "from_": 6, "before": 12},
|
|
54
|
+
{"period": "afternoon", "from_": 12, "before": 18},
|
|
55
|
+
{"period": "evening", "from_": 18, "before": 21},
|
|
56
|
+
{"period": "night", "from_": 21, "before": 6},
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def day_period_rules(language: str) -> list[DayPeriodRule]:
|
|
61
|
+
# Only 'en' data is bundled so far; other languages fall back (see JS ref).
|
|
62
|
+
del language
|
|
63
|
+
return EN_RULES
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def period_for_hour(language: str, hour: int) -> str:
|
|
67
|
+
for rule in day_period_rules(language):
|
|
68
|
+
if rule["from_"] < rule["before"]:
|
|
69
|
+
if rule["from_"] <= hour < rule["before"]:
|
|
70
|
+
return rule["period"]
|
|
71
|
+
elif hour >= rule["from_"] or hour < rule["before"]:
|
|
72
|
+
return rule["period"]
|
|
73
|
+
return "night"
|