klok-cli 1.0.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.
klok/timeparse.py ADDED
@@ -0,0 +1,417 @@
1
+ """Parsing and formatting of the times, durations and ranges klok accepts.
2
+
3
+ The goal is that anything a human would plausibly type at a prompt works:
4
+ ``9am``, ``-15min``, ``yesterday``, ``last monday``, ``:lastweek``,
5
+ ``2026-08-31 09:00 to 11:30``.
6
+ """
7
+
8
+ import re
9
+ from datetime import date, datetime, timedelta
10
+
11
+ WEEKDAYS = {
12
+ "monday": 0, "mon": 0,
13
+ "tuesday": 1, "tue": 1, "tues": 1,
14
+ "wednesday": 2, "wed": 2,
15
+ "thursday": 3, "thu": 3, "thur": 3, "thurs": 3,
16
+ "friday": 4, "fri": 4,
17
+ "saturday": 5, "sat": 5,
18
+ "sunday": 6, "sun": 6,
19
+ }
20
+
21
+ _UNITS = {
22
+ "s": "seconds", "sec": "seconds", "secs": "seconds",
23
+ "second": "seconds", "seconds": "seconds",
24
+ "m": "minutes", "min": "minutes", "mins": "minutes",
25
+ "minute": "minutes", "minutes": "minutes",
26
+ "h": "hours", "hr": "hours", "hrs": "hours",
27
+ "hour": "hours", "hours": "hours",
28
+ "d": "days", "day": "days", "days": "days",
29
+ "w": "weeks", "week": "weeks", "weeks": "weeks",
30
+ }
31
+
32
+ _DURATION_RE = re.compile(r"(\d+(?:\.\d+)?)\s*([a-z]+)")
33
+ _CLOCK_RE = re.compile(r"^(\d{1,2}):(\d{2})(?::(\d{2}))?$")
34
+ _AMPM_RE = re.compile(r"^(\d{1,2})(?::(\d{2}))?(?::(\d{2}))?\s*([ap])\.?m\.?$", re.I)
35
+ _DATE_RE = re.compile(r"^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$")
36
+ _SHORTDATE_RE = re.compile(r"^(\d{1,2})[-/](\d{1,2})$")
37
+ _REL_RE = re.compile(r"^([+-])\s*(.+)$")
38
+ _AGO_RE = re.compile(r"^(.*?)\s+ago$", re.I)
39
+ _LAST_N_RE = re.compile(r"^last\s+(\d+)\s+(day|days|week|weeks|month|months|year|years)$", re.I)
40
+
41
+
42
+ class TimeParseError(ValueError):
43
+ """Raised when user input cannot be understood as a time or duration."""
44
+
45
+
46
+ def local_tz():
47
+ return datetime.now().astimezone().tzinfo
48
+
49
+
50
+ def now_local() -> datetime:
51
+ return datetime.now().astimezone().replace(microsecond=0)
52
+
53
+
54
+ def to_iso(value: datetime) -> str:
55
+ return value.replace(microsecond=0).isoformat()
56
+
57
+
58
+ def from_iso(text: str) -> datetime:
59
+ """Parse an ISO timestamp, assuming local time when no offset is given."""
60
+ if isinstance(text, datetime):
61
+ return text if text.tzinfo else text.replace(tzinfo=local_tz())
62
+ raw = str(text).strip().replace("Z", "+00:00")
63
+ value = datetime.fromisoformat(raw)
64
+ if value.tzinfo is None:
65
+ value = value.replace(tzinfo=local_tz())
66
+ return value.replace(microsecond=0)
67
+
68
+
69
+ # ---------------------------------------------------------------- durations
70
+ def parse_duration(text) -> timedelta:
71
+ """``90`` (minutes), ``1h30m``, ``1:30``, ``45s``, ``2 hours``."""
72
+ if isinstance(text, timedelta):
73
+ return text
74
+ raw = str(text).strip().lower()
75
+ if not raw:
76
+ raise TimeParseError("empty duration")
77
+ negative = raw.startswith("-")
78
+ raw = raw.lstrip("+-").strip()
79
+
80
+ clock = _CLOCK_RE.match(raw)
81
+ if clock:
82
+ hours, minutes, seconds = clock.groups()
83
+ delta = timedelta(hours=int(hours), minutes=int(minutes), seconds=int(seconds or 0))
84
+ return -delta if negative else delta
85
+
86
+ if re.fullmatch(r"\d+(\.\d+)?", raw):
87
+ delta = timedelta(minutes=float(raw))
88
+ return -delta if negative else delta
89
+
90
+ matches = _DURATION_RE.findall(raw)
91
+ if not matches or _DURATION_RE.sub("", raw).strip(" ,and"):
92
+ raise TimeParseError("cannot parse duration: %r" % text)
93
+ kwargs = {}
94
+ for amount, unit in matches:
95
+ key = _UNITS.get(unit)
96
+ if key is None:
97
+ raise TimeParseError("unknown time unit %r in %r" % (unit, text))
98
+ kwargs[key] = kwargs.get(key, 0) + float(amount)
99
+ delta = timedelta(**kwargs)
100
+ return -delta if negative else delta
101
+
102
+
103
+ def format_duration(delta: timedelta, style: str = "short") -> str:
104
+ """``short`` -> ``1h 30m``; ``clock`` -> ``01:30:00``."""
105
+ total = int(round(delta.total_seconds()))
106
+ sign = "-" if total < 0 else ""
107
+ total = abs(total)
108
+ hours, rest = divmod(total, 3600)
109
+ minutes, seconds = divmod(rest, 60)
110
+ if style == "clock":
111
+ return "%s%02d:%02d:%02d" % (sign, hours, minutes, seconds)
112
+ if style == "hours":
113
+ return "%s%.2f" % (sign, total / 3600.0)
114
+ if hours:
115
+ return "%s%dh %02dm" % (sign, hours, minutes)
116
+ if minutes:
117
+ return "%s%dm" % (sign, minutes) if not seconds else "%s%dm %02ds" % (sign, minutes, seconds)
118
+ return "%s%ds" % (sign, seconds)
119
+
120
+
121
+ def round_duration(delta: timedelta, minutes: int) -> timedelta:
122
+ """Round up to the next multiple of ``minutes`` (0 disables rounding)."""
123
+ if minutes <= 0:
124
+ return delta
125
+ step = minutes * 60
126
+ seconds = delta.total_seconds()
127
+ if seconds <= 0:
128
+ return timedelta(0)
129
+ return timedelta(seconds=step * -(-int(seconds) // step))
130
+
131
+
132
+ # ---------------------------------------------------------------- datetimes
133
+ def _at(reference: datetime, hour: int, minute: int = 0, second: int = 0) -> datetime:
134
+ return reference.replace(hour=hour, minute=minute, second=second, microsecond=0)
135
+
136
+
137
+ def _weekday_before(reference: datetime, weekday: int, weeks_back: int = 0) -> datetime:
138
+ midnight = _at(reference, 0)
139
+ delta = (midnight.weekday() - weekday) % 7
140
+ if delta == 0:
141
+ delta = 0 if weeks_back == 0 else 0
142
+ return midnight - timedelta(days=delta + 7 * weeks_back)
143
+
144
+
145
+ def parse_datetime_ex(text, now: datetime = None):
146
+ """Return ``(datetime, granularity)`` where granularity is ``day`` or ``instant``."""
147
+ now = now or now_local()
148
+ if isinstance(text, datetime):
149
+ return (text if text.tzinfo else text.replace(tzinfo=now.tzinfo)), "instant"
150
+ raw = " ".join(str(text).strip().split())
151
+ if not raw:
152
+ raise TimeParseError("empty time")
153
+ low = raw.lower()
154
+
155
+ if low in ("now",):
156
+ return now, "instant"
157
+ if low in ("today", "tod"):
158
+ return _at(now, 0), "day"
159
+ if low in ("yesterday", "yest", "yd"):
160
+ return _at(now, 0) - timedelta(days=1), "day"
161
+ if low == "tomorrow":
162
+ return _at(now, 0) + timedelta(days=1), "day"
163
+ if low == "noon":
164
+ return _at(now, 12), "instant"
165
+ if low == "midnight":
166
+ return _at(now, 0), "instant"
167
+
168
+ ago = _AGO_RE.match(low)
169
+ if ago:
170
+ return now - parse_duration(ago.group(1)), "instant"
171
+
172
+ rel = _REL_RE.match(low)
173
+ if rel and not _DATE_RE.match(low):
174
+ delta = parse_duration(rel.group(2))
175
+ return (now - delta if rel.group(1) == "-" else now + delta), "instant"
176
+
177
+ words = low.split()
178
+ if words[0] in ("last", "this") and len(words) == 2 and words[1] in WEEKDAYS:
179
+ return _weekday_before(now, WEEKDAYS[words[1]], 1 if words[0] == "last" else 0), "day"
180
+ if low in WEEKDAYS:
181
+ return _weekday_before(now, WEEKDAYS[low]), "day"
182
+
183
+ # A date and a time, in either "T" or space separated form.
184
+ if len(words) == 2 or "t" in low:
185
+ for splitter in (" ", "T", "t"):
186
+ if splitter == " " and len(words) != 2:
187
+ continue
188
+ head, sep, tail = raw.partition(splitter)
189
+ if not sep or not tail:
190
+ continue
191
+ try:
192
+ day, _ = parse_datetime_ex(head, now)
193
+ clock, _ = _parse_clock(tail, day)
194
+ except TimeParseError:
195
+ continue
196
+ return clock, "instant"
197
+
198
+ clock = _parse_clock_or_none(raw, now)
199
+ if clock is not None:
200
+ return clock, "instant"
201
+
202
+ match = _DATE_RE.match(low)
203
+ if match:
204
+ year, month, day = (int(part) for part in match.groups())
205
+ return now.replace(year=year, month=month, day=day, hour=0, minute=0, second=0, microsecond=0), "day"
206
+
207
+ match = _SHORTDATE_RE.match(low)
208
+ if match:
209
+ month, day = (int(part) for part in match.groups())
210
+ return now.replace(month=month, day=day, hour=0, minute=0, second=0, microsecond=0), "day"
211
+
212
+ if re.fullmatch(r"\d{10}", low):
213
+ return datetime.fromtimestamp(int(low), now.tzinfo), "instant"
214
+
215
+ try:
216
+ return from_iso(raw), "instant"
217
+ except ValueError:
218
+ raise TimeParseError("cannot parse time: %r" % text)
219
+
220
+
221
+ def _parse_clock(text: str, reference: datetime):
222
+ result = _parse_clock_or_none(text, reference)
223
+ if result is None:
224
+ raise TimeParseError("cannot parse time of day: %r" % text)
225
+ return result, "instant"
226
+
227
+
228
+ def _parse_clock_or_none(text: str, reference: datetime):
229
+ raw = text.strip()
230
+ match = _AMPM_RE.match(raw)
231
+ if match:
232
+ hour = int(match.group(1)) % 12
233
+ if match.group(4).lower() == "p":
234
+ hour += 12
235
+ return _at(reference, hour, int(match.group(2) or 0), int(match.group(3) or 0))
236
+ match = _CLOCK_RE.match(raw)
237
+ if match:
238
+ hour, minute, second = match.groups()
239
+ if int(hour) > 23:
240
+ return None
241
+ return _at(reference, int(hour), int(minute), int(second or 0))
242
+ if re.fullmatch(r"\d{1,2}h", raw, re.I):
243
+ return _at(reference, int(raw[:-1]))
244
+ return None
245
+
246
+
247
+ def parse_datetime(text, now: datetime = None) -> datetime:
248
+ return parse_datetime_ex(text, now)[0]
249
+
250
+
251
+ # ------------------------------------------------------------------- ranges
252
+ def day_bounds(reference: datetime):
253
+ start = _at(reference, 0)
254
+ return start, start + timedelta(days=1)
255
+
256
+
257
+ def week_bounds(reference: datetime, week_start: str = "monday"):
258
+ midnight = _at(reference, 0)
259
+ offset = midnight.weekday() if week_start.lower() == "monday" else (midnight.weekday() + 1) % 7
260
+ start = midnight - timedelta(days=offset)
261
+ return start, start + timedelta(days=7)
262
+
263
+
264
+ def month_bounds(reference: datetime):
265
+ start = _at(reference, 0).replace(day=1)
266
+ end = (start + timedelta(days=32)).replace(day=1)
267
+ return start, end
268
+
269
+
270
+ def quarter_bounds(reference: datetime):
271
+ first_month = 3 * ((reference.month - 1) // 3) + 1
272
+ start = _at(reference, 0).replace(month=first_month, day=1)
273
+ end = start
274
+ for _ in range(3):
275
+ end = (end + timedelta(days=32)).replace(day=1)
276
+ return start, end
277
+
278
+
279
+ def year_bounds(reference: datetime):
280
+ start = _at(reference, 0).replace(month=1, day=1)
281
+ return start, start.replace(year=start.year + 1)
282
+
283
+
284
+ def _shift_months(value: datetime, months: int) -> datetime:
285
+ month_index = value.month - 1 + months
286
+ year = value.year + month_index // 12
287
+ month = month_index % 12 + 1
288
+ day = min(value.day, [31, 29 if year % 4 == 0 and (year % 100 or year % 400 == 0) else 28,
289
+ 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1])
290
+ return value.replace(year=year, month=month, day=day)
291
+
292
+
293
+ EPOCH_FALLBACK = datetime(1970, 1, 1)
294
+
295
+
296
+ def parse_range(tokens, now: datetime = None, week_start: str = "monday", default=None):
297
+ """Turn range words into a half-open ``(start, end)`` pair.
298
+
299
+ Accepts Timewarrior-style hints (``:week``), plain dates, ``A to B``
300
+ spans, ``since monday`` and ``last 7 days``.
301
+ """
302
+ now = now or now_local()
303
+ if isinstance(tokens, str):
304
+ tokens = tokens.split()
305
+ tokens = [str(token) for token in (tokens or []) if str(token).strip()]
306
+ if not tokens:
307
+ if default == "all" or default is None:
308
+ return EPOCH_FALLBACK.replace(tzinfo=now.tzinfo), now + timedelta(days=1)
309
+ return parse_range([default], now, week_start)
310
+
311
+ joined = " ".join(tokens).strip()
312
+ low = joined.lower()
313
+
314
+ hint = low.lstrip(":")
315
+ if low.startswith(":") or hint in ("all",):
316
+ return _hint_range(hint, now, week_start)
317
+
318
+ match = _LAST_N_RE.match(low)
319
+ if match:
320
+ count, unit = int(match.group(1)), match.group(2).rstrip("s")
321
+ end = _at(now, 0) + timedelta(days=1)
322
+ if unit == "day":
323
+ return end - timedelta(days=count), end
324
+ if unit == "week":
325
+ return end - timedelta(weeks=count), end
326
+ if unit == "month":
327
+ return _shift_months(end, -count), end
328
+ return _shift_months(end, -12 * count), end
329
+
330
+ if low.startswith("since "):
331
+ start, _ = parse_datetime_ex(joined[6:], now)
332
+ return start, now + timedelta(seconds=1)
333
+
334
+ for separator in (" to ", " - ", "..", " until "):
335
+ if separator in low:
336
+ index = low.index(separator)
337
+ head = joined[:index].strip()
338
+ tail = joined[index + len(separator):].strip()
339
+ start, _ = parse_datetime_ex(head, now)
340
+ end, end_kind = parse_datetime_ex(tail, now)
341
+ if end_kind == "day":
342
+ end += timedelta(days=1)
343
+ return start, end
344
+
345
+ value, kind = parse_datetime_ex(joined, now)
346
+ if kind == "day":
347
+ return value, value + timedelta(days=1)
348
+ return value, now + timedelta(seconds=1)
349
+
350
+
351
+ def _hint_range(hint: str, now: datetime, week_start: str):
352
+ hint = hint.lower()
353
+ if hint in ("day", "today", "tod"):
354
+ return day_bounds(now)
355
+ if hint in ("yesterday", "yest"):
356
+ return day_bounds(now - timedelta(days=1))
357
+ if hint == "week":
358
+ return week_bounds(now, week_start)
359
+ if hint in ("lastweek", "last-week"):
360
+ start, end = week_bounds(now - timedelta(days=7), week_start)
361
+ return start, end
362
+ if hint == "month":
363
+ return month_bounds(now)
364
+ if hint in ("lastmonth", "last-month"):
365
+ return month_bounds(_shift_months(_at(now, 0).replace(day=1), -1))
366
+ if hint == "quarter":
367
+ return quarter_bounds(now)
368
+ if hint == "year":
369
+ return year_bounds(now)
370
+ if hint in ("lastyear", "last-year"):
371
+ return year_bounds(now.replace(year=now.year - 1))
372
+ if hint == "all":
373
+ return EPOCH_FALLBACK.replace(tzinfo=now.tzinfo), now + timedelta(days=1)
374
+ match = re.fullmatch(r"(\d+)d(ays)?", hint)
375
+ if match:
376
+ end = _at(now, 0) + timedelta(days=1)
377
+ return end - timedelta(days=int(match.group(1))), end
378
+ if hint in WEEKDAYS:
379
+ start = _weekday_before(now, WEEKDAYS[hint])
380
+ return start, start + timedelta(days=1)
381
+ raise TimeParseError("unknown range hint: :%s" % hint)
382
+
383
+
384
+ def parse_hours_window(text: str):
385
+ """``09:00-18:00`` -> ``(time, time)``; empty input -> ``None``."""
386
+ if not text or "-" not in text:
387
+ return None
388
+ head, _, tail = text.partition("-")
389
+ reference = now_local()
390
+ start = _parse_clock_or_none(head.strip(), reference)
391
+ end = _parse_clock_or_none(tail.strip(), reference)
392
+ if start is None or end is None:
393
+ return None
394
+ return start.time(), end.time()
395
+
396
+
397
+ def format_time(value: datetime, time_format: str = "24") -> str:
398
+ return value.strftime("%I:%M %p" if str(time_format) == "12" else "%H:%M")
399
+
400
+
401
+ def format_datetime(value: datetime, time_format: str = "24") -> str:
402
+ return "%s %s" % (value.strftime("%Y-%m-%d"), format_time(value, time_format))
403
+
404
+
405
+ def humanize_ago(delta: timedelta) -> str:
406
+ seconds = int(delta.total_seconds())
407
+ if seconds < 60:
408
+ return "just now" if seconds < 5 else "%d seconds ago" % seconds
409
+ if seconds < 3600:
410
+ return "%d minute%s ago" % (seconds // 60, "" if seconds // 60 == 1 else "s")
411
+ if seconds < 86400:
412
+ return "%d hour%s ago" % (seconds // 3600, "" if seconds // 3600 == 1 else "s")
413
+ return "%d day%s ago" % (seconds // 86400, "" if seconds // 86400 == 1 else "s")
414
+
415
+
416
+ def date_key(value: datetime) -> date:
417
+ return value.date()
klok/utils.py ADDED
@@ -0,0 +1,115 @@
1
+ """Terminal helpers: colour, tables and bars."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ RESET = "\033[0m"
7
+ CODES = {
8
+ "bold": "\033[1m",
9
+ "dim": "\033[2m",
10
+ "red": "\033[31m",
11
+ "green": "\033[32m",
12
+ "yellow": "\033[33m",
13
+ "blue": "\033[34m",
14
+ "magenta": "\033[35m",
15
+ "cyan": "\033[36m",
16
+ "white": "\033[37m",
17
+ "grey": "\033[90m",
18
+ }
19
+ PALETTE = ["cyan", "green", "yellow", "magenta", "blue", "red", "white"]
20
+
21
+
22
+ class Term:
23
+ def __init__(self, mode: str = "auto", stream=None):
24
+ self.stream = stream or sys.stdout
25
+ if mode == "always":
26
+ self.enabled = True
27
+ elif mode == "never" or os.environ.get("NO_COLOR"):
28
+ self.enabled = False
29
+ else:
30
+ self.enabled = bool(getattr(self.stream, "isatty", lambda: False)())
31
+
32
+ def paint(self, text: str, *styles) -> str:
33
+ if not self.enabled or not styles:
34
+ return text
35
+ prefix = "".join(CODES.get(style, "") for style in styles)
36
+ return "%s%s%s" % (prefix, text, RESET) if prefix else text
37
+
38
+ def project_color(self, project: str) -> str:
39
+ if not project:
40
+ return "grey"
41
+ return PALETTE[sum(ord(char) for char in project) % len(PALETTE)]
42
+
43
+ def width(self, default: int = 80) -> int:
44
+ try:
45
+ return max(40, os.get_terminal_size().columns)
46
+ except OSError:
47
+ return default
48
+
49
+
50
+ def visible_length(text: str) -> int:
51
+ """Length of ``text`` ignoring ANSI escapes."""
52
+ length, index = 0, 0
53
+ while index < len(text):
54
+ if text[index] == "\033":
55
+ while index < len(text) and text[index] != "m":
56
+ index += 1
57
+ index += 1
58
+ continue
59
+ length += 1
60
+ index += 1
61
+ return length
62
+
63
+
64
+ def pad(text: str, width: int, align: str = "left") -> str:
65
+ filler = " " * max(0, width - visible_length(text))
66
+ if align == "right":
67
+ return filler + text
68
+ if align == "center":
69
+ half = len(filler) // 2
70
+ return " " * half + text + " " * (len(filler) - half)
71
+ return text + filler
72
+
73
+
74
+ def render_table(rows, headers=None, aligns=None, term: Term = None, indent: str = "") -> str:
75
+ """Render an aligned, borderless table (the shape most CLIs settle on)."""
76
+ rows = [[("" if cell is None else str(cell)) for cell in row] for row in rows]
77
+ if not rows and not headers:
78
+ return ""
79
+ columns = max([len(row) for row in rows] + [len(headers or [])])
80
+ for row in rows:
81
+ row.extend([""] * (columns - len(row)))
82
+ aligns = list(aligns or []) + ["left"] * (columns - len(aligns or []))
83
+ widths = [0] * columns
84
+ if headers:
85
+ headers = list(headers) + [""] * (columns - len(headers))
86
+ for index, header in enumerate(headers):
87
+ widths[index] = visible_length(str(header))
88
+ for row in rows:
89
+ for index, cell in enumerate(row):
90
+ widths[index] = max(widths[index], visible_length(cell))
91
+
92
+ lines = []
93
+ if headers:
94
+ cells = [pad(str(header), widths[index], aligns[index]) for index, header in enumerate(headers)]
95
+ line = indent + " ".join(cells).rstrip()
96
+ lines.append(term.paint(line, "bold") if term else line)
97
+ for row in rows:
98
+ cells = [pad(cell, widths[index], aligns[index]) for index, cell in enumerate(row)]
99
+ lines.append(indent + " ".join(cells).rstrip())
100
+ return "\n".join(lines)
101
+
102
+
103
+ def bar(value: float, maximum: float, width: int = 30, filled: str = "█", empty: str = " ") -> str:
104
+ if maximum <= 0:
105
+ return empty * width
106
+ count = int(round(width * (value / maximum)))
107
+ count = max(0, min(width, count))
108
+ if value > 0 and count == 0:
109
+ count = 1
110
+ return filled * count + empty * (width - count)
111
+
112
+
113
+ def die(message: str, code: int = 1):
114
+ print("klok: %s" % message, file=sys.stderr)
115
+ return code