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.
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
13
+ .hypothesis/
@@ -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).