text: #!/usr/bin/env python3.7
  #
  # pylint: disable=missing-docstring
  
  import argparse
  import logging
  import os
  import random
  import signal
  import sys
  import time
  
  from datetime import datetime
  from datetime import timedelta
  
  import RPi.GPIO as gpio
  import gevent
  import json
  import pytz
  import requests
  
  logging.basicConfig(format='%(asctime)s %(levelname)s[%(process)d]: %(message)s',
                      datefmt='%H:%M:%S',
                      level=logging.INFO)
  
  SLEEP_TIME = 60
  CONFIG_FILE = '/etc/lights.json'
  MANDATORY_FIELDS = {'ports', 'local_tz', 'latitude', 'longitude'}
  
  def to_set(obj):
    if isinstance(obj, int):
      return set([obj])  # Single item
    if not isinstance(obj, set):
      obj = set(obj)
    return obj
  
  
  class Config:
    _instance = None
    config_data = None
    def __new__(cls, *args, **kwargs):
      if cls._instance is None:
        cls._instance = super(Config, cls).__new__(cls)
        cls._instance.config_data = {}
      return cls._instance
  
    def __init__(self, config_file=CONFIG_FILE):
      if self.config_data:
        return
      logging.debug('Reading config file')
      if not os.path.exists(config_file):
        logging.error('Configuration file "%s" not found', config_file)
        sys.exit(os.EX_CONFIG)
  
      try:
        with open(CONFIG_FILE, 'r') as confd:
          lines = []
          for line in confd:
            line = line.strip()
            if not line or line.startswith('#'):
              continue
            lines.append(line)
          self.config_data = json.loads('\n'.join(lines))
      except ValueError as err:
        logging.error('Configuration error: "%s"', err)
        sys.exit(os.EX_CONFIG)
  
      missing_fields = self.config_data.keys() ^ MANDATORY_FIELDS
      if missing_fields != set():
        logging.error('Configuration keys "%s" are missing', missing_fields)
        sys.exit(os.EX_CONFIG)
  
    def __getattr__(self, attr):
      if attr not in self.config_data:
        raise AttributeError("'{}' object has no attribute '{}'".format(self.__class__, attr))
      return self.config_data[attr]
  
  
  class Sunset:
    __cache = {}
  
    def __init__(self, timez, lat, lon):
      now = datetime.now()
  
      if now.date() in Sunset.__cache:
        self._sun = self.__cache[now.date()]
        return
  
      params = dict(lat=lat, lng=lon, formatted=0,
                    date=now.strftime('%Y-%m-%d'))
      url = 'https://api.sunrise-sunset.org/json'
      try:
        resp = requests.get(url=url, params=params, timeout=(3, 10))
        data = resp.json()
      except Exception as err:
        logging.error(err)
        raise
  
      tzone = pytz.timezone(timez)
      self._sun = {}
      for key, val in data['results'].items():
        if key == 'day_length':
          self._sun[key] = val
        else:
          self._sun[key] = datetime.fromisoformat(val).astimezone(tzone)
  
      Sunset.__cache[now.date()] = self._sun
      return
  
    @property
    def sunset(self):
      return self._sun['sunset']
  
  
  class AllMatch(set):
    """Universal set - match everything"""
    def __contains__(self, item):
      return True
  
    def __repr__(self):
      return '{*}'
  
  ALLMATCH = AllMatch()
  
  
  class Event:
    """The Actual Event Class"""
    def __init__(self, action, minute=ALLMATCH, hour=ALLMATCH,
                 day=ALLMATCH, month=ALLMATCH, daysofweek=ALLMATCH,
                 **kwargs):
      self.mins = to_set(minute)
      self.hours = to_set(hour)
      self.days = to_set(day)
      self.months = to_set(month)
      self.daysofweek = to_set(daysofweek)
      self.action = action
      self.kwargs = kwargs
  
    def matchtime(self, tm1):
      """Return True if this event should trigger at the specified datetime"""
  
      logging.debug("%d:%r - %d:%r - %d:%r - %d:%r - %d:%r",
                    tm1.minute, self.mins,
                    tm1.hour, self.hours,
                    tm1.day, self.days,
                    tm1.month, self.months,
                    tm1.weekday(), self.daysofweek)
  
      return (tm1.minute in self.mins and
              tm1.hour in self.hours and
              tm1.day in self.days and
              tm1.month in self.months and
              tm1.weekday() in self.daysofweek)
  
    def check(self, tm1):
      """Check and run action if needed"""
      logging.debug('Event check %r', self)
      if self.matchtime(tm1):
        logging.debug('Match %r', tm1)
        self.action(**self.kwargs)
  
    def __eq__(self, other):
      return (self.action == other.action and
              self.mins == other.mins and
              self.hours == other.hours and
              self.days == other.days and
              self.months == other.months and
              self.daysofweek == other.daysofweek)
  
    def __repr__(self):
      _repr = "<{}> [{}] - mins:{!r} - hours:{!r} - days:{!r} - month:{!r} - weekdays:{!r}"
      return _repr.format(self.__class__.__name__, self.action.__name__, self.mins,
                          self.hours, self.days, self.months, self.daysofweek)
  
  class Task(Event):
    """Like an Event but only run once"""
    def __init__(self, *args, **kwargs):
      super(Task, self).__init__(*args, **kwargs)
      self.has_run = False
  
    def check(self, tm1):
      logging.debug('Task check %r', self)
      if self.has_run:
        logging.debug('%r has already run', self)
        return
      if self.matchtime(tm1):
        logging.debug('Match %r', tm1)
        self.has_run = True
        self.action(**self.kwargs)
  
  
  class CronTab:
    """The crontab implementation"""
    def __init__(self, *events):
      self.events = list()
      for event in events:
        self.append(event)
  
    def _check(self):
      """Check all events in separate greenlets"""
      tm1 = datetime(*datetime.now().timetuple()[:5])
      logging.debug('Time %r', tm1)
      for event in self.events:
        gevent.spawn(event.check, tm1)
  
      tm1 += timedelta(minutes=1)
      sec = (tm1 - datetime.now()).seconds + 1
      logging.debug('Next check in %d', sec)
      gevent.spawn_later(sec, self._check)
  
    def run(self):
      """Run the cron forever"""
      self._check()
      while True:
        gevent.sleep(SLEEP_TIME)
        garbage = [t for t in self.events if isinstance(t, Task) and t.has_run]
        for tsk in garbage:
          self.remove(tsk)
  
    def append(self, event):
      logging.info('CronTab add: %r', event)
      if event not in self.events:
        self.events.append(event)
      else:
        logging.warning('CronTab duplicate %r', event)
  
    def remove(self, event):
      try:
        pos = self.events.index(event)
        del self.events[pos]
        logging.info('CronTab remove: %r', event)
      except ValueError:
        logging.warning('CronTab not found %r', event)
  
  
  class Lights:
  
    def __init__(self, ports):
      self._ports = ports
      gpio.setwarnings(False)
      gpio.setmode(gpio.BCM)
      for port in self._ports:
        gpio.setup(port, gpio.OUT)
  
    def off(self, ports=None, sleep=0.5):
      if not ports:
        ports = self._ports
      for port in ports:
        if port in self._ports:
          gpio.output(port, gpio.HIGH)
          gevent.sleep(sleep)
      logging.info("%s", self)
  
    def on(self, ports=None, sleep=0.5):
      if not ports:
        ports = self._ports
      for port in ports:
        if port in self._ports:
          gpio.output(port, gpio.LOW)
          gevent.sleep(sleep)
      logging.info("%s", self)
  
    def random(self, ports=None, count=25, delay=0.15):
      logging.info('Random - count:%d', count)
      if not ports:
        ports = self._ports[:]
      for _ in range(count):
        port = random.choice(ports)
        gpio.output(port, gpio.LOW)
        gevent.sleep(delay)
        gpio.output(port, gpio.HIGH)
        gevent.sleep(delay)
  
    def __str__(self):
      status = self.status().items()
      return ', '.join([f"{k:02d}:{v}" for k, v in status])
  
    def status(self, ports=None):
      status = {}
      st_msg = {0: 'ON', 1: 'Off'}
      if not ports:
        ports = self._ports
      for port in ports:
        status[port] = st_msg[gpio.input(port)]
      return status
  
  
  def light_show(lights):
    lights.off()
  
    for _ in range(5):
      lights.on()
      gevent.sleep(.2)
      lights.off()
      gevent.sleep(.4)
  
    gevent.sleep(0.5)
    lights.random(count=64)
  
    for _ in range(5):
      lights.on()
      gevent.sleep(.2)
      lights.off()
      gevent.sleep(.4)
  
    gevent.sleep(0.5)
    lights.on()
  
  
  def check_status(lights, ports=None):
    status = lights.status(ports)
    logging.info(', '.join([f"{k:02d}:{v}" for k, v in status.items()]))
  
  
  def sig_dump():
    global cron
    logging.debug('Caught signal: SIGHUP')
    try:
      for event in cron.events:
        logging.info('%r', event)
    except NameError as err:
      logging.error(err)
  
  
  def add_sunset_task(cron, lights):
    config = Config()
    sun = Sunset(config.local_tz, config.latitude, config.longitude)
    logging.info('Sunset at: %s', sun.sunset.time())
    task = Task(lights.on, sun.sunset.minute, sun.sunset.hour)
    cron.append(task)
  
  
  def automation(lights):
    global cron
  
    cron = CronTab(
        Event(lights.off, 0, 22), # Turn off the lights at 10:10pm
        Event(lights.off, 0, 0), # Turn off the lights at midnight just in case
    )
    cron.append(Event(add_sunset_task, 0, (2, 8, 14, 20), cron=cron, lights=lights))
    add_sunset_task(cron, lights)
    cron.run()
  
  
  def main():
    config = Config()
    parser = argparse.ArgumentParser(description='Garden lights')
    on_off = parser.add_mutually_exclusive_group(required=True)
    on_off.add_argument('--off', nargs='*', type=int, help='Turn off all the lights')
    on_off.add_argument('--on', nargs='*', type=int, help='Turn on all the lights')
    on_off.add_argument('--status', nargs='*', type=int, help='Checkt the status of each port')
    on_off.add_argument('--random', type=int, help='Random sequence')
    on_off.add_argument('--light-show', action="store_true", help='Light show')
    on_off.add_argument('--cron', action="store_true", help='Automatic mode')
    pargs = parser.parse_args()
    gevent.signal_handler(signal.SIGHUP, sig_dump)
  
    lights = Lights(config.ports)
  
    if pargs.on is not None:
      lights.on(pargs.on)
    elif pargs.off is not None:
      lights.off(pargs.off)
    elif pargs.status is not None:
      check_status(lights, pargs.status)
    elif pargs.light_show:
      light_show(lights)
    elif pargs.cron:
      automation(lights)
    elif pargs.random:
      lights.random(count=pargs.random)
  
  if __name__ == "__main__":
    main()
  