findall gives you the pieces. finditer gives you the pieces *and their positions*, which is what lets you notice the gaps between them.
import re
pattern = re.compile(r"(\d+)([dhms])")
print(pattern.findall("1h nonsense 30m"))
# -> [('1', 'h'), ('30', 'm')] the nonsense is simply gone
for match in pattern.finditer("1h30m"):
print(match.group(1), match.group(2), match.start(), match.end())
# -> 1 h 0 2
# -> 30 m 2 5 each match begins exactly where the last one ended
- findall cannot tell you what it skipped
It returns matches and nothing else, so text between them vanishes without trace. For extracting things from prose that is exactly right. For parsing a value where every character is supposed to mean something, it turns a typo into a plausible wrong answer.
- Consecutive matches touch
In a well-formed duration each match starts where the previous one ended, and the last one ends at the end of the string. Checking that as you go is how you prove nothing was skipped — no second pass and no second pattern needed.
- The units belong in a dictionary
{'d': 86400, 'h': 3600, 'm': 60, 's': 1} is a lookup table, so adding weeks is one line of data rather than another branch. It is the same argument as the if-chain refactor: a mapping written as control flow is a mapping in the wrong shape.
Matching the parts is easy. The work is proving there was nothing else in the string — so you need to know where each match sat, not only what it contained.
Use finditer and track where you expect the next match to start. If a match begins anywhere other than the end of the previous one, something was skipped. After the loop, check you reached the end of the string.
Keep a position, starting at 0. For each match: if match.start() is not position, raise; otherwise add int(number) times the units table entry, and set position to match.end(). Afterwards, if position is not len(text), raise. Guard the empty string separately — that final check cannot catch it.
import re UNITS = {'d': 86400, 'h': ____, 'm': ____, 's': 1} PATTERN = re.compile(r'(\d+)([dhms])') def parse_duration(text): total = 0 position = 0 for match in PATTERN.____(text): if match.____() != position: raise ValueError(...) total += int(match.group(1)) * UNITS[match.group(____)] position = match.____() if position != ____(text): raise ValueError(...) return total
import re
UNITS = {"d": 86400, "h": 3600, "m": 60, "s": 1}
PATTERN = re.compile(r"(\d+)([dhms])")
def parse_duration(text):
if not text:
raise ValueError("duration cannot be empty")
total = 0
position = 0
for match in PATTERN.finditer(text):
if match.start() != position:
raise ValueError(f"cannot read {text!r} as a duration")
total += int(match.group(1)) * UNITS[match.group(2)]
position = match.end()
if position != len(text):
raise ValueError(f"cannot read {text!r} as a duration")
return total
Tracking the position is the entire difference between pulling durations out of prose and reading a value where every character must count. Five of the cases here are rejections, and all of them come down to that one check. Extraction is forgiving by design; parsing must not be.
position != len(text) rejects a bare number and trailing rubbish, and misses the empty string entirely — nothing matched, so position is 0, the length is 0, and 0 != 0 is false. The function would return 0, quietly turning a missing config value into 'no wait at all'. That is why there is a separate guard on the first line. A condition that covers three cases is worth checking against the fourth.
A pattern of r'(\d+[dhms])+' with fullmatch proves the whole string is well-formed in one call, and then you still have to walk the parts to total them. It is a clean alternative and slightly harder to explain; the position-tracking version keeps validation and summing in one pass, which is why it is the one shown.
If the format is anything more than this — decimals like 1.5h, spaces, plural words like '2 hours', ISO 8601's PT1H30M — reach for a library rather than growing the pattern. pytimeparse and dateutil handle the human variants, and Python’s own datetime.timedelta is what the result should probably become rather than a bare integer, because a timedelta cannot be accidentally added to a number of bytes. Years and months are deliberately absent: neither has a fixed length in seconds.
- Return a datetime.timedelta instead of an integer, and notice which caller mistakes that makes impossible.
- Support a leading minus for negative durations, and decide whether '-1h30m' means minus ninety minutes or minus one hour plus thirty.
- Rewrite it with re.fullmatch and compare which version you would rather extend to accept spaces between the parts.