Repository: realpython/codetiming

Issue:
Human Friendly timespan
@gahjelle Would it be possible to to add an enhancement that returns the elapsed time using [humanfriendly.format_timespan](https://pypi.org/project/humanfriendly/)?

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1e3f497..cfe2e9b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 
 ### Added
 
+- `text` can be a callable returning a formatted string, suggested by [@dchess](https://github.com/dchess) in [#29] ([#30]).
 - Testing with [Interrogate](https://interrogate.readthedocs.io/) to enforce docstrings ([#27]).
 
 
@@ -50,3 +51,5 @@ Initial version of `codetiming`. Version 1.0.0 corresponds to the code in the tu
 [#24]: https://github.com/realpython/codetiming/issues/24
 [#25]: https://github.com/realpython/codetiming/pull/25
 [#27]: https://github.com/realpython/codetiming/pull/27
+[#29]: https://github.com/realpython/codetiming/issues/29
+[#30]: https://github.com/realpython/codetiming/pull/30
diff --git a/README.md b/README.md
index b1e9b53..0b57fb7 100644
--- a/README.md
+++ b/README.md
@@ -71,6 +71,22 @@ Note that the strings used by `text` are **not** f-strings. Instead they are use
 t = Timer(text=f"{__file__}: {{:.4f}}")
 ```
 
+`text` is also allowed to be a callable like a function or a class. If `text` is a callable, it is expected to require one argument: the number of seconds elapsed. It should return a text string that will be logged using logger:
+
+```python
+t = Timer(text=lambda secs: f"{secs / 86400:.0f} days")
+```
+
+This allows you to use third-party libraries like [`humanfriendly`](https://pypi.org/project/humanfriendly/) to do the text formatting:
+
+```
+from humanfriendly import format_timespan
+
+t1 = Timer(text=format_timespan)
+t2 = Timer(text=lambda secs: f"Elapsed time: {format_timespan(secs)}")
+```
+
+
 
 ## Capturing the Elapsed Time
 
diff --git a/codetiming/_timer.py b/codetiming/_timer.py
index 39b483e..08a3787 100644
--- a/codetiming/_timer.py
+++ b/codetiming/_timer.py
@@ -9,7 +9,7 @@ import math
 import time
 from contextlib import ContextDecorator
 from dataclasses import dataclass, field
-from typing import Any, Callable, ClassVar, Optional
+from typing import Any, Callable, ClassVar, Optional, Union
 
 # Codetiming imports
 from codetiming._timers import Timers
@@ -26,7 +26,7 @@ class Timer(ContextDecorator):
     timers: ClassVar[Timers] = Timers()
     _start_time: Optional[float] = field(default=None, init=False, repr=False)
     name: Optional[str] = None
-    text: str = "Elapsed time: {:0.4f} seconds"
+    text: Union[str, Callable[[float], str]] = "Elapsed time: {:0.4f} seconds"
     logger: Optional[Callable[[str], None]] = print
     last: float = field(default=math.nan, init=False, repr=False)
 
@@ -48,13 +48,17 @@ class Timer(ContextDecorator):
 
         # Report elapsed time
         if self.logger:
-            attributes = {
-                "name": self.name,
-                "milliseconds": self.last * 1000,
-                "seconds": self.last,
-                "minutes": self.last / 60,
-            }
-            self.logger(self.text.format(self.last, **attributes))
+            if callable(self.text):
+                text = self.text(self.last)
+            else:
+                attributes = {
+                    "name": self.name,
+                    "milliseconds": self.last * 1000,
+                    "seconds": self.last,
+                    "minutes": self.last / 60,
+                }
+                text = self.text.format(self.last, **attributes)
+            self.logger(text)
         if self.name:
             self.timers.add(self.name, self.last)
 


Test patch:
diff --git a/tests/test_codetiming.py b/tests/test_codetiming.py
index bd77ef3..c916c2c 100644
--- a/tests/test_codetiming.py
+++ b/tests/test_codetiming.py
@@ -227,6 +227,54 @@ def test_using_milliseconds_attribute_in_text(capsys):
     assert int(milliseconds) == round(float(seconds) * 1000)
 
 
+def test_text_formatting_function(capsys):
+    """Test that text can be formatted by a separate function"""
+
+    def format_text(seconds):
+        """Function that returns a formatted text"""
+        return f"Function: {seconds + 1:.0f}"
+
+    with Timer(text=format_text):
+        waste_time()
+
+    stdout, stderr = capsys.readouterr()
+    assert stdout.strip() == "Function: 1"
+    assert not stderr.strip()
+
+
+def test_text_formatting_class(capsys):
+    """Test that text can be formatted by a separate class"""
+
+    class TextFormatter:
+        """Class that behaves like a formatted text"""
+
+        def __init__(self, seconds):
+            """Initialize with number of seconds"""
+            self.seconds = seconds
+
+        def __str__(self):
+            """Represent the class as a formatted text"""
+            return f"Class: {self.seconds + 1:.0f}"
+
+    with Timer(text=TextFormatter):
+        waste_time()
+
+    stdout, stderr = capsys.readouterr()
+    assert stdout.strip() == "Class: 1"
+    assert not stderr.strip()
+
+    def format_text(seconds):
+        """Callable that returns a formatted text"""
+        return f"Callable: {seconds + 1:.0f}"
+
+    with Timer(text=format_text):
+        waste_time()
+
+    stdout, stderr = capsys.readouterr()
+    assert stdout.strip() == "Callable: 1"
+    assert not stderr.strip()
+
+
 def test_timers_cleared():
     """Test that timers can be cleared"""
     with Timer(name="timer_to_be_cleared"):