nl2time 0.1.0__tar.gz
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-0.1.0/.gitignore +6 -0
- nl2time-0.1.0/LICENSE +21 -0
- nl2time-0.1.0/PKG-INFO +73 -0
- nl2time-0.1.0/README.md +48 -0
- nl2time-0.1.0/nl2time/__init__.py +24 -0
- nl2time-0.1.0/nl2time/clock.py +167 -0
- nl2time-0.1.0/nl2time/context.py +116 -0
- nl2time-0.1.0/nl2time/data.py +73 -0
- nl2time-0.1.0/nl2time/engine.py +809 -0
- nl2time-0.1.0/nl2time/errors.py +21 -0
- nl2time-0.1.0/nl2time/ir.py +209 -0
- nl2time-0.1.0/pyproject.toml +43 -0
- nl2time-0.1.0/tests/test_parity.py +45 -0
- nl2time-0.1.0/tests/test_validate.py +76 -0
- nl2time-0.1.0/tests/triage.py +67 -0
- nl2time-0.1.0/uv.lock +188 -0
nl2time-0.1.0/.gitignore
ADDED
nl2time-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nl2time contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
nl2time-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nl2time
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Deterministic temporal-expression IR engine — Python port of the nl2time natural language ⇄ date/time library
|
|
5
|
+
Project-URL: Homepage, https://github.com/AndyFooBlah/nl2time
|
|
6
|
+
Project-URL: Repository, https://github.com/AndyFooBlah/nl2time
|
|
7
|
+
Project-URL: Changelog, https://github.com/AndyFooBlah/nl2time/blob/main/CHANGELOG.md
|
|
8
|
+
Project-URL: Issues, https://github.com/AndyFooBlah/nl2time/issues
|
|
9
|
+
Author: Andrew Brook
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: datetime,llm,natural-language,parser,temporal,timezone
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: whenever>=0.8
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# nl2time (Python)
|
|
27
|
+
|
|
28
|
+
Deterministic resolution of temporal-expression IR — the Python port of
|
|
29
|
+
[nl2time](https://github.com/AndyFooBlah/nl2time), a bidirectional natural
|
|
30
|
+
language ⇄ date/time library built around a JSON intermediate representation
|
|
31
|
+
(`TimeExpr`).
|
|
32
|
+
|
|
33
|
+
**Scope of this release: the language-neutral core.** IR validation,
|
|
34
|
+
`TimeContext` (reference instant, IANA timezone, locale-derived week/date
|
|
35
|
+
policy, ambiguity knobs), and the full deterministic engine `resolve()` —
|
|
36
|
+
every operator including calendar snapping, DST-correct offsets, holidays
|
|
37
|
+
(computed Easter), business-day spans, and ordered ambiguity candidates.
|
|
38
|
+
Natural-language *parsing* and *rendering* are not yet ported; today the
|
|
39
|
+
expected producers of IR are the JS parser, an LLM emitting schema-constrained
|
|
40
|
+
IR, or your own code. Time model: [whenever](https://pypi.org/project/whenever/)
|
|
41
|
+
(Temporal-inspired).
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from nl2time import TimeContext, resolve
|
|
45
|
+
|
|
46
|
+
ctx = TimeContext.make({
|
|
47
|
+
"now": "2026-07-20T17:00:00Z",
|
|
48
|
+
"timeZone": "America/Los_Angeles",
|
|
49
|
+
"locale": "en-US",
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
# "last week" as IR (e.g. produced by the JS parser or an LLM):
|
|
53
|
+
expr = {
|
|
54
|
+
"op": "snap",
|
|
55
|
+
"base": {"op": "offset", "base": {"op": "now"}, "amount": -1, "unit": "week"},
|
|
56
|
+
"unit": "week",
|
|
57
|
+
}
|
|
58
|
+
resolve(expr, ctx)["candidates"][0]
|
|
59
|
+
# {"kind": "interval", "start": Instant("2026-07-12 07:00:00Z"),
|
|
60
|
+
# "end": Instant("2026-07-19 07:00:00Z"), "grain": "week"}
|
|
61
|
+
# (values are `whenever.Instant`; candidates are ordered, ambiguity is data)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Parity guarantee
|
|
65
|
+
|
|
66
|
+
This engine reproduces the JS reference **bit-exactly** — instants, grains,
|
|
67
|
+
and candidate order — across all 2,760 machine-generated engine-parity
|
|
68
|
+
fixtures derived from the project's six-language conformance corpora, enforced
|
|
69
|
+
in CI on every commit. Spec: [ir-spec.md](https://github.com/AndyFooBlah/nl2time/blob/main/docs/ir-spec.md);
|
|
70
|
+
strategy: [porting.md](https://github.com/AndyFooBlah/nl2time/blob/main/docs/porting.md);
|
|
71
|
+
test-data provenance: [corpus/ATTRIBUTIONS.md](https://github.com/AndyFooBlah/nl2time/blob/main/corpus/ATTRIBUTIONS.md).
|
|
72
|
+
|
|
73
|
+
MIT.
|
nl2time-0.1.0/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# nl2time (Python)
|
|
2
|
+
|
|
3
|
+
Deterministic resolution of temporal-expression IR — the Python port of
|
|
4
|
+
[nl2time](https://github.com/AndyFooBlah/nl2time), a bidirectional natural
|
|
5
|
+
language ⇄ date/time library built around a JSON intermediate representation
|
|
6
|
+
(`TimeExpr`).
|
|
7
|
+
|
|
8
|
+
**Scope of this release: the language-neutral core.** IR validation,
|
|
9
|
+
`TimeContext` (reference instant, IANA timezone, locale-derived week/date
|
|
10
|
+
policy, ambiguity knobs), and the full deterministic engine `resolve()` —
|
|
11
|
+
every operator including calendar snapping, DST-correct offsets, holidays
|
|
12
|
+
(computed Easter), business-day spans, and ordered ambiguity candidates.
|
|
13
|
+
Natural-language *parsing* and *rendering* are not yet ported; today the
|
|
14
|
+
expected producers of IR are the JS parser, an LLM emitting schema-constrained
|
|
15
|
+
IR, or your own code. Time model: [whenever](https://pypi.org/project/whenever/)
|
|
16
|
+
(Temporal-inspired).
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from nl2time import TimeContext, resolve
|
|
20
|
+
|
|
21
|
+
ctx = TimeContext.make({
|
|
22
|
+
"now": "2026-07-20T17:00:00Z",
|
|
23
|
+
"timeZone": "America/Los_Angeles",
|
|
24
|
+
"locale": "en-US",
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
# "last week" as IR (e.g. produced by the JS parser or an LLM):
|
|
28
|
+
expr = {
|
|
29
|
+
"op": "snap",
|
|
30
|
+
"base": {"op": "offset", "base": {"op": "now"}, "amount": -1, "unit": "week"},
|
|
31
|
+
"unit": "week",
|
|
32
|
+
}
|
|
33
|
+
resolve(expr, ctx)["candidates"][0]
|
|
34
|
+
# {"kind": "interval", "start": Instant("2026-07-12 07:00:00Z"),
|
|
35
|
+
# "end": Instant("2026-07-19 07:00:00Z"), "grain": "week"}
|
|
36
|
+
# (values are `whenever.Instant`; candidates are ordered, ambiguity is data)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Parity guarantee
|
|
40
|
+
|
|
41
|
+
This engine reproduces the JS reference **bit-exactly** — instants, grains,
|
|
42
|
+
and candidate order — across all 2,760 machine-generated engine-parity
|
|
43
|
+
fixtures derived from the project's six-language conformance corpora, enforced
|
|
44
|
+
in CI on every commit. Spec: [ir-spec.md](https://github.com/AndyFooBlah/nl2time/blob/main/docs/ir-spec.md);
|
|
45
|
+
strategy: [porting.md](https://github.com/AndyFooBlah/nl2time/blob/main/docs/porting.md);
|
|
46
|
+
test-data provenance: [corpus/ATTRIBUTIONS.md](https://github.com/AndyFooBlah/nl2time/blob/main/corpus/ATTRIBUTIONS.md).
|
|
47
|
+
|
|
48
|
+
MIT.
|
|
@@ -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"
|
|
@@ -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)
|
|
@@ -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)
|
|
@@ -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"
|