hank-format 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hank_format/__init__.py +42 -0
- hank_format/_datetime.py +329 -0
- hank_format/_name.py +80 -0
- hank_format/py.typed +0 -0
- hank_format-0.1.0.dist-info/METADATA +107 -0
- hank_format-0.1.0.dist-info/RECORD +8 -0
- hank_format-0.1.0.dist-info/WHEEL +4 -0
- hank_format-0.1.0.dist-info/licenses/LICENSE +21 -0
hank_format/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Shared string formatting functions for standardizing values across the Hank AI stack.
|
|
2
|
+
|
|
3
|
+
Example:
|
|
4
|
+
>>> from hank_format import as_name, as_date_str, as_datetime_str, as_time_str
|
|
5
|
+
>>> as_name("Primary: John Doe")
|
|
6
|
+
'DOE, JOHN'
|
|
7
|
+
>>> as_date_str("12/31/2020 (age 30 yrs)")
|
|
8
|
+
'2020-12-31'
|
|
9
|
+
>>> as_datetime_str("2020-12-31T23:59", precision="minute")
|
|
10
|
+
'2020-12-31 23:59'
|
|
11
|
+
>>> as_time_str("11:30 pm")
|
|
12
|
+
'23:30'
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
from ._datetime import (
|
|
18
|
+
Precision,
|
|
19
|
+
as_date,
|
|
20
|
+
as_date_str,
|
|
21
|
+
as_datetime,
|
|
22
|
+
as_datetime_str,
|
|
23
|
+
as_time,
|
|
24
|
+
as_time_str,
|
|
25
|
+
has_time,
|
|
26
|
+
is_date,
|
|
27
|
+
)
|
|
28
|
+
from ._name import as_name
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"Precision",
|
|
32
|
+
"__version__",
|
|
33
|
+
"as_date",
|
|
34
|
+
"as_date_str",
|
|
35
|
+
"as_datetime",
|
|
36
|
+
"as_datetime_str",
|
|
37
|
+
"as_name",
|
|
38
|
+
"as_time",
|
|
39
|
+
"as_time_str",
|
|
40
|
+
"has_time",
|
|
41
|
+
"is_date",
|
|
42
|
+
]
|
hank_format/_datetime.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Date, time, and datetime parsing and formatting.
|
|
2
|
+
|
|
3
|
+
Public functions accept loosely formatted strings (anything ``dateutil``
|
|
4
|
+
understands plus a few medical-record idioms), ``date``, ``datetime``, and
|
|
5
|
+
``time`` objects, and ``None``. Object-returning functions return ``None`` when
|
|
6
|
+
no usable value exists; string-returning functions return ``""``. Returned
|
|
7
|
+
values never carry timezone info or microseconds.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import re
|
|
14
|
+
from datetime import date, datetime, time
|
|
15
|
+
from typing import Literal, TypeAlias
|
|
16
|
+
|
|
17
|
+
from dateutil import parser
|
|
18
|
+
from dateutil.relativedelta import relativedelta
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
Precision: TypeAlias = Literal["minute", "second"]
|
|
23
|
+
"""Smallest unit kept in a formatted time string. Values are truncated, never rounded."""
|
|
24
|
+
|
|
25
|
+
_SHORT_DATETIME_REGEX = re.compile(
|
|
26
|
+
r"^(?P<month>\d?\d)(?P<sep>/|-)(?P<day>\d?\d)(?P<time> \d?\d:?\d\d.*)$"
|
|
27
|
+
)
|
|
28
|
+
_TIMESPEC: dict[str, str] = {"minute": "minutes", "second": "seconds"}
|
|
29
|
+
_FUTURE_WINDOW = relativedelta(days=2)
|
|
30
|
+
_CENTURY = relativedelta(years=100)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _now() -> datetime:
|
|
34
|
+
"""Current local time. Tests patch this to freeze the clock."""
|
|
35
|
+
return datetime.now()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _today() -> date:
|
|
39
|
+
return _now().date()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _timespec(precision: str) -> str:
|
|
43
|
+
try:
|
|
44
|
+
return _TIMESPEC[precision]
|
|
45
|
+
except KeyError:
|
|
46
|
+
raise ValueError(f"precision must be 'minute' or 'second', not {precision!r}") from None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _normalize(text: str) -> str:
|
|
50
|
+
"""Rewrite strings ``dateutil`` cannot parse into an unambiguous form.
|
|
51
|
+
|
|
52
|
+
Drops a trailing parenthetical, rewrites bare ``MMDDYYYY`` to
|
|
53
|
+
``MM-DD-YYYY`` (``dateutil`` reads 8 digits as ``YYYYMMDD``), and appends
|
|
54
|
+
the current year to a year-less ``MM/DD HH:MM`` (previous year when the
|
|
55
|
+
month/day would otherwise be in the future).
|
|
56
|
+
|
|
57
|
+
Example:
|
|
58
|
+
>>> _normalize("12/31/2020 (age 30 yrs)")
|
|
59
|
+
'12/31/2020'
|
|
60
|
+
>>> _normalize("01012024")
|
|
61
|
+
'01-01-2024'
|
|
62
|
+
>>> _normalize("20240101")
|
|
63
|
+
'20240101'
|
|
64
|
+
>>> _normalize("13012024")
|
|
65
|
+
'13012024'
|
|
66
|
+
"""
|
|
67
|
+
text = text.partition("(")[0].strip()
|
|
68
|
+
if (
|
|
69
|
+
len(text) == 8
|
|
70
|
+
and text.isdecimal()
|
|
71
|
+
and 1 <= int(text[:2]) <= 12
|
|
72
|
+
and int(text[4:6]) in (19, 20)
|
|
73
|
+
):
|
|
74
|
+
return f"{text[:2]}-{text[2:4]}-{text[4:]}"
|
|
75
|
+
|
|
76
|
+
def _append_year(match: re.Match[str]) -> str:
|
|
77
|
+
today = _today()
|
|
78
|
+
month, day = int(match["month"]), int(match["day"])
|
|
79
|
+
year = today.year - int((month, day) > (today.month, today.day))
|
|
80
|
+
return f"{month}{match['sep']}{day}{match['sep']}{year}{match['time']}"
|
|
81
|
+
|
|
82
|
+
return _SHORT_DATETIME_REGEX.sub(_append_year, text, 1)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parse(text: str, default: datetime) -> datetime | None:
|
|
86
|
+
"""``dateutil.parser.parse`` that ignores timezones and returns ``None`` on failure."""
|
|
87
|
+
try:
|
|
88
|
+
return parser.parse(text, ignoretz=True, default=default)
|
|
89
|
+
except (ValueError, OverflowError, TypeError) as err:
|
|
90
|
+
logger.debug("Could not parse %r: %s", text, err)
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _parses_consistently(
|
|
95
|
+
text: str, lower: datetime, upper: datetime, fields: tuple[str, ...]
|
|
96
|
+
) -> bool:
|
|
97
|
+
"""True if parsing ``text`` with two different defaults agrees on ``fields``.
|
|
98
|
+
|
|
99
|
+
A field that ``dateutil`` filled from the default differs between the two
|
|
100
|
+
parses, so agreement means the field was present in ``text``.
|
|
101
|
+
"""
|
|
102
|
+
first, second = _parse(text, lower), _parse(text, upper)
|
|
103
|
+
if first is None or second is None:
|
|
104
|
+
return False
|
|
105
|
+
return all(getattr(first, f) == getattr(second, f) for f in fields)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def is_date(value: str | date | datetime | None) -> bool:
|
|
109
|
+
"""Return ``True`` if ``value`` carries year, month, and day information.
|
|
110
|
+
|
|
111
|
+
Example:
|
|
112
|
+
>>> is_date("12/31/2020")
|
|
113
|
+
True
|
|
114
|
+
>>> is_date("12/31/2020 (age 30 yrs)")
|
|
115
|
+
True
|
|
116
|
+
>>> is_date("23:59")
|
|
117
|
+
False
|
|
118
|
+
>>> is_date("not a date")
|
|
119
|
+
False
|
|
120
|
+
>>> is_date(None)
|
|
121
|
+
False
|
|
122
|
+
"""
|
|
123
|
+
if value is None:
|
|
124
|
+
return False
|
|
125
|
+
if isinstance(value, date):
|
|
126
|
+
return True
|
|
127
|
+
if not isinstance(value, str):
|
|
128
|
+
raise TypeError(f"Expected str, date, datetime, or None, not {type(value).__name__}")
|
|
129
|
+
text = _normalize(value)
|
|
130
|
+
if len(text) < 7:
|
|
131
|
+
year = _today().year
|
|
132
|
+
lower, upper = datetime(year, 1, 1), datetime(year, 12, 31)
|
|
133
|
+
else:
|
|
134
|
+
lower, upper = datetime.min, datetime.max
|
|
135
|
+
return _parses_consistently(text, lower, upper, ("year", "month", "day"))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def has_time(value: str | date | datetime | time | None) -> bool:
|
|
139
|
+
"""Return ``True`` if ``value`` carries hour and minute information.
|
|
140
|
+
|
|
141
|
+
Example:
|
|
142
|
+
>>> has_time("2020-12-31T23:59")
|
|
143
|
+
True
|
|
144
|
+
>>> has_time("23:59")
|
|
145
|
+
True
|
|
146
|
+
>>> has_time("2020-12-31")
|
|
147
|
+
False
|
|
148
|
+
>>> has_time(None)
|
|
149
|
+
False
|
|
150
|
+
"""
|
|
151
|
+
if value is None:
|
|
152
|
+
return False
|
|
153
|
+
if isinstance(value, (datetime, time)):
|
|
154
|
+
return True
|
|
155
|
+
if isinstance(value, date):
|
|
156
|
+
return False
|
|
157
|
+
if not isinstance(value, str):
|
|
158
|
+
raise TypeError(f"Expected str, date, datetime, time, or None, not {type(value).__name__}")
|
|
159
|
+
text = _normalize(value)
|
|
160
|
+
date_only = as_date_str(text)
|
|
161
|
+
if date_only and date_only == text:
|
|
162
|
+
return False
|
|
163
|
+
if ("T" not in text and " " not in text) or (not date_only and len(text) < 11):
|
|
164
|
+
text = f"1/1/1999 {text}"
|
|
165
|
+
year = _today().year
|
|
166
|
+
lower, upper = datetime(year, 1, 1, 0, 0), datetime(year, 1, 1, 23, 59)
|
|
167
|
+
return _parses_consistently(text, lower, upper, ("hour", "minute"))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _core_datetime(
|
|
171
|
+
value: str | date | datetime | time | None,
|
|
172
|
+
default_date: str | date | datetime | None,
|
|
173
|
+
) -> datetime | None:
|
|
174
|
+
"""Single conversion path shared by every public date/time function.
|
|
175
|
+
|
|
176
|
+
``default_date`` supplies the calendar day for inputs that carry only a
|
|
177
|
+
time. Inputs that carry a date ignore it.
|
|
178
|
+
"""
|
|
179
|
+
if value is None:
|
|
180
|
+
return None
|
|
181
|
+
if isinstance(value, datetime):
|
|
182
|
+
return value.replace(microsecond=0, tzinfo=None)
|
|
183
|
+
if isinstance(value, date):
|
|
184
|
+
return datetime(value.year, value.month, value.day)
|
|
185
|
+
if isinstance(value, time):
|
|
186
|
+
day = as_date(default_date)
|
|
187
|
+
if day is None:
|
|
188
|
+
return None
|
|
189
|
+
return datetime.combine(day, value.replace(microsecond=0, tzinfo=None))
|
|
190
|
+
if not isinstance(value, str):
|
|
191
|
+
raise TypeError(f"Expected str, date, datetime, time, or None, not {type(value).__name__}")
|
|
192
|
+
text = _normalize(value)
|
|
193
|
+
if is_date(text):
|
|
194
|
+
parsed = _parse(text, datetime.combine(_today(), time()))
|
|
195
|
+
if parsed is not None and parsed > _now() + _FUTURE_WINDOW:
|
|
196
|
+
parsed -= _CENTURY
|
|
197
|
+
return None if parsed is None else parsed.replace(microsecond=0)
|
|
198
|
+
day = as_date(default_date)
|
|
199
|
+
if day is None or not has_time(text):
|
|
200
|
+
return None
|
|
201
|
+
parsed = _parse(f"{day.isoformat()} {text}", datetime.combine(day, time()))
|
|
202
|
+
return None if parsed is None else parsed.replace(microsecond=0)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def as_date(value: str | date | datetime | None) -> date | None:
|
|
206
|
+
"""Return the calendar date in ``value``, or ``None`` if it has none.
|
|
207
|
+
|
|
208
|
+
Any parsed date more than two days in the future rolls back one century
|
|
209
|
+
(a two-digit-year heuristic that also hits four-digit years), so
|
|
210
|
+
``12/31/45`` is 1945, not 2045.
|
|
211
|
+
|
|
212
|
+
Example:
|
|
213
|
+
>>> as_date("2020-12-31T23:59")
|
|
214
|
+
datetime.date(2020, 12, 31)
|
|
215
|
+
>>> as_date("12/31/2020 (age 30 yrs)")
|
|
216
|
+
datetime.date(2020, 12, 31)
|
|
217
|
+
>>> as_date("01012024")
|
|
218
|
+
datetime.date(2024, 1, 1)
|
|
219
|
+
>>> as_date("23:59") is None
|
|
220
|
+
True
|
|
221
|
+
>>> as_date("not a date") is None
|
|
222
|
+
True
|
|
223
|
+
"""
|
|
224
|
+
result = _core_datetime(value, None)
|
|
225
|
+
return None if result is None else result.date()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def as_date_str(value: str | date | datetime | None) -> str:
|
|
229
|
+
"""Return the date in ``value`` as ``YYYY-MM-DD``, or ``""`` if it has none.
|
|
230
|
+
|
|
231
|
+
Example:
|
|
232
|
+
>>> as_date_str("12/31/2020 11:30 pm")
|
|
233
|
+
'2020-12-31'
|
|
234
|
+
>>> as_date_str("not a date")
|
|
235
|
+
''
|
|
236
|
+
"""
|
|
237
|
+
result = as_date(value)
|
|
238
|
+
return "" if result is None else result.isoformat()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def as_datetime(
|
|
242
|
+
value: str | date | datetime | time | None,
|
|
243
|
+
*,
|
|
244
|
+
default_date: str | date | datetime | None = None,
|
|
245
|
+
) -> datetime | None:
|
|
246
|
+
"""Return ``value`` as a naive ``datetime`` without microseconds, or ``None``.
|
|
247
|
+
|
|
248
|
+
A date without a time becomes midnight. A time without a date uses
|
|
249
|
+
``default_date`` when given and is ``None`` otherwise.
|
|
250
|
+
|
|
251
|
+
Example:
|
|
252
|
+
>>> as_datetime("2020-12-31T23:59")
|
|
253
|
+
datetime.datetime(2020, 12, 31, 23, 59)
|
|
254
|
+
>>> as_datetime("2020-12-31")
|
|
255
|
+
datetime.datetime(2020, 12, 31, 0, 0)
|
|
256
|
+
>>> as_datetime("23:59") is None
|
|
257
|
+
True
|
|
258
|
+
>>> as_datetime("23:59", default_date="2020-12-31")
|
|
259
|
+
datetime.datetime(2020, 12, 31, 23, 59)
|
|
260
|
+
"""
|
|
261
|
+
return _core_datetime(value, default_date)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def as_datetime_str(
|
|
265
|
+
value: str | date | datetime | time | None,
|
|
266
|
+
*,
|
|
267
|
+
precision: Precision = "second",
|
|
268
|
+
default_date: str | date | datetime | None = None,
|
|
269
|
+
) -> str:
|
|
270
|
+
"""Return ``value`` as ``YYYY-MM-DD HH:MM:SS`` (or ``HH:MM`` with ``precision="minute"``).
|
|
271
|
+
|
|
272
|
+
Returns ``""`` when :func:`as_datetime` would return ``None``. Precision
|
|
273
|
+
truncates; it never rounds.
|
|
274
|
+
|
|
275
|
+
Example:
|
|
276
|
+
>>> as_datetime_str("2020-12-31T23:59")
|
|
277
|
+
'2020-12-31 23:59:00'
|
|
278
|
+
>>> as_datetime_str("2020-12-31 23:59:45", precision="minute")
|
|
279
|
+
'2020-12-31 23:59'
|
|
280
|
+
>>> as_datetime_str("23:59")
|
|
281
|
+
''
|
|
282
|
+
>>> as_datetime_str("23:59", precision="minute", default_date="2020-12-31")
|
|
283
|
+
'2020-12-31 23:59'
|
|
284
|
+
"""
|
|
285
|
+
timespec = _timespec(precision)
|
|
286
|
+
result = as_datetime(value, default_date=default_date)
|
|
287
|
+
return "" if result is None else result.isoformat(" ", timespec)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def as_time(value: str | date | datetime | time | None) -> time | None:
|
|
291
|
+
"""Return the clock time in ``value`` without microseconds, or ``None`` if it has none.
|
|
292
|
+
|
|
293
|
+
Example:
|
|
294
|
+
>>> as_time("2020-12-31T23:59:30")
|
|
295
|
+
datetime.time(23, 59, 30)
|
|
296
|
+
>>> as_time("11:30 pm")
|
|
297
|
+
datetime.time(23, 30)
|
|
298
|
+
>>> as_time("2020-12-31") is None
|
|
299
|
+
True
|
|
300
|
+
"""
|
|
301
|
+
if not has_time(value):
|
|
302
|
+
return None
|
|
303
|
+
result = _core_datetime(value, _today())
|
|
304
|
+
return None if result is None else result.time()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def as_time_str(
|
|
308
|
+
value: str | date | datetime | time | None,
|
|
309
|
+
*,
|
|
310
|
+
precision: Precision = "minute",
|
|
311
|
+
) -> str:
|
|
312
|
+
"""Return the clock time in ``value`` as ``HH:MM`` (or ``HH:MM:SS`` with ``precision="second"``).
|
|
313
|
+
|
|
314
|
+
Returns ``""`` when :func:`as_time` would return ``None``. Precision
|
|
315
|
+
truncates; it never rounds.
|
|
316
|
+
|
|
317
|
+
Example:
|
|
318
|
+
>>> as_time_str("2020-12-31T23:59:30")
|
|
319
|
+
'23:59'
|
|
320
|
+
>>> as_time_str("2020-12-31T23:59:30", precision="second")
|
|
321
|
+
'23:59:30'
|
|
322
|
+
>>> as_time_str("7:05 AM")
|
|
323
|
+
'07:05'
|
|
324
|
+
>>> as_time_str("2020-12-31")
|
|
325
|
+
''
|
|
326
|
+
"""
|
|
327
|
+
timespec = _timespec(precision)
|
|
328
|
+
result = as_time(value)
|
|
329
|
+
return "" if result is None else result.isoformat(timespec)
|
hank_format/_name.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Person-name formatting built on ``nameparser``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from nameparser import HumanName
|
|
8
|
+
from nameparser.config import Constants
|
|
9
|
+
|
|
10
|
+
_CONSTANTS = Constants()
|
|
11
|
+
_CONSTANTS.string_format = "{last}, {first} {middle}, {suffix}"
|
|
12
|
+
_CONSTANTS.suffix_acronyms.add("aprn", "aprn-crna", "apn", "-")
|
|
13
|
+
_CONSTANTS.titles.remove("queen", "queen's", "king", "king's")
|
|
14
|
+
|
|
15
|
+
_NOISE = re.compile(r"\[.*?\]|\(.*?\)|\*")
|
|
16
|
+
_WHITESPACE = re.compile(r"\s+")
|
|
17
|
+
_SPACE_BEFORE_TOKEN = re.compile(r"\s+(\S)")
|
|
18
|
+
_REPEATED_COMMAS = re.compile(r",{2,}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _cleanup(text: str) -> str:
|
|
22
|
+
"""Normalize spacing and comma usage in a raw name string.
|
|
23
|
+
|
|
24
|
+
Turns a trailing ``CRED - CRED`` pair into ``CRED, CRED``, treats a period
|
|
25
|
+
after a leading surname as a comma (an OCR artifact), collapses
|
|
26
|
+
whitespace, removes spaces before commas, and collapses repeated commas.
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
>>> _cleanup("Steven Hardman, APRN - CRNA")
|
|
30
|
+
'Steven Hardman, APRN, CRNA'
|
|
31
|
+
>>> _cleanup("CZIEP. MATTHEW EDWIN MD")
|
|
32
|
+
'CZIEP, MATTHEW EDWIN MD'
|
|
33
|
+
>>> _cleanup("Doe ,, John")
|
|
34
|
+
'Doe, John'
|
|
35
|
+
"""
|
|
36
|
+
head, sep, tail = text.rpartition(",")
|
|
37
|
+
if sep and " - " in tail:
|
|
38
|
+
text = f"{head},{tail.replace(' - ', ', ')}"
|
|
39
|
+
tokens = _WHITESPACE.split(text)
|
|
40
|
+
if len(tokens) >= 3 and len(tokens[0]) >= 4 and tokens[0].endswith("."):
|
|
41
|
+
tokens[0] = f"{tokens[0][:-1]},"
|
|
42
|
+
text = " ".join(tokens)
|
|
43
|
+
text = _SPACE_BEFORE_TOKEN.sub(lambda m: ("" if "," in m[0] else " ") + m[1], text)
|
|
44
|
+
return _REPEATED_COMMAS.sub(",", text).strip()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def as_name(value: str | None) -> str:
|
|
48
|
+
"""Format a person's name as ``LAST, FIRST MIDDLE, SUFFIX`` in upper case.
|
|
49
|
+
|
|
50
|
+
Text before the last ``:`` (``Primary: John Doe``) and after the first
|
|
51
|
+
``;`` is discarded, bracketed or parenthesized notes and asterisks are
|
|
52
|
+
removed, and over-collected trailing lines are dropped. Credential
|
|
53
|
+
suffixes are deduplicated and sorted with acronyms last.
|
|
54
|
+
|
|
55
|
+
Example:
|
|
56
|
+
>>> as_name("Primary: John Doe")
|
|
57
|
+
'DOE, JOHN'
|
|
58
|
+
>>> as_name("Steven Hardman, APRN - CRNA")
|
|
59
|
+
'HARDMAN, STEVEN, APRN, CRNA'
|
|
60
|
+
>>> as_name("Smith, John, PhD, MD")
|
|
61
|
+
'SMITH, JOHN, MD, PHD'
|
|
62
|
+
>>> as_name(None)
|
|
63
|
+
''
|
|
64
|
+
"""
|
|
65
|
+
if value is None:
|
|
66
|
+
return ""
|
|
67
|
+
if not isinstance(value, str):
|
|
68
|
+
raise TypeError(f"Expected str or None, not {type(value).__name__}")
|
|
69
|
+
text = _NOISE.sub("", value.rpartition(":")[-1]).strip()
|
|
70
|
+
while "\n" in text and len(text.split()) > 3:
|
|
71
|
+
text = text.rpartition("\n")[0]
|
|
72
|
+
if not text:
|
|
73
|
+
return ""
|
|
74
|
+
name = HumanName(_cleanup(text.split(";")[0]).strip(". "), constants=_CONSTANTS)
|
|
75
|
+
suffixes = {suffix.rstrip(" -") for suffix in name.suffix_list}
|
|
76
|
+
name.suffix = ", ".join(
|
|
77
|
+
sorted(suffixes, key=lambda s: (s.lower() in _CONSTANTS.suffix_acronyms, s.lower()))
|
|
78
|
+
).strip("'\"")
|
|
79
|
+
formatted = name.full_name.upper().strip().replace(".", "").replace("''", "").replace('"', "")
|
|
80
|
+
return _cleanup(formatted.strip("-"))
|
hank_format/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hank-format
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared string formatting functions for standardizing values across the Hank AI stack.
|
|
5
|
+
Author-email: Sam Hartzog <sam.hartzog@hank.ai>
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: python-dateutil>=2.8,<3
|
|
18
|
+
Requires-Dist: nameparser>=1.1,<2
|
|
19
|
+
Project-URL: Home, https://github.com/hank-ai/hank-format
|
|
20
|
+
Project-URL: Issues, https://github.com/hank-ai/hank-format/issues
|
|
21
|
+
|
|
22
|
+
# hank-format
|
|
23
|
+
|
|
24
|
+
Shared string formatting functions for standardizing names, dates, and times
|
|
25
|
+
across the Hank AI stack.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install hank-format
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from hank_format import as_name, as_date, as_date_str, as_datetime_str, as_time_str
|
|
35
|
+
|
|
36
|
+
as_name("Primary: John Doe") # 'DOE, JOHN'
|
|
37
|
+
as_name("Steven Hardman, APRN - CRNA") # 'HARDMAN, STEVEN, APRN, CRNA'
|
|
38
|
+
as_date("12/31/2020 (age 30 yrs)") # datetime.date(2020, 12, 31)
|
|
39
|
+
as_date_str("01012024") # '2024-01-01'
|
|
40
|
+
as_datetime_str("2020-12-31T23:59") # '2020-12-31 23:59:00'
|
|
41
|
+
as_datetime_str("23:59", precision="minute", default_date="2020-12-31") # '2020-12-31 23:59'
|
|
42
|
+
as_time_str("11:30 pm") # '23:30'
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## API
|
|
46
|
+
|
|
47
|
+
| Function | Returns | Notes |
|
|
48
|
+
| --- | --- | --- |
|
|
49
|
+
| `as_name(value)` | `str` | `LAST, FIRST MIDDLE, SUFFIX`, upper case. Strips `Label:` prefixes, `[notes]`, `(notes)`, `*`, and text after `;`. |
|
|
50
|
+
| `is_date(value)` | `bool` | True when a year, month, and day are present. |
|
|
51
|
+
| `has_time(value)` | `bool` | True when an hour and minute are present. |
|
|
52
|
+
| `as_date(value)` | `date \| None` | Calendar date only. |
|
|
53
|
+
| `as_date_str(value)` | `str` | `YYYY-MM-DD` or `""`. |
|
|
54
|
+
| `as_datetime(value, *, default_date=None)` | `datetime \| None` | Midnight when no time is present. Time-only input needs `default_date`. |
|
|
55
|
+
| `as_datetime_str(value, *, precision="second", default_date=None)` | `str` | `YYYY-MM-DD HH:MM:SS`, or `YYYY-MM-DD HH:MM` with `precision="minute"`. |
|
|
56
|
+
| `as_time(value)` | `time \| None` | Clock time only. |
|
|
57
|
+
| `as_time_str(value, *, precision="minute")` | `str` | `HH:MM`, or `HH:MM:SS` with `precision="second"`. |
|
|
58
|
+
|
|
59
|
+
Conventions:
|
|
60
|
+
|
|
61
|
+
- Inputs may be strings, `date`, `datetime`, `time`, or `None`. Other types raise `TypeError`.
|
|
62
|
+
- Object variants return `None` and string variants return `""` when no usable value exists.
|
|
63
|
+
- Returned values are timezone-naive and have no microseconds. `precision` truncates, never rounds.
|
|
64
|
+
- Any parsed date more than two days in the future rolls back 100 years, including four-digit years and year-less `MM/DD` (`12/31/45` is 1945; `12/26` without a time is 1926 when today is 2026-10-08). This is a two-digit-year heuristic inherited from pdf-extractor; strip or validate far-future dates before calling if you need them intact.
|
|
65
|
+
- Year-less `MM/DD HH:MM` strings take the current year, or the previous year if that would be in the future.
|
|
66
|
+
|
|
67
|
+
## Migrating from pdf-extractor `utils`
|
|
68
|
+
|
|
69
|
+
| Legacy | hank-format |
|
|
70
|
+
| --- | --- |
|
|
71
|
+
| `as_name(s)` | `as_name(s)` (lists and `vStr` handling stay in pdf-extractor) |
|
|
72
|
+
| `as_date(v, date_only=True, as_dt=True)` | `as_date(v)` (returns `date`) |
|
|
73
|
+
| `as_date(v, date_only=True)` | `as_date_str(v)` |
|
|
74
|
+
| `as_date(v, as_dt=True)` | `as_datetime(v)` |
|
|
75
|
+
| `as_date(v)` | `as_datetime_str(v)` |
|
|
76
|
+
| `as_time(v, with_date=False)` | `as_time_str(v)` |
|
|
77
|
+
| `as_time(v, strict=True)` | `as_datetime_str(v, precision="minute") if has_time(v) else ""` |
|
|
78
|
+
| `as_time(v, default_date=d)` | `as_datetime_str(v, precision="minute", default_date=d) if has_time(v) else ""` |
|
|
79
|
+
| `as_time(v)` | `as_datetime_str(v, precision="minute") if is_date(v) and has_time(v) else as_time_str(v)` |
|
|
80
|
+
|
|
81
|
+
Legacy `as_time` returned `""` for anything without a time; the new datetime
|
|
82
|
+
functions format a date-only value as midnight, hence the `has_time` guards.
|
|
83
|
+
|
|
84
|
+
## Development
|
|
85
|
+
|
|
86
|
+
Requires [uv](https://docs.astral.sh/uv/).
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
uv sync # create .venv with Python 3.12 and the dev group
|
|
90
|
+
uv run pytest # tests with 100% coverage enforced
|
|
91
|
+
uv run ruff check . && uv run ruff format --check .
|
|
92
|
+
uv run pyright
|
|
93
|
+
uv build # sdist + wheel into dist/
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Releasing
|
|
97
|
+
|
|
98
|
+
1. Open a PR against `main` and add exactly one label: `release:patch`, `release:minor`, or `release:major`.
|
|
99
|
+
2. On merge, the `Release` workflow bumps `__version__` in `hank_format/__init__.py`, runs the tests, builds, commits `Release vX.Y.Z`, tags it, and pushes. A separate `publish` job then uploads the built artifacts to PyPI.
|
|
100
|
+
3. If only the `publish` job fails (for example, trusted publishing is not configured yet), fix the cause and re-run that failed job. Do not re-run the whole workflow: the version is already bumped and tagged, so a full re-run would bump it again.
|
|
101
|
+
|
|
102
|
+
One-time setup:
|
|
103
|
+
|
|
104
|
+
- On pypi.org, under the `hank-format` project (or as a pending publisher), add a trusted publisher with owner `hank-ai`, repository `hank-format`, workflow `release.yml`, environment `pypi`.
|
|
105
|
+
- In the GitHub repo settings, create an environment named `pypi`.
|
|
106
|
+
- The workflow pushes the version-bump commit to `main` with the default `GITHUB_TOKEN`. If `main` is protected, allow the token to bypass the rule or supply a PAT in a `RELEASE_TOKEN` secret and use it in the checkout step.
|
|
107
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
hank_format/__init__.py,sha256=D5vhFT8Hfe4Nb-omYSaJNLaCkTNdrAN_YPYGLaq3i7w,859
|
|
2
|
+
hank_format/_datetime.py,sha256=V03KRjoFdhSFK-u-BiILzL4Afp7KqgXwE45epZcdBWI,11049
|
|
3
|
+
hank_format/_name.py,sha256=3v4irN7S_lGPJIHpoZYZ1Nrk1v3ECkmh_jEsVUEgN-s,3038
|
|
4
|
+
hank_format/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
hank_format-0.1.0.dist-info/licenses/LICENSE,sha256=ATf0xlSg-7xKu3m1GNH602Sfe91hV7CmM3z8ztWO3ro,1065
|
|
6
|
+
hank_format-0.1.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
7
|
+
hank_format-0.1.0.dist-info/METADATA,sha256=8YVkTwli2MXQMpoarHCvhMwLadcQ5UIRxaBJEkWcKOY,5697
|
|
8
|
+
hank_format-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 shartzog
|
|
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.
|