Repository: qtile/qtile

Issue:
Clock and tzupdate 
### The issue:

qtile version:
0.21.0

These days I need to travel with my system and have the need to update my timezone in order to work with my calendar. I'm using `tzupdate` which easily updates my timezone with one command. 

I'm using the Clock widget in the qtile bar as so:
``` python 
        widget.Clock(format="%A %d %b %Y %H:%M:%S %z"),
```

Updating the timezone with `tzupdate` however does not change the timezone on the Clock widget. It requires restarting qtile in order to get this done. I would expect qtile to poll the current system timezone at each tik.

### Required:

- [X] I have searched past issues to see if this bug has already been reported.


Discussion:
This isn't exactly a qtile bug.

If you open an interactive python session and do
```
>>> import datetime
>>> datetime.datetime.now()
### change timezone now with tzupdate
>>> datetime.datetime.now()
```
You will see that the timezone is not updated.
Is there no way to get the new time zone though? If we did, we could use https://docs.python.org/3/library/datetime.html#datetime.datetime.astimezone in the widget, querying the time in UTC and then rendering it with the timezone with that
I think you'd need a third party library which seems excessive for the clock widget.

I don't think datetime handles timezone names. 
Looks like all the widget needs to do is update `self.timezone` if/when needed. If we want to avoid checking the system timezone on every tick we could either make multiple timezones configurable and then commands to scroll between them (so you could literally scroll on the widget to update it). Or if any of those libraries do support fetching the system timezone and the result does indeed change following tzupdate usage, then we can bind a command to that too, so you just need to click the widget to make sure it's timezone is up to date
Ha. Yeah. Been a while since I looked at the code. So the third party libraries are there already. That's good.

Agreed on your approach.
This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 30 days.
This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 30 days.

Resolving patch:
diff --git a/libqtile/widget/clock.py b/libqtile/widget/clock.py
--- a/libqtile/widget/clock.py
+++ b/libqtile/widget/clock.py
@@ -20,11 +20,13 @@
 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
+from __future__ import annotations
 
 import sys
 import time
-from datetime import datetime, timedelta, timezone
+from datetime import datetime, timedelta, timezone, tzinfo
 
+from libqtile.command.base import expose_command
 from libqtile.log_utils import logger
 from libqtile.widget import base
 
@@ -60,11 +62,24 @@ class Clock(base.InLoopPollText):
     def __init__(self, **config):
         base.InLoopPollText.__init__(self, **config)
         self.add_defaults(Clock.defaults)
-        if isinstance(self.timezone, str):
+        self.timezone = self._lift_timezone(self.timezone)
+
+        if self.timezone is None:
+            logger.debug("Defaulting to the system local timezone.")
+
+    def _lift_timezone(self, timezone):
+        if isinstance(timezone, tzinfo):
+            return timezone
+        elif isinstance(timezone, str):
+            # Empty string can be used to force use of system time
+            if not timezone:
+                return None
+
+            # A string timezone needs to be converted to a tzinfo object
             if "pytz" in sys.modules:
-                self.timezone = pytz.timezone(self.timezone)
+                return pytz.timezone(timezone)
             elif "dateutil" in sys.modules:
-                self.timezone = dateutil.tz.gettz(self.timezone)
+                return dateutil.tz.gettz(timezone)
             else:
                 logger.warning(
                     "Clock widget can not infer its timezone from a"
@@ -72,8 +87,12 @@ def __init__(self, **config):
                     " of these libraries, or give it a"
                     " datetime.tzinfo instance."
                 )
-        if self.timezone is None:
-            logger.debug("Defaulting to the system local timezone.")
+        elif timezone is None:
+            pass
+        else:
+            logger.warning("Invalid timezone value %s.", timezone)
+
+        return None
 
     def tick(self):
         self.update(self.poll())
@@ -88,3 +107,27 @@ def poll(self):
         else:
             now = datetime.now(timezone.utc).astimezone()
         return (now + self.DELTA).strftime(self.format)
+
+    @expose_command
+    def update_timezone(self, timezone: str | tzinfo | None = None):
+        """
+        Force the clock to update timezone information.
+
+        If the method is called with no arguments then the widget will reload
+        the timzeone set on the computer (e.g. via ``timedatectl set-timezone ..``).
+        This will have no effect if you have previously set a ``timezone`` value.
+
+        Alternatively, you can pass a timezone string (e.g. ``"Europe/Lisbon"``) to change
+        the specified timezone. Setting this to an empty string will cause the clock
+        to rely on the system timezone.
+        """
+        self.timezone = self._lift_timezone(timezone)
+
+        # Force python to update timezone info (e.g. if system time has changed)
+        time.tzset()
+        self.update(self.poll())
+
+    @expose_command
+    def use_system_timezone(self):
+        """Force clock to use system timezone."""
+        self.update_timezone("")


Test patch:
diff --git a/test/widgets/test_clock.py b/test/widgets/test_clock.py
--- a/test/widgets/test_clock.py
+++ b/test/widgets/test_clock.py
@@ -43,11 +43,6 @@ class MockDatetime(datetime.datetime):
     def now(cls, *args, **kwargs):
         return cls(2021, 1, 1, 10, 20, 30)
 
-    def astimezone(self, tzone=None):
-        if tzone is None:
-            return self
-        return self + datetime.timedelta(hours=tzone)
-
 
 @pytest.fixture
 def patched_clock(monkeypatch):
@@ -75,7 +70,7 @@ def test_clock(fake_qtile, monkeypatch, fake_window):
 
 
 @pytest.mark.usefixtures("patched_clock")
-def test_clock_invalid_timezone(fake_qtile, monkeypatch, fake_window):
+def test_clock_invalid_timezone(fake_qtile, monkeypatch, fake_window, caplog):
     """test clock widget with invalid timezone (and no pytz or dateutil modules)"""
 
     class FakeDateutilTZ:
@@ -103,9 +98,11 @@ def gettz(cls, val):
     fakebar = FakeBar([clk2], window=fake_window)
     clk2._configure(fake_qtile, fakebar)
 
-    # An invalid timezone current causes a TypeError
-    with pytest.raises(TypeError):
-        clk2.poll()
+    # An invalid timezone results in a log message
+    assert (
+        "Clock widget can not infer its timezone from a string without pytz or dateutil."
+        in caplog.text
+    )
 
 
 @pytest.mark.usefixtures("patched_clock")
@@ -127,7 +124,8 @@ def gettz(cls, val):
     clock.dateutil = FakeDateutilTZ
 
     # Fake datetime module just adds the timezone value to the time
-    clk3 = clock.Clock(timezone=1)
+    tz = datetime.timezone(datetime.timedelta(hours=1))
+    clk3 = clock.Clock(timezone=tz)
 
     fakebar = FakeBar([clk3], window=fake_window)
     clk3._configure(fake_qtile, fakebar)
@@ -154,7 +152,8 @@ class FakePytz:
         # to show that this code is being run
         @classmethod
         def timezone(cls, value):
-            return int(value) + 1
+            hours = int(value) + 1
+            return datetime.timezone(datetime.timedelta(hours=hours))
 
     # We need pytz in the sys.modules dict
     monkeypatch.setitem(sys.modules, "pytz", True)
@@ -185,7 +184,8 @@ class FakeDateutilTZ:
         class TZ:
             @classmethod
             def gettz(cls, val):
-                return int(val) + 2
+                hours = int(val) + 2
+                return datetime.timezone(datetime.timedelta(hours=hours))
 
         tz = TZ
 
@@ -269,3 +269,27 @@ def astimezone(self, tzone=None):
     topbar = manager_nospawn.c.bar["top"]
     manager_nospawn.c.widget["clock"].eval("self.tick()")
     assert topbar.info()["widgets"][0]["text"] == "10:21"
+
+
+@pytest.mark.usefixtures("patched_clock")
+def test_clock_change_timezones(fake_qtile, monkeypatch, fake_window):
+    """test commands to change timezones"""
+
+    tz1 = datetime.timezone(datetime.timedelta(hours=1))
+    tz2 = datetime.timezone(-datetime.timedelta(hours=1))
+
+    # Pytz timezone must be a string
+    clk4 = clock.Clock(timezone=tz1)
+
+    fakebar = FakeBar([clk4], window=fake_window)
+    clk4._configure(fake_qtile, fakebar)
+    text = clk4.poll()
+    assert text == "11:20"
+
+    clk4.update_timezone(tz2)
+    text = clk4.poll()
+    assert text == "09:20"
+
+    clk4.use_system_timezone()
+    text = clk4.poll()
+    assert text == "10:20"