Skip to content

Instantly share code, notes, and snippets.

@s-zeid
Last active August 2, 2021 08:22
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save s-zeid/1e55b9c663bb0e84fe4a7b8953b4d48a to your computer and use it in GitHub Desktop.
Save s-zeid/1e55b9c663bb0e84fe4a7b8953b4d48a to your computer and use it in GitHub Desktop.
(old; moved to https://gitlab.com/scottywz/bin/blob/main/ora) not-abomination clock
#!/usr/bin/env python3
# vim: set fdm=marker:
import argparse
import atexit
import base64
import configparser
import datetime
import decimal
import os
import re
import sys
import _thread
import threading
import traceback
import time
import tkinter as tk
from tkinter import font # type: ignore
from typing import *
_INTERRUPT_EXIT_CODE = 0
class Application(tk.Canvas): #{{{1
last_datetime = None
last_tzoffset = None
light = False
running = False
ntp_thread = None
_fs_image = None
_fs_image_path = None
def __init__(self, options, master=None): #{{{2
super().__init__(master)
self.master.tk.eval('tk scaling 1')
self.options = options
self.sunrise = options["sunrise"]
self.sunset = options["sunset"]
self.location = options["location"]
self._default_sunrise_sunset = [self.sunrise, self.sunset]
if options["ntp"]:
self.ntp_thread = NTPThread(server=options["ntp"])
self.ntp_thread.start()
self.easter_eggs = options["easter_eggs"]
self.width = width = options["width"]
self.height = height = options["height"]
if not self.options["base_size"]:
test_size = 512
test_font = tk.font.Font(family=self.options["font_family"], size=test_size)
test_width = test_font.measure("00:00:00")
self.base_size_width_ratio = test_size / test_width
self.base_size_auto = True
else:
self.base_size = round_half_up(self.options["base_size"])
self.base_size_auto = False
self.background = options["background"]
self.foreground = options["foreground_dark"]
self.configure(background=self.background)
self.master.configure(background=self.background)
if options["fullscreen"]:
self.toggle_fullscreen(new_state=True) # calls self.resize()
else:
self.resize()
self.master.title("Clock")
self.configure(borderwidth=0, highlightthickness=0)
self.pack(fill=tk.BOTH, expand=1)
self.create_widgets()
self.bind("<Configure>", self.resize)
if options["keybindings"]:
self.master.bind("<Double-Button-1>", self.toggle_fullscreen)
self.master.bind("<Button-3>", self.toggle_fullscreen)
self.master.bind("<F11>", self.toggle_fullscreen)
self.master.bind("f", self.toggle_fullscreen)
self.master.bind("F", self.toggle_fullscreen)
self.master.bind("q", self.stop)
self.master.bind("Q", self.stop)
def resize(self, event=None, force_geometry=False): #{{{2
if event:
width = event.width + event.x
height = event.height + event.y
if width == self.width and height == self.height:
return
else:
width, height = self.width, self.height
if self.base_size_auto:
self.base_size = round_half_up((width * 0.825) * self.base_size_width_ratio)
self.time_font = tf = tk.font.Font(
family=self.options["font_family"],
size=-self.base_size,
)
self.date_font = df = tk.font.Font(
family=self.options["font_family"],
size=-round_half_up(self.base_size / 2)
)
self.weekday_font = wf = tk.font.Font(
family=self.options["font_family"],
size=-round_half_up(self.base_size / 3)
)
self.td_offset = (tf.metrics()["linespace"] - df.metrics()['linespace']) / 8
self.dw_offset = (df.metrics()["linespace"] - wf.metrics()['linespace']) / 8
self.width, self.height = width, height
self.set_background_image(
self.options.get("background_image"),
self.options.get("image_opacity_light", 1),
self.options.get("image_opacity_dark", 1),
)
self.reset()
if not self.running or force_geometry:
s_w, s_h = self.master.winfo_screenwidth(), self.master.winfo_screenheight()
x, y = round_half_up((s_w - width) / 2), round_half_up((s_h - height) / 2)
self.master.geometry("%dx%d+%d+%d" % (width, height, x, y))
if self.running:
self.draw()
def set_background_image(self, path, opacity_l, opacity_d, blur=0): #{{{2
if path:
import PIL # type: ignore # pip3 install Pillow; apt install python3-pil; zypper install python3-Pillow
import PIL.ImageTk # type: ignore # pip3: Pillow; apt: python3-pil.imagetk; zypper: python3-Pillow-tk
def main():
nonlocal path, opacity_l, opacity_d, blur
if path:
path = os.path.abspath(path)
if self._fs_image and path == self._fs_image_path:
fs_img = self._fs_image
else:
fs_img = self._fs_image = PIL.Image.open(path)
self._fs_image_path = path
img = fs_img.copy()
if blur:
with img as old:
img = old.filter(PIL.ImageFilter.GaussianBlur(radius=blur))
del old
img_l = prepare_image(img, opacity_l)
img_d = prepare_image(img, opacity_d)
img.close()
del img
self.image_light = PIL.ImageTk.PhotoImage(img_l)
self.image_dark = PIL.ImageTk.PhotoImage(img_d)
img_l.close()
img_d.close()
del img_l, img_d
self.image = self.image_light if self.light else self.image_dark
else:
self.image = self.image_light = self.image_dark = None
self._fs_image = self._fs_image_path = None
def prepare_image(img, opacity):
img = img.copy()
if img.mode == "RGB":
with PIL.Image.new("L", img.size, round_half_up(255 * opacity)) as alpha:
img.putalpha(alpha)
del alpha
elif img.mode == "RGBA" and opacity != 1:
with img as old:
with old.copy() as old_0:
with PIL.Image.new("L", img.size, 0) as alpha_0:
old_0.putalpha(alpha_0)
del alpha_0
img = PIL.Image.blend(old_0, old, opacity)
del old_0
del old
self_ratio = self.width / self.height
img_ratio = img.width / img.height
if img.size != self.size:
if img_ratio > self_ratio:
w, h = round_half_up(max(img.width * self.height / img.height, 1)), self.height
left = (w - self.width) / 2
top = 0
else:
w, h = self.width, round_half_up(max(img.height * self.width / img.width, 1))
left = 0
top = (h - self.height) / 2
with img as old:
old.draft(None, (w, h))
img = old.resize((w, h), PIL.Image.BICUBIC)
del old
with img as old:
img = old.crop((left, top, left + self.width, top + self.height))
del old
return img
return main()
def toggle_fullscreen(self, _=None, new_state=None): #{{{2
s_w, s_h = self.master.winfo_screenwidth(), self.master.winfo_screenheight()
if new_state is None:
fullscreen_now = self.master.attributes("-fullscreen")
maxres_now = self.width == s_w and self.height == s_h
new_state = not (fullscreen_now and maxres_now)
if new_state:
self._prev_geom = self.master.winfo_geometry()
self.width, self.height = s_w, s_h
self.master.attributes("-fullscreen", new_state)
self.master.attributes("-zoomed", new_state)
force_geometry = new_state
if not new_state:
if self._prev_geom and self._prev_geom != "1x1+0+0":
self.master.geometry(self._prev_geom)
self.width, self.height = self.master.winfo_width(), self.master.winfo_height()
else:
force_geometry = True
self.width, self.height = self.options["width"], self.options["height"]
self.resize(force_geometry=force_geometry)
def create_widgets(self): #{{{2
return
def tick(self): #{{{2
new_datetime = datetime.datetime.now()
if self.ntp_thread:
offset = self.ntp_thread.offset
if offset is not None:
new_datetime += datetime.timedelta(seconds=offset)
else:
return
new_datetime = new_datetime.replace(microsecond=0)
new_time = new_datetime.time()
new_tzoffset = time.localtime().tm_gmtoff
is_new_day = False
is_new_tzoffset = False
draw = False
if self.last_datetime is None or new_datetime != self.last_datetime:
self.time_text = new_datetime.strftime("%H:%M:%S")
if self.easter_eggs:
if new_time.hour == 4 and new_time.minute == 21 and 0 <= new_time.second <= 9:
self.time_text = "%02d:%02d:%02d" % (new_time.hour, 20, new_time.second + 60)
if new_time.hour == 16 and new_time.minute == 33 and 0 <= new_time.second <= 4:
self.time_text = "%02d:%02d:%02d" % (new_time.hour, 32, new_time.second + 60)
draw = True
if self.last_datetime is None or new_datetime.day != self.last_datetime.day:
self.date_text = new_datetime.strftime("%Y-%m-%d")
self.weekday_text = new_datetime.strftime("%A")
draw = True
is_new_day = True
if self.last_tzoffset is None or new_tzoffset != self.last_tzoffset:
is_new_tzoffset = True
if is_new_day or is_new_tzoffset:
self.sunrise, self.sunset = sunrise_sunset(
self.location,
defaults=self._default_sunrise_sunset,
)
if (new_time >= self.sunrise and new_time < self.sunset) and self.light != True:
self.light = True
self.foreground = self.options["foreground_light"]
self.image = self.image_light
if (new_time >= self.sunset or new_time < self.sunrise) and self.light != False:
self.light = False
self.foreground = self.options["foreground_dark"]
self.image = self.image_dark
self.last_datetime = new_datetime
self.last_tzoffset = new_tzoffset
if draw:
self.draw(is_new_day)
self.update()
def draw(self, is_new_day=False): #{{{2
canvas = self
if not self._rect:
self._rect = canvas.create_rectangle(0, 0, self.width, self.height,
fill=self.background, outline=self.background)
if self.image:
i_cur, i_l, i_d = self._image_ids
if not i_cur or not i_l or not i_d:
i_l = canvas.create_image(0, 0, anchor="nw", tag="image", image=self.image_light)
if self.image_light != self.image_dark:
i_d = canvas.create_image(0, 0, anchor="nw", tag="image", image=self.image_dark)
else:
i_d = i_l
i_cur = None
if self.light:
i_new, i_old = i_l, i_d
else:
i_new, i_old = i_d, i_l
if i_new != i_cur:
canvas.lower(i_old, self._rect)
canvas.tkraise(i_new, self._rect)
i_cur = i_new
self._image_ids = [i_cur, i_l, i_d]
t_pos, d_pos, w_pos = self._text_pos
t_x, t_y = t_pos or (0, 0)
d_x, d_y = d_pos or (0, 0)
w_x, w_y = w_pos or (0, 0)
t_id, d_id, w_id = self._text_ids
if not t_id or not d_id or not w_id:
canvas.delete("text")
t_id = canvas.create_text(t_x, t_y, anchor="nw", tag="text",
text=self.time_text, font=self.time_font, fill=self.foreground)
d_id = canvas.create_text(d_x, d_y, anchor="nw", tag="text",
text=self.date_text, font=self.date_font, fill=self.foreground)
w_id = canvas.create_text(w_x, w_y, anchor="nw", tag="text",
text=self.weekday_text, font=self.weekday_font, fill=self.foreground)
self._text_ids = [t_id, d_id, w_id]
else:
canvas.itemconfig(t_id, text=self.time_text, fill=self.foreground)
canvas.itemconfig(d_id, text=self.date_text, fill=self.foreground)
canvas.itemconfig(w_id, text=self.weekday_text, fill=self.foreground)
if not t_pos or not d_pos or not w_pos:
height = 0
tmp = []
for i in t_id, d_id, w_id:
coords = canvas.bbox(i)
height += coords[3] - coords[1]
x = (self.width / 2) - ((coords[2] - coords[0]) / 2)
tmp += [x]
t_x, d_x, w_x = tmp
margin = ((self.height - height) / 2) - self.td_offset
t_box = canvas.bbox(t_id)
d_box = canvas.bbox(d_id)
t_y, d_y = margin, (margin + (t_box[3] - t_box[1]))
w_y = d_y + (d_box[3] - d_box[1]) + self.dw_offset
self._text_pos = [[t_x, t_y], [d_x, d_y], [w_x, w_y]]
canvas.move(t_id, t_x, t_y)
canvas.move(d_id, d_x, d_y)
canvas.move(w_id, w_x, w_y)
elif is_new_day:
w_coords = canvas.bbox(w_id)
w_x_old = w_coords[0]
w_x = (self.width / 2) - ((w_coords[2] - w_coords[0]) / 2)
canvas.move(w_id, w_x - w_x_old, 0)
def reset(self): #{{{2
self._image_ids = [None, None, None]
self._text_ids = [None, None, None]
self._text_pos = [None, None, None]
self._rect = None
self.delete("all")
def mainloop(self): #{{{2
self.running = True
self.master.protocol("WM_DELETE_WINDOW", self.stop)
atexit.register(self.stop)
# ensure the seconds display is precise
start_datetime = datetime.datetime.now()
while start_datetime.second == datetime.datetime.now().second:
time.sleep(0.05)
self.master.wm_deiconify()
self.tick()
time.sleep(0.1)
while self.running:
self.tick()
time.sleep(0.1)
def stop(self, _=None): #{{{2
self.running = False
if self.ntp_thread:
self.ntp_thread.stop()
class NTPThread(threading.Thread): #{{{1
server: str
offset: Optional[float]
last_response: Optional["ntplib.NTPStats"] # type: ignore
def __init__(self, *, server: str, name=None): #{{{2
import ntplib # type: ignore # pip3 install ntplib; apt install python3-ntplib
super().__init__(group=None, name=name, daemon=True)
self.server = server
self.offset = self.last_response = None
self._running = False
def run(self): #{{{2
global _INTERRUPT_EXIT_CODE
import ntplib # pip3 install ntplib; apt install python3-ntplib
success_wait = 60 * 60 * 6
fail_wait = 60 * 5
retry_wait_base = 2
max_retries = retries = 2
client = ntplib.NTPClient()
first = True
self._running = True
while self._running:
try:
try:
response = client.request(self.server, version=3)
except ntplib.NTPException:
if retries > 0:
retries -= 1
time.sleep(retry_wait_base * (max_retries - retries + 1))
continue
else:
print(f"Error getting the time from {self.server} after {max_retries+1}"
f" {'tries' if max_retries else 'try'}:", file=sys.stderr)
if first:
_INTERRUPT_EXIT_CODE = 123 # NTP port number
raise
else:
traceback.print_exc(file=sys.stderr)
print("", file=sys.stderr)
retries = max_retries
if self._running:
time.sleep(fail_wait)
except KeyboardInterrupt:
self._running = False
except Exception:
raise
else:
self.offset = response.offset # type: ignore
self.last_response = response # type: ignore
first = False
retries = max_retries
if self._running:
time.sleep(success_wait)
except Exception:
self._running = False
traceback.print_exc(file=sys.stderr)
try:
_INTERRUPT_EXIT_CODE
except NameError:
pass
else:
_INTERRUPT_EXIT_CODE = _INTERRUPT_EXIT_CODE or 1
_thread.interrupt_main()
def stop(self): #{{{2
self._running = False
def main(argv): #{{{1
class ReprAutoInt(int):
def __repr__(self):
return "auto"
def __str__(self):
return "auto"
prog = os.path.basename(argv[0])
prog = prog if prog != "__main__.py" else "ora"
p = argparse.ArgumentParser(
prog=prog,
formatter_class=argparse.ArgumentDefaultsHelpFormatter
)
p.add_argument("-P", "--profile", default=None,
help="use a profile (defined as a section in the config file)")
p.add_argument("-w", "--width", type=int, default=1280,
help="width in pixels")
p.add_argument("-H", "--height", type=int, default=720,
help="width in pixels")
p.add_argument("-f", "--fullscreen", action="store_true",
help="launch in fullscreen mode")
p.add_argument("-bg", "--background", default="#000", metavar="#RRGGBB",
help="background color")
p.add_argument("-i", "--background-image", default=None,
help="background image")
p.add_argument("-io", "--image-opacity", type=float, default=None,
help="background image opacity (overrides --image-opacity-{light,dark})"
" (range: [0, 1])")
p.add_argument("-io-l", "--image-opacity-light", type=float, default=1,
help="background image opacity during daytime hours (range: [0, 1])")
p.add_argument("-io-d", "--image-opacity-dark", type=float, default=1,
help="background image opacity during nighttime hours (range: [0, 1])")
p.add_argument("-fg", "--foreground", default=None, metavar="#RRGGBB",
help="foreground color (overrides --foreground-{light,dark})")
p.add_argument("-fg-l", "--foreground-light", default="#fff", metavar="#RRGGBB",
help="foreground color during daytime hours")
p.add_argument("-fg-d", "--foreground-dark", default="#666", metavar="#RRGGBB",
help="foreground color during nighttime hours")
p.add_argument("-F", "--font-family", default="sans-serif",
help="the font family to use")
p.add_argument("-s", "--base-size", type=int, default=ReprAutoInt(0),
help="the base font size")
p.add_argument("-sr", "--sunrise", default="07:00", metavar="HH:MM",
help="sunrise time (add `!` to override location's sunrise time)")
p.add_argument("-ss", "--sunset", default="19:00", metavar="HH:MM",
help="sunset time (add `!` to override location's sunset time)")
p.add_argument("-l", "--location", default=None,
help="location to use for sunrise/sunset"
" (format: `{location name} [/ {elevation (meters)}]`"
" or `{latitude}, {longitude} [/ {elevation (meters)}]`)")
p.add_argument("-L", "--list-locations", action="store_true",
help="list the supported location names and exit")
p.add_argument("--ntp", default=None,
help="if set, use the given NTP server to correct for an inaccurate"
" system time")
p.add_argument("--no-keybindings", dest="keybindings", action="store_false",
help="disable keybindings for fullscreen (f, F11) and quit (q)"
" (does not affect default OS/WM keybindings)")
p.add_argument("--chassis", default=None,
help="override the detected chassis type"
" (see <https://www.freedesktop.org/software/systemd/man/machine-info.html#CHASSIS=> for valid values)")
p.add_argument("--kiosk", action="store_true",
help="equivalent to `--fullscreen --no-keybindings`")
p.add_argument("--install-xdg", action="store_true",
help="installs a .desktop file and SVG icon to ~/.local/share/")
p.add_argument("--easter-eggs", action="store_true",
help=argparse.SUPPRESS)
p.add_argument("--hep", dest="_hep_easter_egg", action="store_true",
help=argparse.SUPPRESS)
# don't use config values for defaults in help output
try:
args = p.parse_args(argv[1:])
except SystemExit as exc:
return exc.code
config = Config()
p.set_defaults(**config.main)
try:
args = p.parse_args(argv[1:])
except SystemExit as exc:
return exc.code
if args.profile:
try:
if args.profile not in config.profiles:
p.error("no profile named {%s}" % repr(args.profile))
if "*" in config.profiles:
p.set_defaults(**config.profiles["*"])
p.set_defaults(**config.profiles[args.profile])
args = p.parse_args(argv[1:])
except SystemExit as exc:
return exc.code
options = args.__dict__.copy()
chassis = options["chassis"] = args.chassis or get_chassis()
if args._hep_easter_egg:
print("Hep! Hep! I'm covered in sawlder! ... Eh? Nobody comes.")
print("--Red Green, https://www.youtube.com/watch?v=qVeQWtVzkAQ#t=6m27s")
return 0
if args.list_locations:
print_astral_locations()
return 0
if args.install_xdg:
try:
install_xdg(argv0=argv[0], prog=prog)
return 0
except RuntimeError as exc:
print(f"{prog}: error: {exc}", file=sys.stderr)
return 1
if options["kiosk"] or is_mobile(chassis):
options["fullscreen"] = True
options["keybindings"] = False
for base_key in ("foreground", "image_opacity"):
base_value = options.get(base_key, None)
if base_value is not None:
for i in ("light", "dark"):
key = "%s_%s" % (base_key, i)
options[key] = base_value
if isinstance(options.get("background_image", None), str):
options["background_image"] = os.path.expanduser(options["background_image"])
icon_gif = r"R0lGODlhYABgAPMAAAAAAF1dYl1dY15cYl5dYl9dYl5cY15dY19dY15eYl5eY11dZF5dZF9dZMzMzAAAACH5BAEAAAAALAAAAABgAGAAAAT+EMhJq7046827/2AojmRpnmiqrmzrvnAsn4dx3PjNHHOPLbmgMGjwyQjDpPJWNKoQu6VUmXCWptgpwQpKZL/SJlejAJuVijEmcG4v1RS3XAmPzu/BBneAd/jxOU42fX+AOj6DhA6GgTKJioyNL5EHfouUBwgvdoaWmDd6LZienzwsn6SlK2yjhaVpKaWpqiilla62J16yuKULJra3l8GmIwLBs7aaI8TJuSIIzb3BIsTC1jjV0sPWIcfb2Icf2M7U49blzx1A6NPBoRzh6eob8u7m8eT39Br23Ngdyuj7Z01MBk7I9vG7EO5awxv5Bj6EWM/fxGIZGs5baEGjpY/AIEN+nBOxnciTIUlWHIiyJUEwJS+G44BQJrGYNt91yAlwJ8+bPn9yZCh0qIVHRRnBw5kUD4imlJ5CNbTs3NQ72q6q7KJVzlKrXc2QiBYWTBUSZWGWYJdWSlW0baXEiptEBd26du9KyqvXRQO9BV7woRuj5lUBM9J+hUH2KgMnjZu+9fG3aWA1RWHBASAw5+YKNj8TbWhQNGhrW0yvxLRYtQYkgEq7ltqm9ewSBo59u/FN9u3fwIMLH068uPHjoiMAADs="
app = Application(options, master=create_master(icon_gif=icon_gif))
app.mainloop()
class Config: #{{{1
def __init__(
self,
path=os.path.join(os.environ.get("XDG_CONFIG_HOME", "~/.config"), "ora.conf"),
):
self.main = main = {}
self.profiles = profiles = {}
self.path = path = os.path.expanduser(path)
if os.path.isfile(path):
with open(path, "r") as f:
s = "[__main]\n" + f.read()
c = configparser.ConfigParser(allow_no_value=True)
c.read_string(s)
for section in c:
if section == "__main":
result_part = main
elif section != "DEFAULT":
profiles[section] = {}
result_part = profiles[section]
for key, value in c[section].items():
key = key.lower().replace("-", "_")
if value is None:
if key.startswith("no_"):
key = key[3:]
value = False
else:
value = True
elif isinstance(value, str) and value.lower() in ("false", "true"):
value = bool(("false", "true").index(value.lower()))
elif isinstance(value, str):
value = value.replace("\r\n", "\n").replace("\r", "\n").replace("\n", " ")
value = value.strip()
result_part[key] = value
def create_master(class_=None, icon_gif=None): #{{{1
try:
import Xlib.display # type: ignore # pip3 install python-xlib; apt install python3-xlib
except ImportError:
class Xlib:
display = None
try:
from setproctitle import setproctitle # type: ignore # pip3 install setproctitle; apt install python3-setproctitle
except ImportError:
def setproctitle(s: str):
print("warning: cannot set process title: setproctitle is not installed",
file=sys.stderr)
print("hint: run `pip3 install setproctitle` or `apt install python3-setproctitle`",
file=sys.stderr)
def set_pid(root):
if root.tk.eval("tk windowingsystem") == "x11":
if not Xlib.display:
print("warning: cannot set process ID on X window: python-xlib is not installed",
file=sys.stderr)
print("hint: run `pip3 install python-xlib` or `apt install python3-xlib`",
file=sys.stderr)
return
root.update()
display = Xlib.display.Display()
window = display.create_resource_object('window', root.winfo_id())
parent = window.query_tree().parent
_NET_WM_PID = lambda: display.intern_atom("_NET_WM_PID")
if not parent.get_full_property(_NET_WM_PID(), Xlib.Xatom.CARDINAL):
parent.change_property(_NET_WM_PID(), Xlib.Xatom.CARDINAL, 32, [os.getpid()])
window.change_property(_NET_WM_PID(), Xlib.Xatom.CARDINAL, 32, [os.getpid()])
class_ = class_ or os.path.basename(sys.argv[0])
root = tk.Tk(className=class_)
root.wm_withdraw()
if icon_gif:
root.wm_iconphoto(True, tk.PhotoImage(data=icon_gif))
setproctitle(os.path.basename(sys.argv[0]))
set_pid(root)
return root
def get_chassis(*, machine_info_path="/etc/machine-info") -> str: #{{{1
# See <https://www.freedesktop.org/software/systemd/man/machine-info.html#CHASSIS=>
# for a list of possible values.
if os.path.isfile(machine_info_path):
with open(machine_info_path, "r") as f:
machine_info = f.read()
match = re.search(r"""^\s*CHASSIS=["']?([^"']+)["']?\s*$""", machine_info, flags=re.M)
if match:
return match.group(1)
return "desktop"
def is_mobile(chassis: str) -> bool: #{{{1
return chassis in ("handset", "tablet", "watch")
def install_xdg(*, argv0: str, prog: str, data_dir="~/.local/share"): #{{{1
desktop_data = ("{{{2" * 0) + """
[Desktop Entry]
Name=Ora
GenericName=not-abomination clock
Categories=Utility;Clock;
Type=Application
Exec=__path__
Icon=ora
Terminal=false
StartupNotify=true
""".lstrip().replace("__path__", os.path.abspath(argv0))
svg_data = ("{{{2" * 0) + """
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<circle cx="12" cy="12" r="10.5" fill="#5e5d63" />
<g fill="none" stroke="#ccc" stroke-width="0.75">
<path d="M11.625,4 v8.6" />
<path d="M11.25,12.4 h5.75" />
</g>
</svg>
""".lstrip()
png_data = base64.b64decode(("{{{2" * 0) + """
iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAMAAADVRocKAAABm1BMVEUAAACAgIBVVVVAQIBVVVVtbW1gYGBVVVVmZmZVVWpiYmJbW1tmVWZgYGBdXWhZWWRgYGBcXGZiWGJeXl5bW2ReXmZaWmNgYGBdXWRiWmJeXmVcXGJgYGZdXWRdXWNgW2BeXmRcXGJgYGVcXGFgW2VeXmJfXGNeXmVdXWNeXmRcXGNeXmJdXWReXmRdXWNfXGJeXmNfXGJeXmRdXWNfXGJeXmNdXWJfXGReXmNfXWRfXGRdXWReXmJeXGNdXWJfXWNeXmJdXWRfXGNfXWJfXWReXWRfXWReXGNeXWNeXmNfXWReXWNeXmJfXWNeXGNdXWReXWNeXGNdXWReXmJfXWNfXWNeXGJdXWNeXWNfXWNeXWNfXWNeXWJfXWNeXWReXWNdXWNeXWReXGNeXGNeXWNeXmNeXWNdXWJfXWReXWNeXGNeXWNeXWNeXWNdXWNeXWNeXWNeXWNeXWNeXWNeXWNeXWNeXWNeXWNeXWNeXmNeXWNeXWNeXWNeXWReXWNeXWNeXWNeXWNeXWNeXWNeXWNeXWNpaW7BwcLMzMxnB1SwAAAAhXRSTlMAAgMEBgcICQoMDQ4PEBYXGBkaGxweHyAhIiYnKCksLS4vMDI1SUtMTU9QUVJUVVZaW1xdXl9gYWJmaWttb3BxcnN0jI+Sl5iam5ydnp+goaKjpKanqqusra+wsrO6u76/wMHExsfJys3Oz9HT1N3e3+Dh4uvs7e7v8PH09fb4+fr7/P3+vZiO0AAAAtNJREFUaN7tmmlfElEUh6+YEWqKFWhqpRXZallYUZmRGASyFGVlKItLGSbRwiIWxswpPnavZ+bOzF1OvuL/AZ7nN8Nd5pwDIZ10clgZmQpGCjul6sFBtbSTjwQvDSPCT84mv4EhXxP+Exh053SmBSZR1gN9kvgz8Z9gmf2lcQn8RKoFtlEyFwXxQ2kVmKK+cAvguwJ1YE59zsHLP/0BuLI1wse/WgXO7N3kwHcvAn/UEPNr6lk2g7TbbQvFipONf7wAYgLI9zPxP4GoALYZDM4CiAtg45gd3/EWZATwvttGYL1+7AUQsubfAFkBXLfieyvygprFnnbYnQ8sAtg033BzgCGAh2Z8dw1HsDdkIkgDjgCSdP55FUugTlAFbwBLAK9o/HEFT9AaowjigCeAmJHft48paPQaBPcBUwB+gyCHK1jT808puAJFv9nuAq4AZnSCJLYgqhOUsQVlXX0B2ALwagRT+AKfRvAUXzCvETzHF4Q1gjy+IKsRfMYXFAVWKZegpBFU8AXfNYImvuCXoOAvJQwC9ldEC8MrKuMLSiLL9A89DMs0D+jRbrQIvuCZRhDEFzwWOa554hO5cHjiEbkyOaK7MkkCW6C/9GexBbf0jT8Fl98y9KiyuIJVw7fpPVzBHYPA1cDkN1zGAmHpf64h1hKK+ScepRVpr/EEaWqVOali8dVz9EL5JZYgblLpD9Zw+NUBs2bFIxzBA/N2zhYGf6PLvGHkqcjza5ZN5svSK0m9Zt20C8kKFuzamsty/BXbBvaRNRl+7qh967d/W5z/kal57VoV5bM1xwnpETz23jG29wlxhARWq7rAM8e5wr3j6tN8Q5zhTc7zwcs95vL/4Dge+MdchJDBFOugLjEgOAs8m/rN0NvKXJCYZo7FbL5mGtFR+XGv6WMo64FejJGy+3bsC6WKjM64CV68vifhXHG30mxWdovZ8LzP0/kbQieHln9aTDzE2UkRJAAAAABJRU5ErkJggg==
""".strip())
#}}}2
if not os.path.isdir(os.path.expanduser(data_dir)):
raise RuntimeError("~/.local/share does not exist or is not a directory")
applications_dir = os.path.join(data_dir, "applications")
desktop_path = os.path.join(applications_dir, "ora.desktop")
print(f"{prog}: installing `{desktop_path}`")
os.makedirs(os.path.expanduser(applications_dir), mode=0o755, exist_ok=True)
with open(os.path.expanduser(desktop_path), "w") as f_str:
f_str.write(desktop_data)
icons_dir = os.path.join(data_dir, "icons", "hicolor", "scalable", "apps")
svg_path = os.path.join(icons_dir, "ora.svg")
print(f"{prog}: installing `{svg_path}`")
os.makedirs(os.path.expanduser(icons_dir), mode=0o755, exist_ok=True)
with open(os.path.expanduser(svg_path), "w") as f_str:
f_str.write(svg_data)
icons_dir = os.path.join(data_dir, "icons", "hicolor", "96x96", "apps")
png_path = os.path.join(icons_dir, "ora.png")
print(f"{prog}: installing `{png_path}`")
os.makedirs(os.path.expanduser(icons_dir), mode=0o755, exist_ok=True)
with open(os.path.expanduser(png_path), "wb") as f_bytes:
f_bytes.write(png_data)
def round_half_up(n): #{{{1
return int(decimal.Decimal(n).quantize(1, rounding=decimal.ROUND_HALF_UP))
def print_astral_locations(): #{{{1
if not TYPE_CHECKING:
import astral # pip3 install astral; apt install python3-astral
try:
import astral.geocoder
_astral_gte_v2 = True
except ImportError:
_astral_gte_v2 = False
else:
_astral_gte_v2 = ...
if _astral_gte_v2:
database = astral.geocoder.database()
else:
database = astral.AstralGeocoder().groups
for group_key in sorted(database.keys()):
group_info = database[group_key]
if group_key in ("utc", "us"):
print("# " + group_key.upper())
else:
print("# " + group_key.title())
for location_key in sorted(group_info.keys()):
loc_list = group_info[location_key]
if not _astral_gte_v2:
loc_list = [loc_list]
for loc_info in loc_list:
print("%s, %s (%f, %f)" % (
loc_info.name, loc_info.region,
loc_info.latitude, loc_info.longitude,
))
print()
# sunrise_sunset() #{{{1
SunriseSunsetTimeSpecInput = Union[str, "SunriseSunsetTimeSpec", datetime.time, None]
class SunriseSunsetTimeSpec(NamedTuple):
time: datetime.time
sticky: bool
@classmethod
def parse(cls, spec: SunriseSunsetTimeSpecInput) -> "SunriseSunsetTimeSpec":
time = datetime.time(0, 0, 0)
sticky = False
if spec is not None:
if isinstance(spec, cls):
time, sticky = spec
elif isinstance(spec, str):
sticky = "!" in spec
time = datetime.datetime.strptime(re.sub(r"!|\s+", "", spec), "%H:%M").time()
elif isinstance(spec, datetime.time):
sticky = False
time = spec
else:
raise TypeError("time must be of type `%s`, not `%s`"
% (str(cls), type(spec).__qualname__))
return cls(time, sticky)
def sunrise_sunset(
location: str,
*,
defaults: List[SunriseSunsetTimeSpecInput] = None,
date: datetime.date = None,
) -> List[datetime.time]:
"""Parse location and sunrise/sunset arguments, and return `datetime.time`s for sunrise/sunset at the location and/or the defaults."""
def _to_naïve_time(dt: datetime.datetime) -> datetime.time:
return dt.astimezone(None).time()
if defaults:
if len(defaults) != 2:
raise ValueError("defaults must be None, empty, or have exactly two values")
defaults = list(defaults[:])
else:
defaults = [SunriseSunsetTimeSpec.parse(None)] * 2
default_specs: List[SunriseSunsetTimeSpec] = []
default_specs += [SunriseSunsetTimeSpec.parse(defaults[0])] # sunrise
default_specs += [SunriseSunsetTimeSpec.parse(defaults[1])] # sunset
del defaults
default_times = [default_specs[0].time, default_specs[1].time]
sunrise_sticky, sunset_sticky = (default_specs[0].sticky, default_specs[1].sticky)
sunrise, sunset = default_times
if not len(location.strip()) or (sunrise_sticky and sunset_sticky):
return default_times
if not TYPE_CHECKING:
import astral # pip3 install astral; apt install python3-astral
try:
import astral.geocoder
import astral.sun
_astral_gte_v2 = True
except ImportError:
_astral_gte_v2 = False
else:
_astral_gte_v2 = ...
elevation = 0.0
if "/" in location:
location, elevation_str = location.split("/", 1)
elevation = float(elevation_str.strip())
location = re.sub(r"\s*,\s*", ",", location.strip())
lat_long: Optional[List[Union[float, str, None]]] = None
if len(location.split(",", 1)) == 2:
lat_long = []
lat_long_test = location.split(",", 1)
for i in lat_long_test:
is_float_loc = bool(re.search(r"^[+-]?[0-9]+(\.[0-9]*)?$", i))
is_str_loc = "\u00B0" in i # degree sign
if not is_float_loc and not is_str_loc:
lat_long = None
break
if is_float_loc:
lat_long += [float(i)]
else:
lat_long += [re.sub(r"""\s*(\u00B0|'|")\s*""", r"\1", i)]
if _astral_gte_v2:
if lat_long is None:
try:
loc_info = astral.geocoder.lookup(location, astral.geocoder.database()) # type: ignore
lat_long = [loc_info.latitude, loc_info.longitude]
except KeyError:
return default_times
observer = astral.Observer( # type: ignore
latitude=lat_long[0],
longitude=lat_long[1],
elevation=elevation,
)
try:
if not sunrise_sticky:
sunrise = _to_naïve_time(astral.sun.sunrise(observer, date=date)) # type: ignore
if not sunset_sticky:
sunset = _to_naïve_time(astral.sun.sunset(observer, date=date)) # type: ignore
except ValueError:
return default_times
else:
if lat_long is None:
try:
loc_obj = astral.AstralGeocoder()[location] # type: ignore
except KeyError:
return default_times
else:
loc_obj = astral.Location(( # type: ignore
"", "", # city, region
lat_long[0], lat_long[1],
"UTC",
elevation,
))
try:
if not sunrise_sticky:
sunrise = _to_naïve_time(loc_obj.sunrise(date=date, local=False)) # type: ignore
if not sunset_sticky:
sunset = _to_naïve_time(loc_obj.sunset(date=date, local=False)) # type: ignore
except astral.AstralError: # type: ignore
return default_times
return [sunrise, sunset]
if __name__ == "__main__": # {{{1
try:
sys.exit(main(sys.argv))
except KeyboardInterrupt:
sys.exit(_INTERRUPT_EXIT_CODE)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment