Skip to content


Subversion checkout URL

You can clone with
Download ZIP
Fetching contributors…

Cannot retrieve contributors at this time

686 lines (551 sloc) 23.937 kB
# -*- coding: utf-8 -*-
#Canto - ncurses RSS reader
# Copyright (C) 2008 Jack Miller <>
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation.
# The Main object encompasses a running instance of Canto. It can be divided
# into a number of parts.
# init
# if called as canto-fetch jump to canto_fetch.main
# parse config
# parse canto specific arguments
# start work thread
# load the initial feed data (from thread)
# do one-off operation flags (like -a, -n, -o, -i, etc.)
# do basic curses init
# instantiate Gui class
# main loop
# check that Gui is still alive
# if we're waiting for a process, sleep
# check for input
# if no input, check on threads
# if work done, update screen
# if input, pass to Gui and interpret return
# if return implies update, queue up work for thread
from process import ProcessHandler
from utility import Cycle
from cfg.base import get_cfg
from const import *
from gui import Gui
import canto_fetch
import utility
import args
import tag
import traceback
import signal
import locale
import curses
import time
import sys
import os
# This should be phased out after 0.7.x becomes standard
def upgrade_help():
print "\nIf you're having trouble upgrading from 0.6.x please visit"
print ""
class Main():
def __init__(self, stdscr = None):
signal.signal(signal.SIGUSR2, self.debug_out)
# Let locale figure itself out
locale.setlocale(locale.LC_ALL, "")
enc = locale.getpreferredencoding()
# If we're canto-fetch, jump to that main function
if sys.argv[0].endswith("canto-fetch"):
# Parse arguments that canto shares with canto-fetch, return
# a lot of file locations and an optlist that will contain the
# parsed, but yet unused canto specific arguments.
conf_dir, log_file, conf_file, feed_dir, script_dir, optlist =\
# Instantiate the config and start the log.
try :
self.cfg = get_cfg(conf_file, log_file, feed_dir, script_dir)
except :
self.cfg.log("Canto v %s (%s)" % \
("%d.%d.%d" % VERSION_TUPLE, GIT_SHA), "w")
self.cfg.log("Time: %s" % time.asctime())
self.cfg.log("Config parsed successfully.")
# If we were passed an existing curses screen (i.e. restart)
# pass it through to the config.
self.cfg.stdscr = stdscr
if self.cfg.stdscr:
self.restarting = True
self.restarting = False
self.restart = False
# Default arguments.
flags = 0
feed_ct = None
opml_file = None
url = None
newtag = None
# Note that every single flag that takes an argument has its
# argument converted to unicode. Saves a lot of bullshit later.
for opt, arg in optlist :
if opt in ["-u","--update"] :
elif opt in ["-n","--checknew"] :
flags |= CHECK_NEW
feed_ct = unicode(arg, enc, "ignore")
elif opt in ["-a","--checkall"] :
flags |= CHECK_NEW
elif opt in ["-l","--list"] :
flags |= FEED_LIST
elif opt in ["-o","--opml"] :
flags |= OUT_OPML
elif opt in ["-i","--import"] :
flags |= IN_OPML
opml_file = unicode(arg, enc, "ignore")
elif opt in ["-r","--url"] :
flags |= IN_URL
url = unicode(arg, enc, "ignore")
elif opt in ["-t","--tag"] :
newtag = unicode(arg, enc, "ignore")
# Import flags harness the same functions as their config
# based counterparts, source_opml and source_url.
if flags & IN_OPML:
self.cfg.locals['source_opml'](opml_file, append=True)
print "OPML imported."
if flags & IN_URL:
self.cfg.locals['source_url'](url, append=True, tag=newtag)
print "URL added."
# All import options should terminate.
if flags & (IN_OPML + IN_URL):
# If self.cfg had to generate a config, make sure we
# update first.
if self.cfg.no_conf:
self.cfg.log("Conf was auto-generated, adding -u")
if flags & UPDATE_FIRST:
self.cfg.log("Pausing to update..."), True, True)
# Detect if there are any new feeds by whether their
# set path exists. If not, run canto-fetch but don't
# force it, so canto-fetch intelligently updates.
for i,f in enumerate(self.cfg.feeds) :
if not os.path.exists(f.path):
self.cfg.log("Detected unfetched feed: %s." % f.URL), True, False)
#Still no go?
if not os.path.exists(f.path):
self.cfg.log("Failed to fetch %s, removing" % f.URL)
self.cfg.feeds[i] = None
# Collapse the feed array, if we had to remove some unfetchables.
self.cfg.feeds = filter(lambda x: x != None, self.cfg.feeds) = []
self.old = [] = ProcessHandler(self.cfg)
# Force an update from disk by queueing a work item for each thread.
# At this point, we just want to do the portion of the update where the
# disk is read, so PROC_UPDATE is used.
self.cfg.log("Populating feeds...")
for f in self.cfg.feeds:, f.URL, []))
for f in self.cfg.feeds:
f.merge([1]), ))
fixedtags =
for i, f in enumerate(self.cfg.feeds):
self.cfg.feeds[i].tags = fixedtags[i]
# Now that the tags have all been straightened out, validate the config.
# Making sure the tags are unique before validation is important because
# part of validation is the actual creation of Tag() objects.
except Exception, err:
print err
# Print out a feed list
if flags & FEED_LIST:
for f in self.cfg.feeds:
print f.tags[0].encode(enc, "ignore")
# This could probably be done better, or more officially
# with some XML library, but for now, print is working
# fairly well.
if flags & OUT_OPML:
self.cfg.log("Outputting OPML")
print """<opml version="1.0">"""
print """<body>"""
for feed in self.cfg.feeds:
ufp = feed.get_ufp()
if "atom" in ufp["version"]:
t = "pie"
t = "rss"
print """\t<outline text="%s" xmlUrl="%s" type="%s" />""" %\
(feed.tags[0].encode(enc, "ignore"), feed.URL, t)
print """</body>"""
print """</opml>"""
# Handle -a/-n flags (print number of new items)
if flags & CHECK_NEW:
if not feed_ct:
unread = 0
for f in self.cfg.feeds:
for s in f:
if "read" not in s["canto_state"]:
unread += 1
print unread
t = [ f for f in self.cfg.feeds if f.tags[0] == feed_ct ]
if not t:
print "Unknown Feed"
print len([ x for x in t[0] if "read"\
not in x["canto_state"]])
# After this point, we know that all further operation is going to
# require all tags to be populated, we queue up the latter
# half of the work, to actually filter and sort the items.
# The reason we clear the feeds first is that only after validation do
# we know what keys are going to have to be precached, and canto tries
# hard to conserve items (see feed.merge), so we need to replace all of
# them with corrected, fresh items from the process that knows about the
# precache
for f in self.cfg.feeds:
del f[:]
signal.signal(signal.SIGCHLD, self.chld)
self.update(1, self.cfg.feeds, PROC_BOTH)
# At this point we know that we're going to actually launch
# the client, so we fire up ncurses and add the screen
# information to our Cfg().
if not self.restarting:
self.cfg.stdscr = curses.initscr()
# curs_set can return ERR, we shouldn't care
# if any of these mess up though, the rest of the
# the operation is suspect, so die.
self.cfg.log("Unable to init curses, bailing")
self.sigusr = 0
self.resize = 0
self.alarmed = 0
self.ticks = 60
self.cfg.height, self.cfg.width = self.cfg.stdscr.getmaxyx()
# Init colors
for i, (fg, bg) in enumerate(self.cfg.colors):
curses.init_pair(i + 1, fg, bg)
self.cfg.log("Unable to init curses color pairs!")
self.cfg.log("Curses initialized.")
# Instantiate the base Gui class
self.gui = Gui(self.cfg, self.cfg.tags.cur())
self.cfg.log("GUI initialized.")
# Signal handling
signal.signal(signal.SIGWINCH, self.winch)
signal.signal(signal.SIGALRM, self.alarm)
signal.signal(signal.SIGINT, self.done)
signal.signal(signal.SIGUSR1, self.sigusr)
self.cfg.log("Signals set.")
self.estring = None
# The main loop is wrapped in one huge try...except so that no matter
# what exception is thrown, we can clean up curses and exit without
# shitting all over the terminal.
# Initial draw of the screen, if not restarting
self.restarting = False
# Main program loop, terminated when all handlers have
# deregistered / exited.
self.cfg.log("Beginning main loop.")
while 1:
# Clear the key
t = None
# Gui is none if a key returned EXIT
if not self.gui:
# If we've spawned a text-based process (i.e. elinks), then just
# pause and wait to be awakened by SIGCHLD
if self.cfg.wait_for_pid:
# Tick when SIGALRM is received.
if self.alarmed:
self.alarmed = 0
# Deferred update from signal
if self.sigusr:
self.sigusr = 0
if "signal" in self.cfg.triggers:
# Get the key
k = self.cfg.stdscr.getch()
# KEY_RESIZE is the only key not propagated, to
# keep users from rebinding it and crashing.
if k == curses.KEY_RESIZE or self.resize:
self.resize = 0
# No input, time to check on the worker.
if k == -1:
# Make sure we don't pin the CPU, so if there's no input and
# no waiting updates, sleep for awhile.
if not
r =, 0.01)
if r:
feed = [ f for f in self.cfg.feeds if f.URL == r[1]][0]
if r[0] == PROC_UPDATE:
old = []
for gf, tf, s, l in r[4]:
if not l:
old.append((gf, tf, s, l))
for i, oldidx in enumerate(l):
l[i] = feed[oldidx]
old.append((gf, tf, s, l))
new = []
for gf, tf, s, l in r[3]:
if not l:
new.append((gf, tf, s, None))
for i, newidx in enumerate(l):
l[i] = (feed[newidx], newidx)
new.append((gf, tf, s, l))
self.gui.alarm(new, old)
feed.qd = False
# Handle Meta pairs
elif k == 195:
k2 = self.cfg.stdscr.getch()
if k2 >= 64:
t = (k2 - 64, 1)
t = (k, 0)
# Just a normal key-press
t = (k, 0)
# Key resolves a keypress tuple into a list of actions
actions = self.gui.key(t)
# Actions are executed in order, and each return code
# is handled in order.
for a in actions:
r = self.gui.action(a)
if r == REFRESH_ALL:
elif r == UPDATE:
elif r in [REFILTER, RETAG]:
elif r == TFILTER:
# Tag filters shouldn't perform a full update, so we map
# the relevant tag to all of the feeds that include that
# tag and update them.
t = self.gui.sel["tag"]
ufds = [ f for f in self.cfg.feeds\
if t.tag in f.tags]
self.update(1, ufds)
elif r == REDRAW_ALL:
elif r == RESTART:
self.restart = True
self.gui = None
elif r == EXIT:
self.gui = None
except Exception:
self.estring = traceback.format_exc()
except KeyboardInterrupt:
def done(self, a=None, b=None):
# Unset signals.
for s in [signal.SIGWINCH, signal.SIGCHLD, signal.SIGINT]:
signal.signal(s, signal.SIG_DFL)
signal.signal(signal.SIGALRM, signal.SIG_IGN)
# Kill the message log
self.cfg.msg = None
# Kill curses
if not self.restart:
self.cfg.log("Curses done.")
# If there was an exception, nicely print it out.
if self.estring:
print "Canto exited on an exception.\n"
print self.estring
print "Please report this bug. Send your logfile " +\
"(%s) to" % self.cfg.log_file
# Flush the work thread to make sure no updates are going on.
self.cfg.log("Flushed to disk.")
# For the most part, it's smart to avoid doing anything but set a flag in an
# signal handler. CHLD is an exception because the only case in which we do
# anymore work than just acknowledging the process is dead is when
# wait_for_pid is set and, in this case the main loop is paused anyway.
def chld(self, a=None, b=None):
# I'm not sure why, but SIGCHLD gets called and occasionally waitpid
# then throws an exception about no waiting processes therefore this is
# wrapped in meaningless try...except
while True:
pid, status = os.waitpid(0, os.WNOHANG)
if pid == 0:
# If the interface is waiting on this pid to be done, reset the
# signal and simulate a resize to make sure the window
# information is still fresh.
if self.cfg.wait_for_pid == pid:
self.cfg.wait_for_pid = 0
signal.signal(signal.SIGALRM, self.alarm)
signal.signal(signal.SIGWINCH, self.winch)
self.alarmed = 1
self.resize = 1
# Back to better practices. =)
def winch(self, a=None, b=None):
self.resize = 1
def alarm(self, a=None, b=None):
self.alarmed = 1
def sigusr(self, a, b):
self.sigusr = 1
# Tick decrements two timers. One for a possible update (if "interval" is a
# valid update trigger), and one for the message box at the bottom of the
# interface so that messages don't persist for very long.
def tick(self, refilter=0):
# Possible update tick
self.ticks -= 1
if self.ticks <= 0:
if "interval" in self.cfg.triggers:
self.update(0, self.cfg.feeds)
self.ticks = 60
# Message tick
self.cfg.msg_tick -= 1
if self.cfg.msg_tick == 0:
self.cfg.message(self.cfg.status(self.cfg), 1)
# Update is where the work is queued up for the work thread.
def update(self, refilter = 0, iter = None, action=PROC_BOTH):
# Default to updating all feeds that match the gui's current tags
if iter == None:
iter = []
for f in self.cfg.feeds:
for t in self.gui.tags:
if t.tag == "*" or t.tag in f.tags:
# This will restart the worker process if an unknown filter has made it
# into the global or tag filters. This should *never* happen unless the
# user has on-the-fly defined a filter (i.e. with search_filter). This
# makes on-the-fly filters rather expensive. Also note that this should
# *always* be accompanied by a REFILTER return code from the gui as the
# kill_process will ignore any further returned data.
restart = 0
for t in self.cfg.tags.cur():
tf = t.filters.cur()
if tf not in self.cfg.all_filters:
restart = 1
gf = self.cfg.filters.cur()
if gf not in self.cfg.all_filters:
restart = 1
if restart:
# Queue up the appropriate items.
for f in iter:
if f.qd and not refilter:
(action, f.URL, f[:],\
for t in self.cfg.tags.cur()],\
for s in f.changed():
s.updated = STORY_QD
f.qd = True
# Refresh should only be called when it's possible that the screen has
# changed shape.
def refresh(self, restart = False):
# Get new self.cfg.{height, width}
if not restart:
self.cfg.height, self.cfg.width = self.cfg.stdscr.getmaxyx()
# If there's a resize hook, execute it.
if self.cfg.resize_hook:
# Make sure we've got a minimum for columns and reader_lines
self.cfg.columns = max(self.cfg.columns, 1)
self.cfg.reader_lines = max(self.cfg.reader_lines, 3)
# Adjust gui_height to compensate for the message at the bottom.
self.cfg.gui_height = self.cfg.height - self.cfg.msg_height
self.cfg.gui_width = self.cfg.width
# Now we interpret the reader_orientation setting from the config to
# shape the reader area, and adjust other height / width settings
# accordingly.
# XXX This logic could be cleaned up...
if self.cfg.reader_orientation == "top":
self.cfg.gui_height -= self.cfg.reader_lines
self.cfg.gui_top = self.cfg.reader_lines
elif self.cfg.reader_orientation == "bottom":
self.cfg.gui_height -= self.cfg.reader_lines
elif self.cfg.reader_orientation == "left":
self.cfg.gui_width -= self.cfg.reader_lines
self.cfg.gui_right = self.cfg.reader_lines
elif self.cfg.reader_orientation == "right":
self.cfg.gui_width -= self.cfg.reader_lines
# Create the message window. This could arguably be crammed into the cfg
# class itself, however, for logging and messaging, the cfg class is
# basically just a glorified way to do away with globals =P
self.cfg.msg = curses.newwin(self.cfg.msg_height,\
self.cfg.width, self.cfg.height - self.cfg.msg_height, 0)
# Perform the main update update.
# This also doesn't follow good signal practices, but it's an exception
# because the backtrace is important.
def debug_out(self, a=None, b=None):
f = open("canto_debug_out", "w")
for l in traceback.format_stack():
Jump to Line
Something went wrong with that request. Please try again.