Skip to content

Commit

Permalink
Better handling of timezones in Clock widget
Browse files Browse the repository at this point in the history
This PR adds two new commands to the Clock widget:
1) update_timezone: allows users to specify a new timezone or just
   refresh the clock based on changes to the system timezone
2) use_system_timezone: clears user-specified timezone so widget uses
   system time

Fixes #3746
  • Loading branch information
elParaguayo authored and tych0 committed Mar 10, 2024
1 parent 728ed0a commit 13e9ac8
Show file tree
Hide file tree
Showing 2 changed files with 85 additions and 18 deletions.
55 changes: 49 additions & 6 deletions libqtile/widget/clock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -60,20 +62,37 @@ 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"
" string without pytz or dateutil. Install one"
" 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())
Expand All @@ -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("")
48 changes: 36 additions & 12 deletions test/widgets/test_clock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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"

0 comments on commit 13e9ac8

Please sign in to comment.