date-wrangler 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.
- date_wrangler-0.1.0/.gitignore +13 -0
- date_wrangler-0.1.0/LICENSE +21 -0
- date_wrangler-0.1.0/PKG-INFO +307 -0
- date_wrangler-0.1.0/README.md +275 -0
- date_wrangler-0.1.0/pyproject.toml +86 -0
- date_wrangler-0.1.0/src/date_wrangler/__init__.py +58 -0
- date_wrangler-0.1.0/src/date_wrangler/__main__.py +119 -0
- date_wrangler-0.1.0/src/date_wrangler/calendars.py +158 -0
- date_wrangler-0.1.0/src/date_wrangler/config.py +166 -0
- date_wrangler-0.1.0/src/date_wrangler/format.py +135 -0
- date_wrangler-0.1.0/src/date_wrangler/normalize.py +105 -0
- date_wrangler-0.1.0/src/date_wrangler/py.typed +0 -0
- date_wrangler-0.1.0/src/date_wrangler/resolve.py +326 -0
- date_wrangler-0.1.0/src/date_wrangler/rules.py +477 -0
- date_wrangler-0.1.0/src/date_wrangler/spec.py +73 -0
- date_wrangler-0.1.0/src/date_wrangler/types.py +151 -0
- date_wrangler-0.1.0/src/date_wrangler/vocab.py +150 -0
- date_wrangler-0.1.0/src/date_wrangler/wrangler.py +584 -0
- date_wrangler-0.1.0/tests/test_calendars.py +238 -0
- date_wrangler-0.1.0/tests/test_corpus.py +159 -0
- date_wrangler-0.1.0/tests/test_properties.py +280 -0
- date_wrangler-0.1.0/tests/test_semantics.py +282 -0
- date_wrangler-0.1.0/tests/test_wrangler.py +711 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Steephan Selvaradjou
|
|
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.
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: date-wrangler
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Wrangles messy human dates into clean ranges: parses 'last quarter', 'since March', '15 Jan 2024'.
|
|
5
|
+
Project-URL: Homepage, https://github.com/steephanselvaradjou/date-wrangler
|
|
6
|
+
Project-URL: Issues, https://github.com/steephanselvaradjou/date-wrangler/issues
|
|
7
|
+
Project-URL: Source, https://github.com/steephanselvaradjou/date-wrangler
|
|
8
|
+
Author: Steephan Selvaradjou
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: analytics,date,date-parser,date-range,dates,datetime,finance,fiscal,fiscal-year,natural-language,nlp,parsing,period,quarter,ytd
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: tzdata; platform_system == 'Windows'
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: hypothesis>=6; extra == 'dev'
|
|
28
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# date-wrangler
|
|
34
|
+
|
|
35
|
+
Wrangles messy human dates into clean ranges: parses `last quarter`, `since March`,
|
|
36
|
+
`15 Jan 2024`.
|
|
37
|
+
|
|
38
|
+
Whatever someone types — an absolute date, a relative expression, an open-ended range, a
|
|
39
|
+
fiscal period — it comes back as one type: a half-open `DateRange` that is safe to hand
|
|
40
|
+
straight to a query.
|
|
41
|
+
|
|
42
|
+
> **Status: early development (0.1.0).** The API may still change before 1.0.
|
|
43
|
+
|
|
44
|
+
## Why another date library
|
|
45
|
+
|
|
46
|
+
Most date libraries answer "what instant is this?". `date-wrangler` answers **"what range is
|
|
47
|
+
this?"** — which is the question you actually have when a person types `last quarter`,
|
|
48
|
+
`since March`, or `Q1 FY25` into a search box.
|
|
49
|
+
|
|
50
|
+
Everything is a range: a single day is a `DAY`-grain range, a month is a `MONTH`-grain
|
|
51
|
+
range, a fiscal quarter is a `QUARTER`-grain range on the fiscal basis. That one decision
|
|
52
|
+
is what lets days, weeks, quarters, fiscal years and open-ended intervals share an API
|
|
53
|
+
instead of each needing their own.
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install date-wrangler
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Python 3.10+. No runtime dependencies, except `tzdata` on Windows — which ships no system
|
|
62
|
+
timezone database, so the standard library needs it for the optional `tz=` argument.
|
|
63
|
+
|
|
64
|
+
## The core model
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from datetime import date
|
|
68
|
+
from date_wrangler import FiscalCalendar, Basis
|
|
69
|
+
from date_wrangler.calendars import quarter_range, month_range, current_fiscal_year
|
|
70
|
+
|
|
71
|
+
cal = FiscalCalendar.india() # fiscal year starts in April
|
|
72
|
+
fy = current_fiscal_year(date(2025, 9, 4), cal) # -> 2026
|
|
73
|
+
|
|
74
|
+
q1 = quarter_range(fy, 1, cal, Basis.FISCAL)
|
|
75
|
+
q1.start # date(2025, 4, 1)
|
|
76
|
+
q1.end # date(2025, 7, 1) <- exclusive
|
|
77
|
+
q1.end_inclusive # date(2025, 6, 30) <- for display
|
|
78
|
+
q1.grain # Grain.QUARTER
|
|
79
|
+
q1.sql("order_date")
|
|
80
|
+
# "order_date >= '2025-04-01' AND order_date < '2025-07-01'"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Ranges are half-open
|
|
84
|
+
|
|
85
|
+
`end` is the first day *outside* the range, never the last day inside it. This is the
|
|
86
|
+
Duckling convention and it exists to prevent a specific, common data bug:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
feb = month_range(2024, 2)
|
|
90
|
+
feb.end # date(2024, 3, 1)
|
|
91
|
+
feb.end_inclusive # date(2024, 2, 29)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`WHERE ts BETWEEN '2024-02-01' AND '2024-02-29'` silently drops every row after midnight
|
|
95
|
+
on the 29th when `ts` is a timestamp. `ts < '2024-03-01'` does not. Half-open ranges also
|
|
96
|
+
tile exactly — `Q1.end == Q2.start` — so they compose without off-by-one errors.
|
|
97
|
+
|
|
98
|
+
### Open-ended ranges
|
|
99
|
+
|
|
100
|
+
Either bound may be `None`, which is what makes `since March` and `before 2024`
|
|
101
|
+
expressible at all:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
r.start, r.end # date(2024, 3, 1), None
|
|
105
|
+
r.is_bounded # False
|
|
106
|
+
r.sql("d") # "d >= '2024-03-01'"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`end=None` means **unbounded** — never a silent "up to today". Deciding between those is
|
|
110
|
+
the caller's business, so you opt in explicitly:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
r.clamp(hi=date.today() + timedelta(days=1))
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Configuration
|
|
117
|
+
|
|
118
|
+
Config is passed per call and never read from a module global, so one process can serve
|
|
119
|
+
tenants on different fiscal calendars.
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from date_wrangler import WranglerConfig, FiscalCalendar, DateOrder, MonthNumber, YearLabel
|
|
123
|
+
|
|
124
|
+
WranglerConfig(
|
|
125
|
+
fiscal=FiscalCalendar.us_federal(), # October start
|
|
126
|
+
date_order=DateOrder.MDY, # how to read 03/04/2024
|
|
127
|
+
month_number=MonthNumber.YEAR, # what "jan 24" means
|
|
128
|
+
bare_period_basis=Basis.FISCAL, # what a bare "Q1" means
|
|
129
|
+
two_digit_pivot=68, # "99" -> 1999, not 2099
|
|
130
|
+
strictness="balanced",
|
|
131
|
+
)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Presets: `FiscalCalendar.india()`, `.uk()`, `.australia()`, `.us_federal()`, `.calendar()`.
|
|
135
|
+
|
|
136
|
+
Fiscal years follow the pandas `Q-MAR` convention by default — labelled by the year they
|
|
137
|
+
**end**, so with an April start FY2024 runs Apr 2023 – Mar 2024 and Apr–Jun is Q1. Set
|
|
138
|
+
`label_by=YearLabel.START_YEAR` for the US corporate convention.
|
|
139
|
+
|
|
140
|
+
Invalid configuration fails on construction with a message naming the field, not later
|
|
141
|
+
from inside `date()` on the first request that mentions a quarter.
|
|
142
|
+
|
|
143
|
+
### `jan 24` — day or year?
|
|
144
|
+
|
|
145
|
+
Genuinely ambiguous, and it depends who is writing. Prose means the 24th; a finance sheet
|
|
146
|
+
listing `jan 24, feb 24, mar 24` means the year. `month_number` decides:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
parse("jan 24") # 24 January 2025 (default)
|
|
150
|
+
parse("jan 24", config=WranglerConfig(month_number=MonthNumber.YEAR))
|
|
151
|
+
# January 2024
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The setting only decides that ambiguous middle. Everything else settles itself:
|
|
155
|
+
|
|
156
|
+
| written | reads as | why |
|
|
157
|
+
|---|---|---|
|
|
158
|
+
| `march 3` | 3 March | a single digit is never a year |
|
|
159
|
+
| `jan 24th` | 24 January | ordinal suffix |
|
|
160
|
+
| `jan '24` | January 2024 | apostrophe |
|
|
161
|
+
| `jan 2024` | January 2024 | four digits |
|
|
162
|
+
| `jan 87` | January 1987 | above 31, so it cannot be a day |
|
|
163
|
+
| `january 15, 2024` | 15 January 2024 | the year is already stated |
|
|
164
|
+
|
|
165
|
+
## Parsing text
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
from date_wrangler import parse
|
|
169
|
+
|
|
170
|
+
for m in parse("revenue for Q1 FY25 vs Q1 FY24", today=date(2025, 9, 4)):
|
|
171
|
+
print(m.text, m.span, m.range.start, m.range.end)
|
|
172
|
+
# Q1 FY25 (12, 19) 2024-04-01 2024-07-01
|
|
173
|
+
# Q1 FY24 (23, 30) 2023-04-01 2023-07-01
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`parse()` returns `DateMatch` objects carrying the resolved range, the matched text and its
|
|
177
|
+
span in the string you passed — so you can highlight or rewrite without searching again.
|
|
178
|
+
|
|
179
|
+
A comparison stays **two** matches. Merging `compare Q1 2024 to Q1 2025` into one fifteen
|
|
180
|
+
month span is the kind of error that survives review because the number still looks
|
|
181
|
+
plausible.
|
|
182
|
+
|
|
183
|
+
### What it understands
|
|
184
|
+
|
|
185
|
+
| | |
|
|
186
|
+
|---|---|
|
|
187
|
+
| Fiscal periods | `Q1 FY25`, `Q1FY24`, `H1 FY25`, `1H 2024`, `FY2024-25`, `fy-24`, `F.Y. 2024` |
|
|
188
|
+
| Calendar periods | `CY2024`, `Q1 of 2024`, `January 2024`, `2024` |
|
|
189
|
+
| Fiscal month index | `third month of FY24`, `twelfth month` |
|
|
190
|
+
| Relative | `last 3 months`, `next 2 quarters`, `3 months ago`, `this week`, `yesterday` |
|
|
191
|
+
| Weekdays | `last Monday`, `next Friday`, `this Tuesday` |
|
|
192
|
+
| To-date | `YTD`, `MTD`, `QTD`, `last YTD` (the same window a year earlier) |
|
|
193
|
+
| Reporting shorthand | `TTM`, `LTM`, `T12M`, `L3M`, `trailing 12 months`, `rolling 3 months` |
|
|
194
|
+
| Period-ending | `quarter ending June 2024`, `year ended March 2024` |
|
|
195
|
+
| Absolute | `2024-03-15`, `15 January 2024`, `January 15, 2024`, `03/04/2024` |
|
|
196
|
+
| Ranges | `Q1 to Q2`, `Jan–Mar`, `from April to September 2024`, `Nov to Feb` (wraps) |
|
|
197
|
+
| Open-ended | `since March`, `from Q1 onwards`, `up to March 2024`, `before 2024`, `after FY24` |
|
|
198
|
+
| Point-in-time | `as of 31 March 2024` |
|
|
199
|
+
|
|
200
|
+
Connectors include `to`, `through`, `thru`, `until`, `till`, `upto`, `and`, and hyphen, en
|
|
201
|
+
dash or em dash — the last three matter because editors rewrite `-` as `–` on sight.
|
|
202
|
+
|
|
203
|
+
### Telling "nothing there" from "couldn't read it"
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
matches, diags = diagnose("5000 years ago", today=today)
|
|
207
|
+
# matches == []
|
|
208
|
+
# diags == [Diagnostic(text='5000 years ago', ..., reason='...outside the supported range')]
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`parse()` never raises on user input.
|
|
212
|
+
|
|
213
|
+
### Precision on running prose
|
|
214
|
+
|
|
215
|
+
Bare month names are the dominant false positive for this kind of library — `strictness`
|
|
216
|
+
controls how eagerly they are claimed:
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
parse("the march on Washington") # [] — no cue, so "march" is a noun
|
|
220
|
+
parse("sales in March") # matched — "in" is a cue
|
|
221
|
+
WranglerConfig(strictness="greedy") # match any month name anywhere
|
|
222
|
+
WranglerConfig(strictness="strict") # require a year or explicit period marker
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Rewriting text
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
substitute("sales report of Q1", today=today)
|
|
229
|
+
# 'sales report of from April 2025 to June 2025'
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Only the matched phrase is replaced.
|
|
233
|
+
|
|
234
|
+
**One limit worth knowing.** Substitution is textual, so an inserted phrase can fuse with a
|
|
235
|
+
neighbouring token that was never part of a date:
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
substitute("sales 15 Q1") # 'sales 15 April 2025 to June 2025'
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Read that back and `15 April 2025` is a perfectly good date, so a second pass gives a
|
|
242
|
+
different answer. Repeated substitution always *converges* — it never grows without bound,
|
|
243
|
+
which is the failure that matters — but it is not idempotent in one pass when a bare number
|
|
244
|
+
abuts a date expression. When exactness matters, use `parse()` and render the ranges
|
|
245
|
+
yourself; `substitute` is a convenience.
|
|
246
|
+
|
|
247
|
+
## Output format
|
|
248
|
+
|
|
249
|
+
Formatting is a separate, replaceable function, so the output format and structure are
|
|
250
|
+
entirely yours. Three levels, in increasing order of control:
|
|
251
|
+
|
|
252
|
+
**1. Don't format at all.** The dates are already objects — `m.range.start`, `m.range.end`,
|
|
253
|
+
`m.range.grain`. Most callers never need a string.
|
|
254
|
+
|
|
255
|
+
**2. `make_formatter()`** — build one from format strings:
|
|
256
|
+
|
|
257
|
+
```python
|
|
258
|
+
from date_wrangler import make_formatter
|
|
259
|
+
|
|
260
|
+
make_formatter()(r) # '2024-04-01 to 2024-06-30'
|
|
261
|
+
make_formatter(date_format="%d/%m/%Y", closed="{start} - {end}")(r)
|
|
262
|
+
# '01/04/2024 - 30/06/2024'
|
|
263
|
+
make_formatter(closed="BETWEEN '{start}' AND '{end}'")(r)
|
|
264
|
+
# "BETWEEN '2024-04-01' AND '2024-06-30'"
|
|
265
|
+
make_formatter(closed="[{start}, {end}]", inclusive_end=False)(r)
|
|
266
|
+
# '[2024-04-01, 2024-07-01]'
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
`date_format` is a `strftime` pattern; the templates are `str.format` patterns taking
|
|
270
|
+
`{start}` and `{end}`. Separate templates exist for each shape — `closed`, `single`,
|
|
271
|
+
`since`, `until`, `before`, `after`, `as_of`, `unbounded`.
|
|
272
|
+
|
|
273
|
+
`inclusive_end` decides which day `{end}` names. It defaults to `True`, so a human-facing
|
|
274
|
+
string says the last day *inside* the period (`2024-06-30`); set it `False` to emit the
|
|
275
|
+
exclusive bound (`2024-07-01`) for a machine.
|
|
276
|
+
|
|
277
|
+
**3. Any callable.** A formatter is just `DateRange -> str`:
|
|
278
|
+
|
|
279
|
+
```python
|
|
280
|
+
substitute(text, formatter=lambda r: f"<{r.start}..{r.end})")
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Built-ins: `format_range` (prose, locale-independent) and `format_iso` (`2024-04-01/2024-07-01`).
|
|
284
|
+
|
|
285
|
+
## Command line
|
|
286
|
+
|
|
287
|
+
```console
|
|
288
|
+
$ date-wrangler --today 2025-09-04 "revenue since Q1 FY25"
|
|
289
|
+
'since Q1 FY25'
|
|
290
|
+
from April 2024 onwards
|
|
291
|
+
2024-04-01/.. grain=quarter basis=fiscal mod=since confidence=1
|
|
292
|
+
SQL: d >= '2024-04-01'
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
`--json` for machine-readable output; `--fiscal-start`, `--basis`, `--date-order` and
|
|
296
|
+
`--strictness` to try configurations.
|
|
297
|
+
|
|
298
|
+
## Development
|
|
299
|
+
|
|
300
|
+
```bash
|
|
301
|
+
pip install -e ".[dev]"
|
|
302
|
+
pytest
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
## License
|
|
306
|
+
|
|
307
|
+
MIT © 2026 Steephan Selvaradjou — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# date-wrangler
|
|
2
|
+
|
|
3
|
+
Wrangles messy human dates into clean ranges: parses `last quarter`, `since March`,
|
|
4
|
+
`15 Jan 2024`.
|
|
5
|
+
|
|
6
|
+
Whatever someone types — an absolute date, a relative expression, an open-ended range, a
|
|
7
|
+
fiscal period — it comes back as one type: a half-open `DateRange` that is safe to hand
|
|
8
|
+
straight to a query.
|
|
9
|
+
|
|
10
|
+
> **Status: early development (0.1.0).** The API may still change before 1.0.
|
|
11
|
+
|
|
12
|
+
## Why another date library
|
|
13
|
+
|
|
14
|
+
Most date libraries answer "what instant is this?". `date-wrangler` answers **"what range is
|
|
15
|
+
this?"** — which is the question you actually have when a person types `last quarter`,
|
|
16
|
+
`since March`, or `Q1 FY25` into a search box.
|
|
17
|
+
|
|
18
|
+
Everything is a range: a single day is a `DAY`-grain range, a month is a `MONTH`-grain
|
|
19
|
+
range, a fiscal quarter is a `QUARTER`-grain range on the fiscal basis. That one decision
|
|
20
|
+
is what lets days, weeks, quarters, fiscal years and open-ended intervals share an API
|
|
21
|
+
instead of each needing their own.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install date-wrangler
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Python 3.10+. No runtime dependencies, except `tzdata` on Windows — which ships no system
|
|
30
|
+
timezone database, so the standard library needs it for the optional `tz=` argument.
|
|
31
|
+
|
|
32
|
+
## The core model
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from datetime import date
|
|
36
|
+
from date_wrangler import FiscalCalendar, Basis
|
|
37
|
+
from date_wrangler.calendars import quarter_range, month_range, current_fiscal_year
|
|
38
|
+
|
|
39
|
+
cal = FiscalCalendar.india() # fiscal year starts in April
|
|
40
|
+
fy = current_fiscal_year(date(2025, 9, 4), cal) # -> 2026
|
|
41
|
+
|
|
42
|
+
q1 = quarter_range(fy, 1, cal, Basis.FISCAL)
|
|
43
|
+
q1.start # date(2025, 4, 1)
|
|
44
|
+
q1.end # date(2025, 7, 1) <- exclusive
|
|
45
|
+
q1.end_inclusive # date(2025, 6, 30) <- for display
|
|
46
|
+
q1.grain # Grain.QUARTER
|
|
47
|
+
q1.sql("order_date")
|
|
48
|
+
# "order_date >= '2025-04-01' AND order_date < '2025-07-01'"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Ranges are half-open
|
|
52
|
+
|
|
53
|
+
`end` is the first day *outside* the range, never the last day inside it. This is the
|
|
54
|
+
Duckling convention and it exists to prevent a specific, common data bug:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
feb = month_range(2024, 2)
|
|
58
|
+
feb.end # date(2024, 3, 1)
|
|
59
|
+
feb.end_inclusive # date(2024, 2, 29)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`WHERE ts BETWEEN '2024-02-01' AND '2024-02-29'` silently drops every row after midnight
|
|
63
|
+
on the 29th when `ts` is a timestamp. `ts < '2024-03-01'` does not. Half-open ranges also
|
|
64
|
+
tile exactly — `Q1.end == Q2.start` — so they compose without off-by-one errors.
|
|
65
|
+
|
|
66
|
+
### Open-ended ranges
|
|
67
|
+
|
|
68
|
+
Either bound may be `None`, which is what makes `since March` and `before 2024`
|
|
69
|
+
expressible at all:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
r.start, r.end # date(2024, 3, 1), None
|
|
73
|
+
r.is_bounded # False
|
|
74
|
+
r.sql("d") # "d >= '2024-03-01'"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`end=None` means **unbounded** — never a silent "up to today". Deciding between those is
|
|
78
|
+
the caller's business, so you opt in explicitly:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
r.clamp(hi=date.today() + timedelta(days=1))
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Configuration
|
|
85
|
+
|
|
86
|
+
Config is passed per call and never read from a module global, so one process can serve
|
|
87
|
+
tenants on different fiscal calendars.
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from date_wrangler import WranglerConfig, FiscalCalendar, DateOrder, MonthNumber, YearLabel
|
|
91
|
+
|
|
92
|
+
WranglerConfig(
|
|
93
|
+
fiscal=FiscalCalendar.us_federal(), # October start
|
|
94
|
+
date_order=DateOrder.MDY, # how to read 03/04/2024
|
|
95
|
+
month_number=MonthNumber.YEAR, # what "jan 24" means
|
|
96
|
+
bare_period_basis=Basis.FISCAL, # what a bare "Q1" means
|
|
97
|
+
two_digit_pivot=68, # "99" -> 1999, not 2099
|
|
98
|
+
strictness="balanced",
|
|
99
|
+
)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Presets: `FiscalCalendar.india()`, `.uk()`, `.australia()`, `.us_federal()`, `.calendar()`.
|
|
103
|
+
|
|
104
|
+
Fiscal years follow the pandas `Q-MAR` convention by default — labelled by the year they
|
|
105
|
+
**end**, so with an April start FY2024 runs Apr 2023 – Mar 2024 and Apr–Jun is Q1. Set
|
|
106
|
+
`label_by=YearLabel.START_YEAR` for the US corporate convention.
|
|
107
|
+
|
|
108
|
+
Invalid configuration fails on construction with a message naming the field, not later
|
|
109
|
+
from inside `date()` on the first request that mentions a quarter.
|
|
110
|
+
|
|
111
|
+
### `jan 24` — day or year?
|
|
112
|
+
|
|
113
|
+
Genuinely ambiguous, and it depends who is writing. Prose means the 24th; a finance sheet
|
|
114
|
+
listing `jan 24, feb 24, mar 24` means the year. `month_number` decides:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
parse("jan 24") # 24 January 2025 (default)
|
|
118
|
+
parse("jan 24", config=WranglerConfig(month_number=MonthNumber.YEAR))
|
|
119
|
+
# January 2024
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The setting only decides that ambiguous middle. Everything else settles itself:
|
|
123
|
+
|
|
124
|
+
| written | reads as | why |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| `march 3` | 3 March | a single digit is never a year |
|
|
127
|
+
| `jan 24th` | 24 January | ordinal suffix |
|
|
128
|
+
| `jan '24` | January 2024 | apostrophe |
|
|
129
|
+
| `jan 2024` | January 2024 | four digits |
|
|
130
|
+
| `jan 87` | January 1987 | above 31, so it cannot be a day |
|
|
131
|
+
| `january 15, 2024` | 15 January 2024 | the year is already stated |
|
|
132
|
+
|
|
133
|
+
## Parsing text
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from date_wrangler import parse
|
|
137
|
+
|
|
138
|
+
for m in parse("revenue for Q1 FY25 vs Q1 FY24", today=date(2025, 9, 4)):
|
|
139
|
+
print(m.text, m.span, m.range.start, m.range.end)
|
|
140
|
+
# Q1 FY25 (12, 19) 2024-04-01 2024-07-01
|
|
141
|
+
# Q1 FY24 (23, 30) 2023-04-01 2023-07-01
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`parse()` returns `DateMatch` objects carrying the resolved range, the matched text and its
|
|
145
|
+
span in the string you passed — so you can highlight or rewrite without searching again.
|
|
146
|
+
|
|
147
|
+
A comparison stays **two** matches. Merging `compare Q1 2024 to Q1 2025` into one fifteen
|
|
148
|
+
month span is the kind of error that survives review because the number still looks
|
|
149
|
+
plausible.
|
|
150
|
+
|
|
151
|
+
### What it understands
|
|
152
|
+
|
|
153
|
+
| | |
|
|
154
|
+
|---|---|
|
|
155
|
+
| Fiscal periods | `Q1 FY25`, `Q1FY24`, `H1 FY25`, `1H 2024`, `FY2024-25`, `fy-24`, `F.Y. 2024` |
|
|
156
|
+
| Calendar periods | `CY2024`, `Q1 of 2024`, `January 2024`, `2024` |
|
|
157
|
+
| Fiscal month index | `third month of FY24`, `twelfth month` |
|
|
158
|
+
| Relative | `last 3 months`, `next 2 quarters`, `3 months ago`, `this week`, `yesterday` |
|
|
159
|
+
| Weekdays | `last Monday`, `next Friday`, `this Tuesday` |
|
|
160
|
+
| To-date | `YTD`, `MTD`, `QTD`, `last YTD` (the same window a year earlier) |
|
|
161
|
+
| Reporting shorthand | `TTM`, `LTM`, `T12M`, `L3M`, `trailing 12 months`, `rolling 3 months` |
|
|
162
|
+
| Period-ending | `quarter ending June 2024`, `year ended March 2024` |
|
|
163
|
+
| Absolute | `2024-03-15`, `15 January 2024`, `January 15, 2024`, `03/04/2024` |
|
|
164
|
+
| Ranges | `Q1 to Q2`, `Jan–Mar`, `from April to September 2024`, `Nov to Feb` (wraps) |
|
|
165
|
+
| Open-ended | `since March`, `from Q1 onwards`, `up to March 2024`, `before 2024`, `after FY24` |
|
|
166
|
+
| Point-in-time | `as of 31 March 2024` |
|
|
167
|
+
|
|
168
|
+
Connectors include `to`, `through`, `thru`, `until`, `till`, `upto`, `and`, and hyphen, en
|
|
169
|
+
dash or em dash — the last three matter because editors rewrite `-` as `–` on sight.
|
|
170
|
+
|
|
171
|
+
### Telling "nothing there" from "couldn't read it"
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
matches, diags = diagnose("5000 years ago", today=today)
|
|
175
|
+
# matches == []
|
|
176
|
+
# diags == [Diagnostic(text='5000 years ago', ..., reason='...outside the supported range')]
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`parse()` never raises on user input.
|
|
180
|
+
|
|
181
|
+
### Precision on running prose
|
|
182
|
+
|
|
183
|
+
Bare month names are the dominant false positive for this kind of library — `strictness`
|
|
184
|
+
controls how eagerly they are claimed:
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
parse("the march on Washington") # [] — no cue, so "march" is a noun
|
|
188
|
+
parse("sales in March") # matched — "in" is a cue
|
|
189
|
+
WranglerConfig(strictness="greedy") # match any month name anywhere
|
|
190
|
+
WranglerConfig(strictness="strict") # require a year or explicit period marker
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Rewriting text
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
substitute("sales report of Q1", today=today)
|
|
197
|
+
# 'sales report of from April 2025 to June 2025'
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Only the matched phrase is replaced.
|
|
201
|
+
|
|
202
|
+
**One limit worth knowing.** Substitution is textual, so an inserted phrase can fuse with a
|
|
203
|
+
neighbouring token that was never part of a date:
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
substitute("sales 15 Q1") # 'sales 15 April 2025 to June 2025'
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Read that back and `15 April 2025` is a perfectly good date, so a second pass gives a
|
|
210
|
+
different answer. Repeated substitution always *converges* — it never grows without bound,
|
|
211
|
+
which is the failure that matters — but it is not idempotent in one pass when a bare number
|
|
212
|
+
abuts a date expression. When exactness matters, use `parse()` and render the ranges
|
|
213
|
+
yourself; `substitute` is a convenience.
|
|
214
|
+
|
|
215
|
+
## Output format
|
|
216
|
+
|
|
217
|
+
Formatting is a separate, replaceable function, so the output format and structure are
|
|
218
|
+
entirely yours. Three levels, in increasing order of control:
|
|
219
|
+
|
|
220
|
+
**1. Don't format at all.** The dates are already objects — `m.range.start`, `m.range.end`,
|
|
221
|
+
`m.range.grain`. Most callers never need a string.
|
|
222
|
+
|
|
223
|
+
**2. `make_formatter()`** — build one from format strings:
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
from date_wrangler import make_formatter
|
|
227
|
+
|
|
228
|
+
make_formatter()(r) # '2024-04-01 to 2024-06-30'
|
|
229
|
+
make_formatter(date_format="%d/%m/%Y", closed="{start} - {end}")(r)
|
|
230
|
+
# '01/04/2024 - 30/06/2024'
|
|
231
|
+
make_formatter(closed="BETWEEN '{start}' AND '{end}'")(r)
|
|
232
|
+
# "BETWEEN '2024-04-01' AND '2024-06-30'"
|
|
233
|
+
make_formatter(closed="[{start}, {end}]", inclusive_end=False)(r)
|
|
234
|
+
# '[2024-04-01, 2024-07-01]'
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
`date_format` is a `strftime` pattern; the templates are `str.format` patterns taking
|
|
238
|
+
`{start}` and `{end}`. Separate templates exist for each shape — `closed`, `single`,
|
|
239
|
+
`since`, `until`, `before`, `after`, `as_of`, `unbounded`.
|
|
240
|
+
|
|
241
|
+
`inclusive_end` decides which day `{end}` names. It defaults to `True`, so a human-facing
|
|
242
|
+
string says the last day *inside* the period (`2024-06-30`); set it `False` to emit the
|
|
243
|
+
exclusive bound (`2024-07-01`) for a machine.
|
|
244
|
+
|
|
245
|
+
**3. Any callable.** A formatter is just `DateRange -> str`:
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
substitute(text, formatter=lambda r: f"<{r.start}..{r.end})")
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Built-ins: `format_range` (prose, locale-independent) and `format_iso` (`2024-04-01/2024-07-01`).
|
|
252
|
+
|
|
253
|
+
## Command line
|
|
254
|
+
|
|
255
|
+
```console
|
|
256
|
+
$ date-wrangler --today 2025-09-04 "revenue since Q1 FY25"
|
|
257
|
+
'since Q1 FY25'
|
|
258
|
+
from April 2024 onwards
|
|
259
|
+
2024-04-01/.. grain=quarter basis=fiscal mod=since confidence=1
|
|
260
|
+
SQL: d >= '2024-04-01'
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
`--json` for machine-readable output; `--fiscal-start`, `--basis`, `--date-order` and
|
|
264
|
+
`--strictness` to try configurations.
|
|
265
|
+
|
|
266
|
+
## Development
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
pip install -e ".[dev]"
|
|
270
|
+
pytest
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## License
|
|
274
|
+
|
|
275
|
+
MIT © 2026 Steephan Selvaradjou — see [LICENSE](LICENSE).
|