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.
@@ -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
+ ]
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: flit 3.12.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.