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 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"