pgconsole/app.py
author Radek Brich <brich.radek@ifortuna.cz>
Tue, 06 May 2014 18:37:43 +0200
changeset 101 2a2d0d5df03b
parent 76 3a41b351b122
permissions -rw-r--r--
Refactor ToolBase to allow tool composition. Add TableSync tool (composited). Move more tools under pgtool.

from gi.repository import GObject
from gi.repository import Gtk, Gdk
from gi.repository import GdkPixbuf
from gi.repository import cairo, Pango
from gi.repository import GtkSource

import psycopg2
import psycopg2.extensions
import psycopg2.extras

import time

from pgconsole.config import cfg
from pgconsole.editor import Editor
from pgconsole.dataview import DataView
from pgconsole.database import Database, BadConnectionError, DatabaseError
from pgconsole.settings import Settings
from pgconsole.panedext import HPanedExt, VPanedExt


class PgConsoleApp:
    def __init__(self):
        self.db = Database()
        self.conn = None

        win = Gtk.Window()
        self.win = win
        win.set_title('PostgreSQL Console')
        win.set_size_request(300, 200)  # minimal size
        win.set_default_size(800, 600)
        self.restore_window_size()
        win.connect("destroy", self.destroy)
        win.connect("key_press_event", self.keypress)
        win.connect('configure-event', self.on_configure)

        # toolbar
        toolbar = Gtk.Toolbar()
        toolbar.set_style(Gtk.ToolbarStyle.ICONS)
        toolbar.set_property("icon-size", Gtk.IconSize.SMALL_TOOLBAR)

        tb = Gtk.ToolButton(Gtk.STOCK_PREFERENCES)
        tb.set_label('Settings')
        tb.set_tooltip_text('Settings')
        tb.connect('clicked', self.settings)
        self.tb_settings = tb
        toolbar.add(tb)

        self.cb_server = Gtk.ComboBoxText.new_with_entry()
        self.cb_server.set_tooltip_text('Server')
        self.cb_server.get_child().set_property("editable", False)
        self.cb_server.set_property("add_tearoffs", True)
        #self.cb_server.set_property("focus-on-click", False)
        self.cb_server.set_property("can-focus", True)
        self.cb_server.connect('changed', self.on_server_changed)
        self.cb_server.connect("key_press_event", self.toolbar_server_keypress)
        ti = Gtk.ToolItem()
        ti.add(self.cb_server)
        toolbar.add(ti)

        self.cb_dbname = Gtk.ComboBoxText.new_with_entry()
        self.cb_dbname.set_tooltip_text('Database')
        self.cb_dbname.get_child().set_property("editable", False)
        self.cb_dbname.set_property("add_tearoffs", True)
        #self.cb_dbname.set_property("focus-on-click", False)
        self.cb_dbname.set_property("can-focus", True)
        ti = Gtk.ToolItem()
        ti.add(self.cb_dbname)
        toolbar.add(ti)

        tb = Gtk.ToolButton(Gtk.STOCK_CONNECT)
        tb.set_label('Connect')
        tb.set_tooltip_text('Connect')
        tb.connect('clicked', self.connect)
        toolbar.add(tb)
        self.tb_connect = tb

        sep = Gtk.SeparatorToolItem()
        toolbar.add(sep)

        tb = Gtk.ToolButton(Gtk.STOCK_EXECUTE)
        tb.set_label('Execute')
        tb.set_tooltip_text('Execute')
        tb.connect('clicked', self.execute)
        toolbar.add(tb)
        tb.set_sensitive(False)
        self.tb_execute = tb
        tb = Gtk.ToolButton(Gtk.STOCK_NEW)
        tb.set_label('Begin transaction')
        tb.set_tooltip_text('Begin transaction')
        tb.connect('clicked', self.begin)
        tb.set_sensitive(False)
        toolbar.add(tb)
        self.tb_begin = tb
        tb = Gtk.ToolButton(Gtk.STOCK_APPLY)
        tb.set_label('Commit')
        tb.set_tooltip_text('Commit')
        tb.connect('clicked', self.commit)
        tb.set_sensitive(False)
        self.tb_commit = tb
        toolbar.add(tb)
        tb = Gtk.ToolButton(Gtk.STOCK_CANCEL)
        tb.set_label('Rollback')
        tb.set_tooltip_text('Rollback')
        tb.connect('clicked', self.rollback)
        tb.set_sensitive(False)
        self.tb_rollback = tb
        toolbar.add(tb)

        sep = Gtk.SeparatorToolItem()
        toolbar.add(sep)

        # editor
        self.editor = Editor()

        # data view
        self.dataview = DataView()


        vbox = Gtk.VBox(False, 2)

        sep = Gtk.SeparatorToolItem()
        sep.set_expand(True)
        sep.set_draw(False)
        toolbar.add(sep)

        self.throbber_anim = GdkPixbuf.PixbufAnimation.new_from_file('data/Throbber.gif')
        self.throbber = Gtk.Image()
        tb = Gtk.ToolItem()
        tb.add(self.throbber)
        toolbar.add(tb)
        sep = Gtk.SeparatorToolItem()
        sep.set_draw(False)
        toolbar.add(sep)

        vbox.pack_start(toolbar, False, False, 0)

        vpaned = VPanedExt()
        vpaned.set_border_width(5)

        hpaned = HPanedExt()
        hpaned.set_border_width(0)
        hpaned.add1(self.editor)
        hpaned.child_set_property(self.editor, 'shrink', False)
        hpaned.set_snap2(80)
        hpaned.set_property('position', 500)

        vpaned.add1(hpaned)
        vpaned.set_snap1(80)
        vpaned.set_property('position', 300)

        vpaned.add2(self.dataview)
        vpaned.set_snap2(80)

        self.vpaned = vpaned
        self.hpaned = hpaned

        # log
        self.logbuf = Gtk.TextBuffer()
        view = Gtk.TextView.new_with_buffer(self.logbuf)
        view.set_editable(False)
        font_desc = Pango.FontDescription('monospace')
        if font_desc:
            view.modify_font(font_desc)

        sw = Gtk.ScrolledWindow()
        sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
        sw.set_shadow_type(Gtk.ShadowType.ETCHED_IN)
        sw.add(view)
        hpaned.add2(sw)

        vbox.pack_start(vpaned, expand=True, fill=True, padding=0)

        win.add(vbox)

        self.editor.view.grab_focus()

        self.reload_server_list(0)

        win.show_all()

        self.restore_win_state()


    def main(self):
        Gtk.main()


    def destroy(self, widget, data=None):
        self.save_win_state()
        cfg.save()
        Gtk.main_quit()


    def on_configure(self, w, ev):
        cfg.root.window.size.width = ev.width
        cfg.root.window.size.height = ev.height


    def restore_window_size(self):
        self.win.resize(cfg.root.window.size.width, cfg.root.window.size.height)


    def save_win_state(self):
        cfg.root.window.dividers.verticaldivider = self.vpaned.get_position()
        cfg.root.window.dividers.horizontaldivider = self.hpaned.get_position()
        cfg.root.window.dividers.editordivider = self.editor.get_position()


    def restore_win_state(self):
        pos = cfg.root.window.dividers.verticaldivider
        if pos >= 0:
            self.vpaned.set_position(pos)
        pos = cfg.root.window.dividers.horizontaldivider
        if pos >= 0:
            self.hpaned.set_position(pos)
        pos = cfg.root.window.dividers.editordivider
        if pos >= 0:
            self.editor.set_position(pos)


    def get_typename(self, oid, size):
        self.curs.execute('SELECT typname FROM pg_type WHERE oid=%s', [oid])
        psycopg2.extras.wait_select(self.curs.connection)
        row = self.curs.fetchone()

        typname = None
        if row:
            typname = row[0]
            if typname == 'int4':
                return 'integer'
            if typname in ('timestamp', 'interval', 'date'):
                return typname
            if size and size > 0 and size < 65535:
                typname += '(%s)' % size
        return typname


    def get_conninfo(self, nodb=False):
        sel = self.cb_server.get_active()
        srv = cfg.servers.server[sel]
        if nodb:
            dbname = 'postgres'
        else:
            dbname = self.cb_dbname.get_active_text()
        conninfo = 'host=%s port=%s dbname=%s user=%s password=%s' \
            % (srv.host, srv.port, dbname, srv.user, srv.password)
        return conninfo


    def connect(self, w):
        conninfo = self.get_conninfo()
        if self.conn:
            # disconnect
            self.db.put_conn(conninfo, self.conn)
            self.conn = None
            self.cb_server.set_sensitive(True)
            self.cb_dbname.set_sensitive(True)
            self.tb_connect.set_stock_id(Gtk.STOCK_CONNECT)
            self.tb_connect.set_label('Connect')
            self.tb_connect.set_tooltip_text('Connect')
            self.tb_execute.set_sensitive(False)
            self.tb_begin.set_sensitive(False)
        else:
            # connect
            self.logbuf.insert(self.logbuf.get_end_iter(), 'Connect %s\n' % conninfo)
            try:
                self.conn = self.db.get_conn(conninfo)
            except DatabaseError as e:
                self.logbuf.insert(self.logbuf.get_end_iter(), 'Error:\n%s\n' % e)
                return
            self.cb_server.set_sensitive(False)
            self.cb_dbname.set_sensitive(False)
            self.tb_connect.set_stock_id(Gtk.STOCK_DISCONNECT)
            self.tb_connect.set_label('Disconnect')
            self.tb_connect.set_tooltip_text('Disconnect')
            self.tb_execute.set_sensitive(True)
            self.tb_begin.set_sensitive(True)


    def begin(self, w):
        self.logbuf.insert(self.logbuf.get_end_iter(), 'Begin transaction\n')
        curs = self.conn.cursor()
        curs.execute('BEGIN')
        psycopg2.extras.wait_select(curs.connection)

        self.tb_connect.set_sensitive(False)
        self.tb_begin.set_sensitive(False)
        self.tb_commit.set_sensitive(True)
        self.tb_rollback.set_sensitive(True)


    def commit(self, w):
        self.logbuf.insert(self.logbuf.get_end_iter(), 'Commit\n')
        curs = self.conn.cursor()
        curs.execute('COMMIT')
        psycopg2.extras.wait_select(curs.connection)

        self.tb_connect.set_sensitive(True)
        self.tb_begin.set_sensitive(True)
        self.tb_commit.set_sensitive(False)
        self.tb_rollback.set_sensitive(False)


    def rollback(self, w):
        self.logbuf.insert(self.logbuf.get_end_iter(), 'Rollback\n')
        curs = self.conn.cursor()
        curs.execute('ROLLBACK')
        psycopg2.extras.wait_select(curs.connection)

        self.tb_connect.set_sensitive(True)
        self.tb_begin.set_sensitive(True)
        self.tb_commit.set_sensitive(False)
        self.tb_rollback.set_sensitive(False)


    def execute(self, widget):
        query = self.editor.get_selection() or self.editor.get_text()

        self.tb_connect.set_sensitive(False)
        self.tb_execute.set_sensitive(False)
        self.tb_begin.set_sensitive(False)
        self.tb_commit.set_sensitive(False)
        self.tb_rollback.set_sensitive(False)
        self.throbber.set_from_animation(self.throbber_anim)

        self.curs = self.conn.cursor()

        self.t1 = time.time()
        try:
            self.curs.execute(query)
        except (psycopg2.OperationalError, psycopg2.DatabaseError) as e:
            self.logbuf.insert(self.logbuf.get_end_iter(), 'Error:\n' + str(e))
            return

        self.execute_poll()


    def execute_poll(self, source=None, cond=None):
        try:
            state = self.conn.poll()
        except (psycopg2.OperationalError, psycopg2.DatabaseError) as e:
            self.logbuf.insert(self.logbuf.get_end_iter(), 'Error:\n' + str(e))
            return

        if state == psycopg2.extensions.POLL_OK:
            self.execute_finish()
        elif state == psycopg2.extensions.POLL_WRITE:
            GObject.io_add_watch(self.conn.fileno(), GObject.IO_OUT, self.execute_poll)
        elif state == psycopg2.extensions.POLL_READ:
            GObject.io_add_watch(self.conn.fileno(), GObject.IO_IN, self.execute_poll)
        else:
            self.logbuf.insert(self.logbuf.get_end_iter(), "poll() returned %s" % state)
            return


    def execute_finish(self):
        t2 = time.time()
        t = (t2 - self.t1)*1000

        self.throbber.clear()

        self.logbuf.insert(self.logbuf.get_end_iter(),
            'Query successful (%d ms, %d rows)\n' % (t, self.curs.rowcount))

        # notices
        for n in self.conn.notices:
            self.logbuf.insert(self.logbuf.get_end_iter(), n)

        if self.curs.rowcount >= 0:
            rows = self.curs.fetchall()

            names = []
            for c in self.curs.description:
                name = c[0]
                typename = self.get_typename(c[1], c[3])
                names += [(name, typename)]

            self.dataview.load_data(names, rows)

        self.tb_execute.set_sensitive(True)
        if self.conn.get_transaction_status() == psycopg2.extensions.TRANSACTION_STATUS_INTRANS:
            self.tb_commit.set_sensitive(True)
            self.tb_rollback.set_sensitive(True)
        else:
            self.tb_connect.set_sensitive(True)
            self.tb_begin.set_sensitive(True)


    def simulate_click(self, tb):
        if tb.get_property('sensitive'):
            tb.get_child().activate()


    def keypress(self, w, event):
        keyname = Gdk.keyval_name(event.keyval)
        if keyname == 'F1':
            self.simulate_click(self.tb_settings)
            return True
        if keyname == 'F2':
            print(self.cb_server.popup())
            return True
        if keyname == 'F3':
            print(self.cb_dbname.popup())
            return True
        if keyname == 'F4':
            self.simulate_click(self.tb_connect)
            return True
        if keyname == 'F5':
            self.simulate_click(self.tb_execute)
            return True
        if keyname == 'F6':
            self.simulate_click(self.tb_begin)
            return True
        if keyname == 'F7':
            self.simulate_click(self.tb_commit)
            return True
        if keyname == 'F8':
            self.simulate_click(self.tb_rollback)
            return True

        return False


    def toolbar_server_keypress(self, w, event):
        keyname = Gdk.keyval_name(event.keyval)
        if keyname == 'Tab':
            self.populate_db_list()
            self.cb_dbname.grab_focus()
            return True


    def on_server_changed(self, w):
        if self.cb_server.get_active() != -1:
            self.populate_db_list()


    def populate_db_list(self):
        conninfo = self.get_conninfo(True)
        try:
            conn = self.db.get_conn(conninfo)
        except DatabaseError as e:
            self.logbuf.insert(self.logbuf.get_end_iter(), 'Error:\n%s\n' % e)
            return

        curs = conn.cursor()
        curs.execute('SELECT * FROM pg_catalog.pg_database WHERE NOT datistemplate ORDER BY 1')
        psycopg2.extras.wait_select(conn)
        rows = curs.fetchall()

        self.db.put_conn(conninfo, conn)

        for i in range(self.cb_dbname.get_model().iter_n_children(None)):
            self.cb_dbname.remove(0)

        for row in rows:
            self.cb_dbname.append_text(row[0])

        self.cb_dbname.set_active(0)


    def reload_server_list(self, sel=None):
        # clean
        for i in range(self.cb_server.get_model().iter_n_children(None)):
            self.cb_server.remove(0)
        self.cb_server.get_child().set_text('')

        try:
            # populate
            for server in cfg.servers.server:
                if str(server.name):
                    title = '%s (%s)' % (str(server.name), str(server.host))
                else:
                    title = str(server.host)
                self.cb_server.append_text(title)
        except AttributeError:
            pass

        if not sel is None:
            self.cb_server.set_active(sel)


    def settings(self, w):
        Settings(self)