Last active
August 24, 2020 03:08
-
-
Save deanishe/ce442c3a768adedc9c39 to your computer and use it in GitHub Desktop.
Automatically toggle Alfred dark/light themes at sunset/sunrise. You need to edit the location settings before use.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/bash -e | |
# Wrapper for the toggle_alfred_theme.py script at | |
# https://gist.github.com/deanishe/ce442c3a768adedc9c39 | |
# (where this script also comes from) | |
# | |
# The purpose of this wrapper is to enable you to update the Python script | |
# without having to edit the script to change the settings each time. You keep | |
# them in here instead, and this script should hopefully prove dumb enough | |
# to require little updating... | |
# | |
# This script assumes it is in the same directory as toggle_alfred_theme.py. | |
# If it isn't, set PYTHON_SCRIPT_PATH below to the full path of | |
# toggle_alfred_theme.py | |
PYTHON_SCRIPT_PATH= | |
# --------------------------------------------------------- | |
# Location | |
# Change these settings to match your location. You can get the | |
# latitude, longitude and elevation from the Wikipedia page for | |
# your town/city. | |
# TZ_NAME must be one of the timezones understood by `pytz` | |
# Run this script with the option --timezones to see a list. | |
export CITY=Essen | |
export COUNTRY=Germany | |
export LATITUDE=51.450833 | |
export LONGITUDE=7.013056 | |
export TZ_NAME=Europe/Berlin | |
export ELEVATION=116 # In metres! | |
# --------------------------------------------------------- | |
# Default themes | |
# You can override these settings on the command line with the | |
# --light and --dark options | |
export LIGHT_THEME="Alfred" | |
export DARK_THEME="Alfred Dark" | |
# Python interpreter to run toggle_alfred_theme.py with (when called from the | |
# Launch Agent). If you want to use a Homebrew Python, change this to | |
# `/usr/local/bin/python` | |
# When you install the script, ensure it works with this Python interpreter. | |
export PYTHON_INTERPRETER=/usr/bin/python | |
# Pause after reloading the Launch Agent. If the script appears | |
# to be constantly running on your machine, increase this number | |
export PAUSE_AFTER_RELOAD=3 | |
# Set to 1 to capture STDOUT/STDERR of Launch Agent and set | |
# DEBUG logging level. The output of STDOUT & STDERR are saved to | |
# net.deanishe.alfred-toggle-theme.launch-agent.stdXXX.log files | |
# in ~/Library/Logs, alongside the log file. | |
export DEBUG=0 | |
########################################################### | |
# Don't edit below unless you know what you're doing | |
########################################################### | |
# Assume toggle_alfred_theme.py is in the same directory as this script | |
if [[ -z "${PYTHON_SCRIPT_PATH}" ]]; then | |
PYTHON_SCRIPT_PATH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/toggle_alfred_theme.py" | |
fi | |
"${PYTHON_INTERPRETER}" "${PYTHON_SCRIPT_PATH}" "$@" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/python | |
# encoding: utf-8 | |
# | |
# Copyright (c) 2015 [email protected] | |
# | |
# MIT Licence. See http://opensource.org/licenses/MIT | |
# | |
# Created on 2015-02-03 | |
# | |
from __future__ import print_function, unicode_literals, absolute_import | |
import pytz | |
from astral import Location | |
import time | |
import sys | |
import subprocess | |
import stat | |
import plistlib | |
import os | |
import logging.handlers | |
import logging | |
import getopt | |
from datetime import datetime, timedelta | |
from contextlib import contextmanager | |
""" | |
toggle_alfred_theme.py [-v|-q] [-d|--dark <theme>] [-l|--light <theme>] | |
**You must first edit this script (or the accompanying bash wrapper) | |
to set your location!** | |
These scripts (Python script and bash wrapper) live at: | |
https://gist.github.com/deanishe/ce442c3a768adedc9c39 | |
Change Alfred's theme depending on whether it's dark outside. Theme | |
is changed immediately when the script is run and the script will | |
also call itself again at sunrise and sunset (via launchd) to change | |
Alfred's theme. It's works well together with F.lux, which can | |
switch to Yosemite's dark mode at sunset. | |
Just run the script *once* with your preferred themes: | |
python toggle_alfred_theme.py --dark 'Dark Theme' --light 'Light Theme' | |
or if you're using the wrapper: | |
toggle-alfred-theme.bash --dark 'Dark Theme' --light 'Light Theme' | |
and it will ensure Alfred's theme is changed every day at sunrise | |
and sunset. | |
To change your preferred themes, just run the script again. | |
Note: Because the script calls itself via launchd, if you move the | |
script, it will stop working until you run it again. | |
Usage: | |
toggle_alfred_theme.py (-h|--help) | |
toggle_alfred_theme.py --timezones | |
toggle_alfred_theme.py (-t|--times) | |
toggle_alfred_theme.py [-n] [-v|-q] [--dark <theme>] [--light <theme>] | |
Options: | |
-h, --help Show this help message | |
-n, --nothing Show what would be set, but make no changes | |
-t, --times Show sunrise and sunset times for next 7 days | |
--timezones Show a list of (>500) supported timezones | |
-l, --light <theme> Alfred theme to use after sunrise | |
-d, --dark <theme> Alfred theme to use after sunset | |
-v, --verbose Show debugging info | |
-q, --quiet Only show warnings and errors | |
Installation & Setup: | |
This script requires the `astral` and `pytz` libraries. Install with: | |
pip install astral | |
It's better to install them in the same directory as this script (or | |
use a virtualenv), in order not to muck up your Python installation | |
or break other software: | |
pip install --target=/directory/where/this/script/is astral | |
Adjust the settings at the top of this script in the CONFIG section | |
(or in the bash wrapper) to match your location. | |
`TZ_NAME` must be one of the timezones recognised by `pytz`. To see | |
a list of all supported timezones, run this script with the | |
--timezones option. (Note there are over 500 timezones.) | |
You can usually find your town's latitude, longitude and elevation | |
on its Wikipedia page. | |
How it works: | |
When run, the script will immediately set Alfred's theme according | |
to whether it's light or dark out, then tell OS X to run the script | |
again at the next sunset/sunrise. Even if your computer is off/asleep | |
when the script is supposed to run, it will be run immediately on | |
boot/wake. | |
Note: Yosemite has some issues with running LaunchAgents on wake. If | |
the script isn't running when it's supposed to on Yosemite, but the | |
script reports the correct times, it's a problem with Yosemite, not | |
this script. | |
The script has to fork into the background (i.e. exit successfully | |
immediately) because `launchctl` doesn't like the script updating | |
the Launch Agent while it's running it. | |
""" | |
# .8888b oo | |
# 88 " | |
# .d8888b. .d8888b. 88d888b. 88aaa dP .d8888b. | |
# 88' `"" 88' `88 88' `88 88 88 88' `88 | |
# 88. ... 88. .88 88 88 88 88 88. .88 | |
# `88888P' `88888P' dP dP dP dP `8888P88 | |
# .88 | |
# d8888P | |
# | |
# Change these settings to match your location. You can get the | |
# latitude, longitude and elevation from the Wikipedia page for | |
# your town/city. | |
# TZ_NAME must be one of the timezones understood by `pytz` | |
# Run this script with the option --timezones to see a list. | |
CITY = 'Essen' | |
COUNTRY = 'Germany' | |
LATITUDE = 51.450833 | |
LONGITUDE = 7.013056 | |
TZ_NAME = 'Europe/Berlin' | |
ELEVATION = 116 # In metres! | |
# --------------------------------------------------------- | |
# You probably don't need to touch the settings below | |
# Default light and dark Alfred themes. The light theme will be set at | |
# sunrise, the dark theme at sunset The default settings ('Light' and | |
# 'Dark') are built-in Alfred themes. | |
# | |
# There is no real need to use these options: you can specify the | |
# themes on the command line with -l and -d and those choices will | |
# be preserved. These are just to ensure the script works even if | |
# you don't specify a theme on the command line. | |
LIGHT_THEME = 'Alfred' | |
DARK_THEME = 'Alfred Dark' | |
# Python interpreter to run this script with (when called from the | |
# Launch Agent). If you want to use a Homebrew Python, change this to | |
# `/usr/local/bin/python` | |
PYTHON_INTERPRETER = '/usr/bin/python' | |
# Seconds to wait after reloading the Launch Agent. If the script | |
# appears to be constantly running on your machine, increase this number | |
PAUSE_AFTER_RELOAD = 3 # seconds | |
# Set to `True` to capture STDOUT/STDERR of Launch Agent and set | |
# DEBUG logging level. The output of STDOUT & STDERR are saved to | |
# net.deanishe.alfred-toggle-theme.stdXXX.log files | |
# in ~/Library/Logs, alongside the log file. | |
DEBUG = False | |
# dP | |
# 88 | |
# .d8888b. .d8888b. .d888b88 .d8888b. | |
# 88' `"" 88' `88 88' `88 88ooood8 | |
# 88. ... 88. .88 88. .88 88. ... | |
# `88888P' `88888P' `88888P8 `88888P' | |
################################################################## | |
# DON'T CHANGE ANYTHING BELOW UNLESS YOU KNOW WHAT YOU'RE DOING! | |
################################################################## | |
# The Launch Agent that'll be saved in ~/Library/LaunchAgents. The | |
# script uses the Launch Agent to call itself at sunrise/-set to change | |
# the theme. A great feature of launchd is that if the computer is | |
# off/asleep when an agent is supposed to run, it'll run the agent on | |
# boot/wake instead. | |
LAUNCH_AGENT_NAME = 'net.deanishe.alfred-toggle-theme' | |
LAUNCH_AGENT_PATH = os.path.expanduser( | |
'~/Library/LaunchAgents/' + LAUNCH_AGENT_NAME + '.plist') | |
PID_FILE = '/tmp/' + LAUNCH_AGENT_NAME + '.pid' | |
# AppleScript to tell Alfred to switch theme | |
AS_SCRIPT = ('tell application id "com.runningwithcrayons.Alfred"' | |
' to set theme "{theme}"') | |
THIS_SCRIPT = os.path.abspath(__file__) | |
# Default parameters to save to the Launch Agent .plist | |
LAUNCH_AGENT = { | |
'Label': 'Toggle Alfred theme at sunrise and sunset', | |
'ProgramArguments': [], | |
'StartCalendarInterval': {}, | |
'EnvironmentVariables': {}, | |
'RunAtLoad': True, | |
} | |
# dP oo | |
# 88 | |
# 88 .d8888b. .d8888b. .d8888b. dP 88d888b. .d8888b. | |
# 88 88' `88 88' `88 88' `88 88 88' `88 88' `88 | |
# 88 88. .88 88. .88 88. .88 88 88 88 88. .88 | |
# dP `88888P' `8888P88 `8888P88 dP dP dP `8888P88 | |
# .88 .88 .88 | |
# d8888P d8888P d8888P | |
log = logging.getLogger('alfred-theme') | |
logging.basicConfig(format='%(levelname)-8s %(message)s') | |
log.setLevel(logging.INFO) | |
logdir = os.path.expanduser('~/Library/Logs') | |
LOG_PATH = os.path.join(logdir, LAUNCH_AGENT_NAME + '.log') | |
logfile = logging.handlers.RotatingFileHandler( | |
LOG_PATH, | |
maxBytes=1024**2, | |
backupCount=1, | |
encoding='utf-8') | |
logfile.setFormatter(logging.Formatter( | |
'%(asctime)s %(levelname)-8s [%(name)s] %(message)s', | |
datefmt='%d/%m %H:%M:%S')) | |
logging.getLogger().addHandler(logfile) | |
# .d8888b. 88d888b. dP .dP dP .dP .d8888b. 88d888b. .d8888b. | |
# 88ooood8 88' `88 88 d8' 88 d8' 88' `88 88' `88 Y8ooooo. | |
# 88. ... 88 88 88 .88' 88 .88' 88. .88 88 88 | |
# `88888P' dP dP 8888P' 8888P' `88888P8 dP `88888P' | |
TRUE_STRINGS = ('1', 'true', 'yes', 'on', 'ja', 'oui') | |
FALSE_STRINGS = ('0', 'false', 'no', 'off', 'nein', 'non') | |
# Override script settings with environmental variables | |
CITY = os.getenv('CITY', CITY) | |
COUNTRY = os.getenv('COUNTRY', COUNTRY) | |
LATITUDE = float(os.getenv('LATITUDE', LATITUDE)) | |
LONGITUDE = float(os.getenv('LONGITUDE', LONGITUDE)) | |
TZ_NAME = os.getenv('TZ_NAME', TZ_NAME) | |
ELEVATION = int(os.getenv('ELEVATION', ELEVATION)) | |
LIGHT_THEME = os.getenv('LIGHT_THEME', LIGHT_THEME) | |
DARK_THEME = os.getenv('DARK_THEME', DARK_THEME) | |
PYTHON_INTERPRETER = os.getenv('PYTHON_INTERPRETER', PYTHON_INTERPRETER) | |
PAUSE_AFTER_RELOAD = int(os.getenv('PAUSE_AFTER_RELOAD', PAUSE_AFTER_RELOAD)) | |
env_debug = os.getenv('DEBUG', None) | |
if env_debug is not None: | |
if env_debug.lower() in TRUE_STRINGS: | |
DEBUG = True | |
elif env_debug.lower() in FALSE_STRINGS: | |
DEBUG = False | |
else: # wtf did they enter? | |
log.warning('invalid DEBUG value: ', env_debug) | |
# dP dP oo | |
# 88 88 | |
# .d888b88 .d8888b. 88d888b. dP dP .d8888b. .d8888b. dP 88d888b. .d8888b. | |
# 88' `88 88ooood8 88' `88 88 88 88' `88 88' `88 88 88' `88 88' `88 | |
# 88. .88 88. ... 88. .88 88. .88 88. .88 88. .88 88 88 88 88. .88 | |
# `88888P8 `88888P' 88Y8888' `88888P' `8888P88 `8888P88 dP dP dP `8888P88 | |
# .88 .88 .88 | |
# d8888P d8888P d8888P | |
if DEBUG: | |
# Also capture STDOUT/STDERR of Launch Agent and save it to | |
# ~/Library/Logs | |
STDOUT_PATH = os.path.join(logdir, LAUNCH_AGENT_NAME + '.stdout.log') | |
STDERR_PATH = os.path.join(logdir, LAUNCH_AGENT_NAME + '.stderr.log') | |
# if os.path.exists(LOG_PATH): | |
# os.chmod(LOG_PATH, stat.S_IRUSR | stat.S_IWUSR) | |
log.setLevel(logging.DEBUG) | |
# Capture STDOUT/STDERR from Launch Agent | |
LAUNCH_AGENT['StandardOutPath'] = STDOUT_PATH | |
LAUNCH_AGENT['StandardErrorPath'] = STDERR_PATH | |
# Helpful with debugging, but adds a lot of complexity. | |
# Should probably remove this. | |
# LAUNCH_AGENT['RunAtLoad'] = True | |
# dP dP | |
# 88 88 | |
# 88d888b. .d8888b. 88 88d888b. .d8888b. 88d888b. .d8888b. | |
# 88' `88 88ooood8 88 88' `88 88ooood8 88' `88 Y8ooooo. | |
# 88 88 88. ... 88 88. .88 88. ... 88 88 | |
# dP dP `88888P' dP 88Y888P' `88888P' dP `88888P' | |
# 88 | |
# dP | |
class Options(dict): | |
"""Access dict keys as attributes.""" | |
def __getattr__(self, attr): | |
if attr in self: | |
return self[attr] | |
else: | |
raise AttributeError(repr(attr)) | |
# --------------------------------------------------------- | |
# Processes | |
def daemonise(stdin='/dev/null', stdout='/dev/null', stderr='/dev/null'): | |
"""Fork the current process into a background daemon. | |
:param stdin: where to read input | |
:type stdin: filepath | |
:param stdout: where to write stdout output | |
:type stdout: filepath | |
:param stderr: where to write stderr output | |
:type stderr: filepath | |
""" | |
# Do first fork. | |
try: | |
pid = os.fork() | |
if pid > 0: | |
os._exit(0) # Exit first parent. | |
except OSError as e: | |
log.critical('fork #1 failed: (%s) %s', e.errno, e.strerror) | |
sys.exit(1) | |
# Decouple from parent environment. | |
os.chdir(os.path.dirname(THIS_SCRIPT)) | |
os.umask(0) | |
os.setsid() | |
# Do second fork. | |
try: | |
pid = os.fork() | |
if pid > 0: | |
os._exit(0) # Exit second parent. | |
except OSError as e: | |
log.critical('fork #2 failed: (%d) %s', e.errno, e.strerror) | |
sys.exit(1) | |
# Now I am a daemon! | |
# Redirect standard file descriptors. | |
si = open(stdin, 'r', 0) | |
so = open(stdout, 'a+', 0) | |
se = open(stderr, 'a+', 0) | |
if hasattr(sys.stdin, 'fileno'): | |
os.dup2(si.fileno(), sys.stdin.fileno()) | |
if hasattr(sys.stdout, 'fileno'): | |
os.dup2(so.fileno(), sys.stdout.fileno()) | |
if hasattr(sys.stderr, 'fileno'): | |
os.dup2(se.fileno(), sys.stderr.fileno()) | |
@contextmanager | |
def pidfile(path=PID_FILE): | |
"""Context manager to save the PID of the current process to `path`. | |
Automatically deletes `path` on `__exit__` | |
""" | |
with open(path, 'wb') as fp: | |
fp.write(str(os.getpid())) | |
log.debug('wrote PID (%s) file : %s', os.getpid(), path) | |
yield | |
if os.path.exists(pidfile): | |
os.unlink(pidfile) | |
log.debug('deleted own PID file : %s', path) | |
def already_running(): | |
"""Return `True` if a copy of this script is already running.""" | |
if not os.path.exists(PID_FILE): | |
return False | |
with open(PID_FILE) as fp: | |
pid = fp.read().strip() | |
if not pid.isdigit(): | |
os.unlink(PID_FILE) | |
return False | |
pid = int(pid) | |
try: | |
os.kill(pid, 0) | |
except OSError: | |
if os.path.exists(PID_FILE): | |
os.unlink(PID_FILE) | |
log.debug('deleted stale PID file: %s', PID_FILE) | |
return False | |
log.debug('script already running with PID: %s', pid) | |
return True | |
# dP dP | |
# 88 88 | |
# .d8888b. dP. .dP d8888P .d8888b. 88d888b. 88d888b. .d8888b. 88 | |
# 88ooood8 `8bd8' 88 88ooood8 88' `88 88' `88 88' `88 88 | |
# 88. ... .d88b. 88 88. ... 88 88 88 88. .88 88 | |
# `88888P' dP' `dP dP `88888P' dP dP dP `88888P8 dP | |
def set_theme(theme): | |
"""Set Alfred theme to theme with name `theme`.""" | |
script = AS_SCRIPT.format(theme=theme) | |
retcode = subprocess.call([b'osascript', b'-e', script.encode('utf-8')]) | |
if retcode != 0: | |
log.critical('AppleScript command failed. ' | |
'osascript exited with %s', retcode) | |
else: | |
log.info('Alfred theme set to `%s`', theme) | |
return retcode | |
def update_launch_agent(dt, dark_theme, light_theme, verbose=False): | |
"""Rewrite the launch agent to run at datetime `dt`. | |
`dt` should be a local time, not UTC | |
- Unload Launch Agent | |
- Rewrite Launch Agent | |
- Load Launch Agent | |
""" | |
if os.path.exists(LAUNCH_AGENT_PATH): | |
retcode = subprocess.call(['launchctl', 'unload', LAUNCH_AGENT_PATH]) | |
if retcode != 0: | |
log.warning('unload Launch Agent. launchctl exited with %d', | |
retcode) | |
params = LAUNCH_AGENT.copy() | |
cmd = [PYTHON_INTERPRETER, THIS_SCRIPT, | |
'--dark', dark_theme, '--light', light_theme] | |
if verbose: | |
cmd.append('-v') | |
params['ProgramArguments'] = cmd | |
# Start date for launch agent | |
d = { | |
'Month': dt.month, | |
'Day': dt.day, | |
'Hour': dt.hour, | |
'Minute': dt.minute, | |
} | |
params['StartCalendarInterval'] = d | |
env = { | |
'CITY': CITY, | |
'COUNTRY': COUNTRY, | |
'LATITUDE': str(LATITUDE), | |
'LONGITUDE': str(LONGITUDE), | |
'TZ_NAME': TZ_NAME, | |
'ELEVATION': str(ELEVATION), | |
'PYTHON_INTERPRETER': PYTHON_INTERPRETER, | |
'PAUSE_AFTER_RELOAD': str(PAUSE_AFTER_RELOAD), | |
'DEBUG': ('0', '1')[DEBUG], | |
'LIGHT_THEME': LIGHT_THEME, | |
'DARK_THEME': DARK_THEME, | |
} | |
params['EnvironmentVariables'] = env | |
with open(LAUNCH_AGENT_PATH, 'wb') as fp: | |
plistlib.writePlist(params, fp) | |
# Ensure correct permissions | |
os.chmod(LAUNCH_AGENT_PATH, stat.S_IRUSR | stat.S_IWUSR) | |
retcode = subprocess.call(['launchctl', 'load', LAUNCH_AGENT_PATH]) | |
if retcode != 0: | |
log.critical('load Launch Agent. launchctl exited with %s', retcode) | |
else: | |
log.info('Updated and reloaded Launch Agent `%s`', LAUNCH_AGENT_PATH) | |
return retcode | |
# --------------------------------------------------------- | |
# Dates and times and sunrises and sunsets | |
def format_delta(delta): | |
"""Make a `datetime.timedelta` human readable.""" | |
output = [] | |
seconds = delta.seconds | |
minutes, seconds = divmod(seconds, 60) | |
hours, minutes = divmod(minutes, 60) | |
days, hours = divmod(hours, 24) | |
# I think the next sunrise/-set can be more than 24 hours away in | |
# Alaska and Iceland... | |
if days > 0: | |
output.append('{} day{}'.format(days, 's'[days == 1:])) | |
if hours > 0: | |
output.append('{} hour{}'.format(hours, 's'[hours == 1:])) | |
if minutes > 0: | |
output.append('{} minute{}'.format(minutes, 's'[minutes == 1:])) | |
return ', '.join(output) | |
def round_datetime(dt): | |
"""Remove seconds and microseconds from `datetime`. | |
Rounds down to minute | |
""" | |
delta = timedelta(seconds=dt.second, microseconds=dt.microsecond) | |
return dt - delta | |
def get_location(): | |
"""Return `astral.Location` for wherever you are.""" | |
return Location((CITY, COUNTRY, LATITUDE, LONGITUDE, TZ_NAME, | |
ELEVATION)) | |
def get_sunrise_sunset(days=7): | |
"""Return sunrise and sunset times for coming days. | |
Returns a list of `(sunrise, sunset)` tuples of `datetime` objects. | |
`datetimes` are in local timezone | |
""" | |
results = [] | |
location = get_location() | |
utc = pytz.utc | |
tz = pytz.timezone(TZ_NAME) | |
now = utc.localize(datetime.utcnow()) | |
day = now.astimezone(tz) | |
for i in range(days): | |
sunrise = location.sunrise(day, local=True) | |
sunset = location.sunset(day, local=True) | |
results.append((sunrise, sunset)) | |
day += timedelta(days=1) | |
return results | |
def next_change(): | |
"""Get UTC datetime of next sunset/sunrise. | |
Returns a tuple `(utc_dt, is_sunset)` | |
""" | |
location = get_location() | |
now = day = pytz.utc.localize(datetime.utcnow()) | |
while True: | |
sunrise = location.sunrise(day, local=False) | |
if sunrise > now: | |
return (sunrise, False) | |
sunset = location.sunset(day, local=False) | |
if sunset > now: | |
return (sunset, True) | |
day += timedelta(days=1) | |
def is_dark(utc_dt): | |
"""Return True if `utc_dt` is after sunset.""" | |
location = get_location() | |
sunset = round_datetime(location.sunset(utc_dt, local=False)) | |
sunrise = round_datetime(location.sunrise(utc_dt, local=False)) | |
if utc_dt >= sunrise and utc_dt < sunset: | |
log.debug(utc_dt.strftime('light at %Y-%m-%d %X %Z')) | |
return False | |
log.debug(utc_dt.strftime('dark at %Y-%m-%d %X %Z')) | |
return True | |
# dP oo | |
# 88 | |
# .d8888b. .d8888b. d8888P dP .d8888b. 88d888b. .d8888b. | |
# 88' `88 88' `"" 88 88 88' `88 88' `88 Y8ooooo. | |
# 88. .88 88. ... 88 88 88. .88 88 88 88 | |
# `88888P8 `88888P' dP dP `88888P' dP dP `88888P' | |
# --------------------------------------------------------- | |
# Parse command-line options | |
def get_options(args=None): | |
"""Returns (options, args). if `args` is `None`, uses `sys.argv[1:]`.""" | |
# Use getopt as argparse isn't available in Python 2.6 on Snow Leopard :( | |
# Use tuples instead of dict to preserve order. Otherwise, there's | |
# no way to know whether `times` or `timezones` will grab the | |
# -t option. | |
defaults = ( | |
('dark=', DARK_THEME), | |
('light=', LIGHT_THEME), | |
('help', False), | |
('verbose', False), | |
('quiet', False), | |
('nothing', False), | |
('times', False), | |
('timezones', False), | |
) | |
# Map options to formats, e.g. {'-d': 'dark=', '--dark': 'dark='} | |
opt_format_map = {} | |
opts_short = '' | |
# Do this all the long way round, so we know which long option | |
# 'won' the short option | |
for fmt, default in defaults: | |
name = fmt.rstrip('=') | |
opt_format_map['--' + name] = fmt | |
c = name[0] | |
if c not in opts_short: # Don't overwrite existing short option | |
opts_short += c | |
opt_format_map['-' + c] = fmt | |
if fmt.endswith('='): | |
opts_short += ':' | |
try: | |
opts, args = getopt.getopt(sys.argv[1:], | |
opts_short, | |
[t[0] for t in defaults]) | |
except getopt.GetoptError as err: | |
usage(str(err)) | |
sys.exit(2) | |
parsed = dict(opts) | |
# --------------------------------------------------------- | |
# Populate options | |
# Initialise with defaults | |
options = Options([(k.rstrip('='), v) for (k, v) in defaults]) | |
# Update options from CLI arguments | |
for opt in parsed: | |
fmt = opt_format_map.get(opt) | |
if not fmt: | |
log.error('Unknown option : %s', opt) | |
continue | |
name = fmt.rstrip('=') | |
value = parsed[opt] | |
# Set flag options to True | |
if not fmt.endswith('=') and value is not None: | |
value = True | |
options[name] = value | |
return (options, args) | |
# --------------------------------------------------------- | |
# Other script actions | |
def list_timezones(): | |
"""Print the names of all supported timezones.""" | |
for tzname in pytz.all_timezones: | |
print(tzname) | |
def list_times(): | |
"""Show sunrise and sunset times for the next 7 days.""" | |
print('Date Sunrise Sunset') | |
print('---------- --------- ---------') | |
for (sunrise, sunset) in get_sunrise_sunset(): | |
print(' '.join([sunrise.strftime('%Y-%m-%d'), | |
sunrise.strftime('%H:%M %Z'), | |
sunset.strftime('%H:%M %Z')])) | |
def usage(msg=None): | |
"""Show help.""" | |
if msg: | |
print(msg + '\n') | |
print(__doc__) | |
# --------------------------------------------------------- | |
# Main script entry point | |
def main(): | |
o, _ = get_options() | |
if o.verbose: | |
log.setLevel(logging.DEBUG) | |
elif o.quiet: | |
log.setLevel(logging.WARNING) | |
log.debug('options=%s', o) | |
# --------------------------------------------------------- | |
# Alternate actions | |
if o.help: | |
usage() | |
return 0 | |
elif o.timezones: | |
list_timezones() | |
return 0 | |
elif o.times: | |
list_times() | |
return 0 | |
# The script basically works by running itself via launchd, so to | |
# prevent endless loops, exit here in the case that the script was | |
# indirectly called by another running copy of itself. | |
if already_running(): | |
log.info('exiting: script is already running') | |
return 0 | |
# --------------------------------------------------------- | |
# Set Alfred's theme and load Launch Agent to run the script | |
# again at the next sunrise/-set | |
# UTC dates and times are used internally. Localtime is only | |
# used for user output and the Launch Agent | |
utc = pytz.utc | |
local_tz = pytz.timezone(TZ_NAME) | |
# Ensure `now` has UTC timezone | |
now = utc.localize(datetime.utcnow()) | |
# --------------------------------------------------------- | |
# Set theme right now | |
current_theme = o.light | |
if is_dark(now): | |
current_theme = o.dark | |
if not o.nothing: | |
log.info('light theme: %s', o.light) | |
log.info(' dark theme: %s', o.dark) | |
# There's no simple way to determine which theme Alfred is | |
# currently sporting, so just set theme regardless | |
set_theme(current_theme) | |
else: | |
print('light theme: ' + o.light) | |
print(' dark theme: ' + o.dark) | |
print('would set current theme: ' + current_theme) | |
# --------------------------------------------------------- | |
# Update Launch Agent to call script at next sunrise/-set | |
utc_dt, is_sunset = next_change() | |
local_dt = utc_dt.astimezone(local_tz) | |
delta = utc_dt - now | |
event = ('sunrise', 'sunset')[is_sunset] | |
next_theme = (o.light, o.dark)[is_sunset] | |
msg = 'next change: theme `{}` at {} in {} at {}'.format( | |
next_theme, | |
event, | |
format_delta(delta), | |
local_dt.strftime('%H:%M %Z')) | |
if o.nothing: | |
print(msg) | |
else: | |
log.info(msg) | |
retcode = 0 | |
if not o.nothing: # Update Launch Agent in background | |
log.debug('forking into background ...') | |
daemonise() | |
with pidfile(): | |
log.debug('[background] running in background') | |
# Wait for launchctl to finish loading the Launch Agent | |
time.sleep(2) | |
# Set script to run 1 minute after sunrise/-set just to | |
# be on the safe side | |
retcode = update_launch_agent(local_dt + timedelta(minutes=1), | |
o.dark, o.light, | |
o.verbose) | |
# Wait for a few seconds to give launchctl a chance to load | |
# the Launch Agent and (possibly) run this script (which | |
# will exit immediately). If this script exits before any | |
# "grandchild", an infinite loop may ensue... | |
log.debug('[background] pausing for %d seconds before exiting ...', | |
PAUSE_AFTER_RELOAD) | |
time.sleep(PAUSE_AFTER_RELOAD) | |
log.debug('[background] done ' + '-' * 32) | |
return retcode | |
if __name__ == '__main__': | |
sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment