Showing posts with label Python. Show all posts
Showing posts with label Python. Show all posts

Thursday, February 4, 2016

byteplay ok on linux

Oh my but this was a strenuous afternoon. I believe I shall now drone on about my adventures, just because the story ends well.

First, I installed Ubuntu 14.04LTS on my 64-bit dev virtual machine. I kind of hated to do this because the install ISO does not offer what to a Mac user is the sensible choice, of reinstalling the OS without changing the user files. Nope. Either it will install "beside" the existing system, meaning in a different disk partition, or it installs "over" the existing system which wipes all your user files and settings. So I did the latter, knowing full well I would have to reinstall all my dev tools and dependencies.

In a month or two I will need to install Py/Qt5.6 and some Python modules, so I can upgrade PPQT and Cobro. But for the time being, I only needed to get a working Wing IDE and Python 3.5. The Wing IDE is a simple install, then activate with my license key.

Python.org does not offer an installable package for Linux. There are Ubuntu/Debian packages for Python 2.7 and for Python 3.4, but I needed 3.5. The only way to get that is by downloading the source package and making it. Well, I've done that often enough. Download, unzip, make, make test, sudo make install. All seems to go well until I try to start Wing, and it can't start Python. "Missing module _struct".

DuckDuck that phrase and you will find that hundreds of people have encountered it. What does it mean? It can be properly translated as, "you suck at installing software". Something didn't go well in the make-install step. More missing things show up when I find out that neither pip nor easy-install were installed. When I try to download and run get-pip.py, it fails with an error because the SSL lib wasn't installed, and also the zlib was not installed.

Scrolling back in the make output I find a list of modules that it couldn't locate. Did it put the list at the end where a person would notice it? Of course not! It buried it between a couple of hundred lines of output before and after. Did it tell you what to do? Of course not! Well, it did; it helpfully advised that you "look for the name of the missing module in setup.py".

Which I did, and saw where it built a list of libraries to search for things. Then I got a terminal window and used the find command to find the things it was not finding. Oh, there they are, in a special 64-bit library. Add the path to that library to the code of setup.py and rerun the make install. OK then! Now I've got pip, and Wing is working, whee.

So now I can attempt what I set out to do a couple hours earlier, pip install byteplay3. Which of course fails with an obscure message, "Could not find a version that satisfies the requirement". What requirement? Back to DuckDuckGo. Oh yeah, plenty of people were having this problem a couple years ago, when pip began to require version numbers that complied with PEP400. I read PEP400. No, my version number fits the pattern. But in one of these postings I see a remark that, oh, also, pip does not support modules that are not hosted directly at PyPi. A module hosted at github, for example. Oh. Which I am doing.

So I find out about setup.py's build and sdist and upload commands. And find out the hard way, that you can't do

python setup.py sdist
python setup.py upload

oh no no no! You have to do

python setup.py sdist upload

all one line. Because...? Who knows.

Anyway, did that, and then in my Linux system and my Mac OS system both I could do pip install byteplay3 and it did it and I could start Python and run the example from the readme, and it worked.

Tomorrow, Windows. I am so looking forward to that.

Monday, December 28, 2015

Holiday in the Endless Sky

So for fun I've been playing Endless Sky. It's a nicely made re-implementation of the old EV Nova (which appears to be still available, although I'm not sure it would run in a current Mac OS).

Part of the early grind of Endless Sky, as it was for EV Nova, is to haul commodities from star to star, buying low and selling high, until you accumulate enough credits to buy a better ship. Each star has prices for ten different commodity types, and one of the first questions the player asks is, what route has the highest profit? (In EV Nova, there was one fabulously profitable one-hop route, the only problem being that you were almost guaranteed to be attacked by pirates every time through it.)

Endless Sky's galaxy is defined in a simple text file that is part of the game package (here's the source). I looked at it and thought, hmmm. The galaxy is an undirected graph; the commodity prices are properties of the nodes of the graph. This looks like a job for Python! So one morning I sat down in and in less than two hours, I had code to read and store the galaxy as a graph, and it worked first time! What follows is a slightly upgraded version, so it has about 2:30 invested in it.

import sys
try:
    filename = sys.argv[1]
    map_file = open( filename, 'rt', encoding='utf-8', errors='ignore' )
except:
    print('usage: mapper <file path to the map file>')
    exit(-1)

# map trade names to indexes in a system trade list
trade_indices = {
    "Clothing" : 0,
    "Electronics" : 1,
    "Equipment" : 2,
    "Food" : 3,
    "Heavy Metals" : 4,
    "Industrial" : 5,
    "Luxury Goods" : 6,
    "Medical" : 7,
    "Metal" : 8,
    "Plastic" : 9
}
trade_names = { d : n for n, d in trade_indices.items() }

# The galaxy is a dict of { 'system-name' : System } It is an undirected
# graph in which the edges are system-names in each system's links:
#    for neighbor_name in Galaxy['some-name'].links:
#        neighbor_system = Galaxy[neighbor_name]

Galaxy = dict()

class System( object ):
    def __init__( self ):
        self.trade = [0] * 10 # prices of 10 commodity types here
        self.links = set() # names of connected systems
        self.habitable = False # can trade here?

import regex
# The following matches to a pattern of either
#   verb namestring [nnn]
# or
#   verb "name string with spaces" [nnn]

rx_verb = regex.compile( '''^\s*(\w+)\s+((\w+)|['"]([\w\s]+)['"])(\s*\d+)?\s*$''', flags=regex.IGNORECASE )

# If the current line is "verb name [nnn]" return (verb, namestring, [nnn|None]).
# Otherwise return (None, None, None)

def match_line( line ):
    match = rx_verb.match( line )
    verb = None
    namestring = None
    number = None
    if match is not None :
        verb = match.group(1)
        namestring = match.group(3) if match.group(4) is None else match.group(4)
        number = match.group(5)
    return (verb, namestring, number)

# Read the map, build the Galaxy

in_system = False

for line in map_file :

    ( verb, name_string, number ) = match_line( line )

    if not in_system :
        if verb == 'system' :
            # Beginning a system block. Create a system and file it.
            new_system = System()
            Galaxy[name_string] = new_system
            in_system = True
            habitable = False
            gov_not_Hai = True
        continue

    # Process "link", "trade", "government" and "object" statements
    if verb == 'link' :
        # new_system links to name_string. Note the map has reciprocal links,
        # we do not need to add a reverse link. In fact we couldn't, because
        # name_string may not be in the galaxy yet.
        new_system.links.add( name_string )
        continue

    if verb == 'trade' :
        # new system trade line found, store the price in the system list
        commodity_index = trade_indices[ name_string ]
        commodity_price = int( number )
        new_system.trade[ commodity_index ] = commodity_price
        continue

    # Check "object name" statements. The regex only matches to "object
    # name", not to the more common "object" statements. If a system has an
    # object with a name, it is habitable. Except for Algiebra, which has
    # a named moon but cannot be used for trade.
    if verb == 'object' :
        habitable = 'Watcher' != name_string #anywhere but Algiebra
        continue

    # Look for government starting with Hai, because those systems are not
    # accessible until later in the game (this could be conditioned on a
    # command-line option)
    if verb == 'government' :
        gov_not_Hai = not name_string.startswith( 'Hai' )

    # if the current line is blank, we are at the end of this system.
    if 0 == len(line.strip()) :
        new_system.habitable = habitable and gov_not_Hai
        in_system = False
        continue

    # Note the official map *ALWAYS* has a blank line at the end of a
    # system block i.e. preceding a 'system' statement. If the map file
    # is mis-edited to not have such a blank, this code will merge the
    # trade etc. lines from the following system to the previous one.

# All map lines processed, the Galaxy is built.

With the galaxy as a graph, it took another hour to brute-force the best trade route of one, two, three or four hops. No longer route because the starter ship and the typical freighter can't go more than four hops without landing.

# Return the set of all system names that are n hops out from the given
# system.
def n_hop_targets( system, n ):
    if n == 1 :
        return set( system.links )
    all_targets = set()
    for target in system.links :
        all_targets |= n_hop_targets( Galaxy[ target ], n-1 )
    return all_targets

# Compare the trade price lists from two systems. Return the index
# of the most profitable commodity and the profit margin.
def compare_prices( buy_list, sell_list ):
    profits = [ buy-sell for buy, sell in zip( buy_list, sell_list ) ]
    p = max( profits )
    return profits.index(p), p

# Find the most profitable trade route between a given system and any of a
# set of systems it can reach at some number of hops. Input is the system
# itself, and set of target names.
# return the system name and (outbound buy, profit, inbound buy, profit)

def best_trade_route( system, target_set ):
    home_line = system.trade
    out_p = in_p = 0 # outbound and inbound profits
    out_c = in_c = None # outbound, inbound indexes
    where = None # target system
    for dest_name in target_set :
        dest_sys = Galaxy[ dest_name ]
        if dest_sys.habitable :
            dest_line = dest_sys.trade
            d_c, d_p = compare_prices( home_line, dest_line )
            h_c, h_p = compare_prices( dest_line, home_line )
            if (out_p + in_p) < (d_p + h_p) :
                out_p, out_c = d_p, d_c
                in_p, in_c   = h_p, h_c
                where = dest_name
    return (where, out_c, out_p, in_c, in_p )

def best_n_hop(n):
    best_name = None # name of winning start point
    best_targ = None # name of its trade partner
    best_info = (0, 0, 0, 0)
    best_rt = 0 # round-trip profit
    pdict = dict()
    for name, system in Galaxy.items() :
        if system.habitable :
            where, out_c, out_p, in_c, in_p = best_trade_route(
                system,
                n_hop_targets( system, n )
                )
            if (out_p + in_p) > best_rt :
                best_name = name
                best_targ = where
                best_info = ( out_c, out_p, in_c, in_p )
                best_rt = out_p + in_p
                pdict[best_rt] = (best_name,best_targ,best_info)
    print( '\n\nBest', n, 'hop trade route is' )
    print( best_name, 'to', best_targ )
    print( 'outbound take', trade_names[ best_info[0] ], 'earning', best_info[1] )
    print( 'inbound bring', trade_names[ best_info[2] ], 'earning', best_info[3] )
    print( 'round-trip profit is', best_rt )

best_n_hop(1)
best_n_hop(2)
best_n_hop(3)
best_n_hop(4)

So before lunch I knew that I should trade Metals and Luxury Goods between Alphard and Delta Velorum. I took a certain amount of pleasure in having all that work quickly and easily.

My pleasure was rather lessened when I googled "Endless Sky best trade route" and found a forum post with exactly that route, found by some non-programmer weeks ago.

Oh, well.

Thursday, June 26, 2014

Doing It "Pythonically" (with added thought)

I'm starting work on the Find module. One of the features of that is that each of the input fields—the Find text and each of three independent Replace texts—has a memory pop-up: a button that pops up a menu of the ten most recent strings used in that field. It's a very nice help when you are alternating between two or three complex regex searches. (And stolen from Guiguts and BBEdit.)

The details of this widget were of course encapsulated in a class; for V.2 it is class RecallMenuButton. In V.1 this was a QComboBox because that is easy to present. I maintained the recent strings in a QStringList, and any time the list was updated the widget could reload itself in one call, self.insertItems(list). However, I wanted it to look like a square button, not a list, so I had it set its own max width to 29px. Under Windows that didn't work with the default style, so I had to set it to a non-native style "CleanLooks", and that no longer exists.

Anyway for V.2 I am using a "command button" which is a button that has an associated menu. I maintain a python list of QActions, one for each string. When the menu for the button emits the aboutToShow signal, I clear the menu and add the list of actions to it.

So I was starting to code the remember() method of this class, which takes a string and, if it is in the list now, deletes it; then adds it to the front of the list. But some considerations:

  • The string might very likely be in the list already as the first item, because it will be frequent to Find or Replace the same string over and over.
  • The list might be empty, at least the first time.
  • The string might not be in the list at all.
  • The list should never exceed MAX_STRINGS in length.

So the V.1 code is about a dozen lines, with a Fortran-like loop over the list to find and remove the string if it exists. But I'd like to do it more "pythonically" this time. I came up with this:

    def remember(self, string):
        new_stack = [act for act in self.string_stack if string != act.text()]
        new_stack[0:0] = QAction(string)
        self.string_stack = new_stack[0:self.MAX_STRINGS]

After composing which I said,

Now, this does not try to short-cut the presumptively common case of the string already being on top of the stack. If the added string is at the front of the stack it will be removed and added back in the same position. Is this a waste of time? Yes, but the test for that special case would look like:

    def remember(self, string):
        if len(self.string_stack):
            if string == self.string_stack[0].text() :
                return

...and that much extra code would surely waste as much time as it saved, or nearly. Or would it? Hmmm.

Edit: no, it would not be worth it and here's why. The Find UI is only going to "remember" a find or replace string if that string is manually edited by the user. When the Find or Replace line-edit field is filled by the program (from the remembered-strings popup menu or from a user-loadable macro button) a flag is cleared. Said flag is only set when the user edits in the field (the textChanged signal). When the field is actually used (Find or Replace action done), the flag is tested and remember() is called only if the flag is set. Thus remember() is only ever called for strings that the user has entered or altered. This greatly reduces both the number of calls to remember() and the chances that a remembered string already exists at any position in the stack. So there is no point in guarding against the input string being at stack[0].

Friday, May 23, 2014

First Python2 to Python3 Problem

PPQT, like its inspiration Guiguts, maintains a bunch of metadata in a separate file. When saving bookname.txt it also saves bookname.meta with things like the user's notes, the file positions of all page breaks, and much else. This is an inherently fragile scheme because nothing links the two files except their names. The operating system, for example, has no idea the two files should always be copied together. It works adequately because the users are schooled in the habit of keeping everything about one project in one folder, so there's rarely a problem.

However, in the early days of PPQT an early adopter exposed the weakness when he had to restore a project from backup, and restored the book file but not the meta file. So all the metadata were wrong, page breaks in the wrong place, etc.

In response, I added a simple hash signature. On saving a file, PPQT takes an SHA-1 hash of the document text, and writes the hash signature into the meta file. On opening a file, the text is hashed and the signature compared to the metadata. If they differ, the user gets a strongly-worded warning message.

The code to output the signature is basically this:

    cuisineart = QCryptographicHash(QCryptographicHash.Sha1)
    ...
    cuisineart.addData(the_document.toPlainText())
    meta_stream << '{{DOCHASH '
    meta_stream << bytes(cuisineart.result()).__repr__()
    meta_stream << ' }}\n'

("Cuisineart" is the name of a line of food processors. Ha ha.) So the whole text of the book file is poured into the blender. A signature was obtained as cuisineart.result(). This is a QByteArray. In PyQt4, one needed to coerce that to a Python bytes() class. That value's .__repr__() converted it to something printable. The result was a line in the .meta file like this:

{{DOCHASH '\x9a\x9fjG\x99\xd0\x1b\xea\x84\xdeT\x8f:\xb8\xfb\xd5\x06\x82\x10|' }}

Does anyone see the problem with this? In Python 2, the __repr__() of a bytes value is a character string. In Python 2, we still used the old comfortable, sloppy assumption that a char was a byte was a char.

In Python 3, it is a bytes string, because Python 3 draws a sharp distinction between characters—which are numeric tokens that stand for letters and have a bit precision somewhere between 8 and 32—and bytes, which are 8-bit unsigned numbers with no defined character representation.

Practical result? Code identical to the above, executed by Python 3, produces this:

{{DOCHASH "b'\\x9a\\x9fjG\\x99\\xd0\\x1b\\xea\\x84\\xdeT\\x8f:\\xb8\\xfb\\xd5\\x06\\x82\\x10|'" }}

Even when the byte values started out identical, '\xde\xad' != "b'\\xde\\xad'".

In PPQT2, metadata reading is distributed. Various modules "register" to read and write different metadata sections. A reader function is called when a line starts with {{SECTION. It is passed the SECTION value, a version number, and a "parm" containing whatever string followed the SECTION on that line. In the case of the DOCHASH section, the parm is the signature string. The reader for the DOCHASH section began like this:

    def _read_hash(self, stream, sentinel, v, parm) :
        cuisineart = QCryptographicHash(QCryptographicHash.Sha1)
        cuisineart.addData(the_document.toPlainText())
        if parm != cuisineart.result().__repr__() :
            '''issue horrible warning to user'''

And of course, being executed in Python 3, it didn't work; the repr of the new signature has a b in it. Fortunately there is the version parameter. That is read earlier in the metadata stream. In early files it's omitted and defaulted to 0; later files have it as {{VERSION 0}}. So the DOCHASH reader can do this:

    def _read_hash(self, stream, sentinel, v, parm) :
        if v < '2' :
            '''do something to make an old signature compatible'''

But, um, what? Experimenting on the command line, we find this:

 >>> b = b'\xde\xad\xbe\xef'
 >>> c = '\xde\xad\xbe\xef'
 >>> b == c
 False
 >>> # very well, coerce c into bytes
 >>> bytes(c)
Traceback (most recent call last):
  File "", line 1, in 
builtins.TypeError: string argument without an encoding
 >>> bytes(c,'Latin-1','ignore')
b'\xde\xad\xbe\xef'
 >>> bytes(c,'Latin-1','ignore) == b
True
 >>> b.__repr__() == bytes(c,'Latin-1','ignore').__repr__()
True

So the answer is to convert the old char value into a bytes value. The bytes() function insists that I say how the chars are encoded. This is reasonable for the general case, but I happen to know these chars aren't chars; they're just bytes. I looked for an encoding type that would convey that, but didn't see any. So I use 'Latin-1' with 'ignore' to say, if some byte isn't Latin-1, just pass it along thankyouverymuch.

I think this'll work:

    def _read_hash(self, stream, sentinel, v, parm) :
        if v < 2 :
            parm = bytes(parm,'Latin-1','ignore').__repr__()
        cuisineart = QCryptographicHash(QCryptographicHash.Sha1)
        cuisineart.addData(the_document.toPlainText())
        if parm != cuisineart.result().__repr__() :
            '''issue horrible warning to user'''

Tuesday, May 6, 2014

Signal from a module

So the fonts module knows all about QFonts. Its main purpose is to keep track of the user's preference for the mono font (used in the editor) and the general font (used by all the UI widgets), getting these from the app settings at startup and recording them in settings at shutdown, and supporting the yet-to-be written preferences dialog. It offers services like

  • get_fixed() to return a QFont set to the user's preferred monospaced family and size,
  • scale(updown,qfont) to return a qfont scaled up or down one point,
  • ask_font(mono=True) to present a QFontDialog with an appropriate title and return a selected QFont.

When the user chooses a different mono font, any editview needs to know, because it has to explicitly set the font of its QPlainTextEdit. There could be one editview, or several, or even none. Similarly the main window needs to know if the user wants to use a different font for the UI (unlikely, but why not?). This is just the right place to use a Qt signal. Any editview, or the main window can connect itself to that signal while initializing.

However this turned out to be tricky because fonts is a module, not a class. Thereby hangs a short boring story...

Modul-ism

When I was writing PPQT V.1 I didn't really understand Python namespaces. I was stuck in the mindset of assembly language and C—hey, I'm old, what do you want?—so I vaguely equated Python import with a C #include. They are sort of alike in that both bring some externally-defined code into the scope of a source module. But as I bet you know, they are very, very different in implementation.

Take a stupid example. Let's say you have the following modules:

common.py:
    GLOB = 1
unit_a.py:
    import common
    common.GLOB = 2
unit_b.py:
    import common
    print(common.GLOB)
main.py:
    import unit_a
    import unit_b

When you run main.py, what happens? The first mental hurdle to get over is to realize that import is an executable statement: any code in the imported thing is executed. Normally that's declarative code like def or class but the assignments and the print() in these modules will also execute. So something is going to be printed: what?

If import was like #include, it would print 1 because the common imported into unit_b would be an independent copy of the file common.py. But that's not what happens. The first time import common is executed—as part of executing import unit_a—Python creates a common namespace with the one entry, GLOB bound to a 1, which unit_a then reassigns to 2. The next time import common is executed—as part of executing import unit_b—all that happens is that unit_b's namespace gets a reference to the same common namespace, from which it prints 2.

Although I understood this in a theoretical way, I couldn't quite shake the suspicion that importing the same module more than once was somehow a risky proposition. So I took pains to create a class to hold global values. I instantiated one of that class early, and passed that object into each sub-module. It was over-complicated, unnecessary, and in fact a bad design because the global-holding object ended up an overstuffed portmanteau full of unrelated things.

So, V.2, we do things pythonically. As I said, anything font-related gets handled in the fonts module, which has a number of global values with names like _MONO_FAMILY. These get set when the main window calls fonts.initialize(settings), they may get reset when the yet-to-be-written preferences calls fonts.set_general() or fonts.set_fixed(), and so on. And any module that imports fonts will be using the one and only fonts namespace and the same global values.

Signalling

Fine, but what about that signal? Say that preferences calls fonts.set_fixed() with a new choice of QFont. The fontsChanged signal needs to be emitted. But how, or from what?

The new PyQt5 signal/slot API insists that a signal has to be bound to a QObject instance. Fonts is a module, and it has no need define a class or make an object. But it wants to emit a signal. So this is what I had to do:

class Signaller(QObject):
    fontChange = pyqtSignal(bool)
    def connect(self, slot):
        self.fontChange.connect(slot)
    def send(self,boola):
        self.fontChange.emit(boola)

_SIGNALLER = Signaller()

def notify_me(slot):
    _SIGNALLER.connect(slot)
def _emit_signal(boola):
    _SIGNALLER.send(boola)

Signaller is a QObject with just one attribute, a class variable fontChange that is a signal. The signal carries one parameter, a boolean. (It's True if the font changed was the mono font, False if it was the UI font.)

Signaller has two methods, one to connect its signal to a slot, and one to emit the signal. One Signaller object is created and saved in a global reference.

Now, a call to fonts.notify_me() can be used to hook up any Python executable to the fontChange signal. Within the fonts module, a function like fonts.set_fixed() can call _emit_signal(True) to send the signal.

This works fine; the unit-test driver hooked up a function, called fonts.set_fixed(), and its function was invoked.

Monday, April 28, 2014

Scanning the pixels

Last post I described how you can get a "voidptr" through which you can PEEK the bytes of an image. This is needed to implement zoom-to-width and zoom-to-height buttons on the imageview. The code that I wrote for this in V.1 never felt right. It's a pure CPU-burning FORTRAN-style loop process; even more so than the character or word census of a large text. The scan image for a typical page is several megapixels (the test image used below is about 1800x2700, or 5Mpx) and the only process I could find for determining the margin has to access a good fraction of them. Clicking "To Width" or "To Height" on an older laptop incurs a noticeable pause of a second or more. I want to shorten that.

(Note that in what follows, the code for to-width and to-height is virtually the same, just substituting "top" and "bottom" for "left" and "right". I'm writing about to-width; whatever lessons I learn are immediately applicable to to-height.)

Starting point

Let's look at the code as it is now. The point is to find out how much of the left and right margins of the image are all-white, then scale and center the image to exclude them. To find the left margin, I look at the pixels of each row from left to right, stopping at a black spot. Once I've found a black spot, I never have to scan farther to the right than that point. I keep going to successive rows, hoping to find a black spot even more to the left. After looking at all rows I know the pixel count to the left of the leftmost black spot. The same logic applies to the right margin: look at all rows from right to left, in order to find the rightmost black spot.

An early complication was that finding a single black pixel threw up false positives. Scan images often have one-pixel, even two-pixel "fly specks" outside the text area. So I modified the code to stop only on a dark spot of at least 3 pixels, which added some overhead. I added a heuristic. Noting that many pages have large white areas at the top and bottom, I started the scan with the middle row, hoping to quickly find some black pixels nearer the left margin.

The following code is heavily rewritten and refactored. You can view the original if you like, but don't bother. The original has four nearly-identical loops for scanning the left margin, middle to end then top to middle, then the right margin the same. These loops—I realized yesterday—differ only in the start, stop and step values of their controlling ranges. So I factored the inner loops out to this:

    def inner_loop(row_range, col_start, margin, col_step):
        '''
        Perform inner loop over columns of current margin within rows of
        row_range. Look for a better margin and return that value.
        '''
        pa, pb = 255, 255 # virtual white outside column
        for row in row_range:
            for col in range(col_start, margin, col_step):
                pc = color_table[ ord(bytes_ptr[row+col]) ]
                if (pa + pb + pc) < 24 : # black or dark gray trio
                    margin = col # new, narrower, margin
                    break # no need to look further on this row
                pa, pb = pb, pc # else shift 3-pixel window
        return margin

The key statement is pc = color_table[ ord(bytes_ptr[row+col]) ]. From inside, out: bytes_ptr[row+col] peeks the next pixel value which is an index into the color table. ord() is needed because the voidptr access returns a byte value and (for reasons that escape me) Python will not permit a byte type as a list index. The color_table is a list of the possible GG values from RRGGBB, smaller numbers being darker. I'll talk about it in a minute.

The operation of this code should be fairly clear: slide a 3-pixel window along the pixels of one row up to a limit; when they comprise a dark spot, break the loop and set a new, more stringent limit. Return the final limit value.

Now let's look at the code that sets up for and calls that inner loop.

def find_image_margins(image):
    '''
    Determine the left and right margin widths that are (effectively)
    all-white in an image, returning the tuple (left,right). The image is
    presumed to be a scanned book page in the Indexed-8 format, one byte per
    pixel, where a pixel value is an index into a color table with 32-bit
    entries 0xAARRGGBB (AA=alpha channel).
    '''
    rows = image.height() # number of pixels high
    cols = image.width() # number of logical pixels across
    stride = (cols + 3) & (-4) # scan-line width in bytes
    bytes_ptr = image.bits() # uchar * a_bunch_o_pixels
    bytes_ptr.setsize(stride * rows) # make the pointer indexable
    # Get a reduced version of the color table by extracting just
    # the GG values of each entry. If the image is PNG-1, this
    # gives [0,255] but it could have 8, 16, even 256 elements.
    color_table = [ int((image.color(c) >> 8) & 255)
                     for c in range(image.colorCount()) ]

The first five lines of code set up the voidptr to the image bytes. The stride value is used because the Qt documentation notes that every pixel row occupies a whole number of 32-bit words.

Note the remark about the "presumed" image format? In early testing I forgot to force the image to Indexed-8. The calculation stride * rows yielded about 5MB but that was much larger than the true memory size of a compressed 1-bit PNG file. The result was that the first time the inner loop tried to access a byte of a middle row—Python seg-faulted. Yes, you can bring down, not just your own app, but the whole interpreter, by mis-using a voidptr.

If the image file is really monochrome and stored as PNG-1, the color table will have two entries. But I can't require that or assume that. It will be a PNG but it might be a PNG-8 with actual colors. So make a color table of just the GG values (Green is commonly used as a proxy for pixel brightness) and index it by the pixel value.

Now, to work:

    # Some pages start with many lines of white pixels so in hopes of
    # establishing a narrow margin quickly, start at the middle, go to
    # the end, then do the top half. Begin: left side from the middle down.
    left_margin = inner_loop(
                    range(int(rows/2)*stride, (rows-1)*stride, stride),
                    0, int(cols/2), 1
    )
    # With hopefully narrower margin, scan from the top to the middle:
    left_margin = inner_loop(
                    range(0, int(rows/2)*stride, stride),
                    0, left_margin, 1
                    )
    # Now do exactly the same but for the right margin, taking columns
    # from the rightmost, inward.
    right_margin = inner_loop(
                    range(int(rows/2)*stride, (rows-1)*stride, stride),
                    cols-1, int(cols/2), -1
                    )
    right_margin = inner_loop(
                    range(0, int(rows/2)*stride, stride),
                    cols-1, right_margin, -1
                    )
    return left_margin, right_margin

Each call to the inner loop passes a range of rows to examine—in the form of pre-calculated byte offsets—and the three factors for a range of columns. The end result is the width of the white margins of the page.

Timings of this code

When I use the ctime package to time one application of find_image_margins() to a representative page image, I get these not awfully helpful stats:

         871938 function calls in 1.921 seconds
   Ordered by: internal time
   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        4    1.815    0.454    1.921    0.480 imagetest.py:20(inner_loop)
   871922    0.106    0.000    0.106    0.000 {built-in method ord}
        1    0.000    0.000    1.921    1.921 {built-in method exec}
        1    0.000    0.000    1.921    1.921 imagetest.py:12(find_image_margins)

The remaining lines are all-zero times. So: running under cprofile, this takes almost two seconds to run. On a 2.8GHz CPU, that's roughly six billion instructions. Wow! This would have been completely infeasible on, say, my old Mac Plus where it would have taken something like 20 minutes to complete on its 8 megahertz CPU. On my current Macbook Pro, it's a minor annoyance.

The count of 871,922 calls to ord tells how many pixels were examined in the inner loop. The total time of 1.815 tells how much time the inner loop spent on those pixels, or about 108,000,000 microseconds on 900,000 pixels giving 120 microseconds per pixel examined.

Optimizing

I see two approaches to optimizing this program. One: reduce the 120μsec spent in the inner loop on each pixel. Two: reduce the number of pixels examined in the inner loop. Reducing either number by half would halve the program execution time.

I believe I will spend a bit of time exploring both approaches, and report in the next post.

Tuesday, April 15, 2014

Current Line Revisited

A few days ago I described my progress on editview, but just today I stumbled on a big improvement. Here's how it looks now.

If you click through you find that's a quite large image. The reason is, it's from a retina macbook so what looks like quite a modest window, when captured, comes out 1500px wide. Here are the improvements from the prior version.

  • The current-line highlight now extends the full width of the window. Before it was only as long as the text on that line.
  • Scanno highlighting (the lilac highlights) is implemented. You can load a file of common OCR errors and they are marked wherever they appear.
  • Spellcheck highlighting (wiggly magenta underlines) is implemented, including alternate dictionaries. Note the line with <span lang='fr_FR'>; those words get checked against the french dictionary instead of the default one.

Pretty much all that remains is to finish an automated unit test of these features. I have one simple unit test driver now that uses QTest to automate a number of keystrokes, but I need to also automate exercising a pop-up context menu. That'll be an adventure I'm sure.

In the previous post I kvetched about how, although a QTextBlock has a format (QTextBlockFormat), you could only interrogate it, and modifying it didn't change the format. As a result, what I expected would be a simple way to set a current-line highlight, by setting the background brush of the current text block, didn't work.

Then today, browsing around the QTextCursor documentation, what should my eye fall upon but a setBlockFormat method! You can ask a QTextBlock for its format, but in order to set it, you have to aim a QTextCursor at that block, and then tell the cursor to set the block's format.

Bizarre.

Well, at any rate, not how I'd have designed it. But I didn't, so...

So I realized that my previous method of highlighting the current line using the extraSelections mechanism was over-complicated. I changed the logic to set a background brush on the current block. The cursor-moved logic now reads as follows:

Note: The following is still not the correct way to set a current-line highlight. Do not emulate this code. See this post for the problem with it and a later post for the correct approach.

    def _cursor_moved(self):
        tc = QTextCursor(self.Editor.textCursor())
        self.ColNumber.setText(str(tc.positionInBlock()))
        tb = tc.block()
        if tb == self.last_text_block:
            return # still on same line, nothing more to do
        # Fill in line-number widget, line #s are origin-1
        self.LineNumber.setText(str(tb.blockNumber()+1))
        # Fill in the image name and folio widgets
        pn = self.page_model.page_index(tc.position())
        if pn is not None : # the page model has info on this position
            self.ImageFilename.setText(self.page_model.filename(pn))
            self.Folio.setText(self.page_model.folio_string(pn))
        else: # no image data, or cursor is above page 1
            self.ImageFilename.setText('')
            self.Folio.setText('')
        # clear any highlight on the previous current line
        self.last_cursor.setBlockFormat(self.normal_line_fmt)
        # remember this new current line
        self.last_cursor = tc
        self.last_text_block = tb
        # and set its highlight
        tc.setBlockFormat(self.current_line_fmt)

Friday, April 11, 2014

Why Can't Huns Spell?

Lordy but I hate the kind of work described below. It's really stressful. (I know, kvetch, kvetch, kvetch.)

I'm to the point where I want to test the ability to mark words that fail spellcheck. To do that, I need the ability to check spelling, duh!

PPQT version 1 went through stages of spell-checking, each representing many hours of effort. First, trying to send words over a pipe to Aspell running as a subprocess. Then I wrote my own all-Python spell-checker to use the Myspell/OpenOffice dictionary format. That was a useful learning exercise. I learned:

  • All about the format and content of the .dic/.aff dictionary files.
  • That German is a damned hard language to spell-check.
  • That there are a lot of subtleties to the spell-check algorithms.

In the end German defeated my code. I just couldn't get it to handle multiple affixes properly. In the nick of time I found this Python wrapper for Hunspell. Like a lot of FOSS, it was created by someone who needed it a few years ago, and that person has apparently moved on and left it dangling unmaintained. But it can be made to work, with effort—for Python 2.x. And it was suuu-weet once I got it going, blazing fast and reliable. I made it work for Mac OS and for Linux, but blew many hours failing to make it work for my Windows distribution. Eventually I went on ELance and paid a dude $150 to make the Hunspell wrapper work on Windows. Money well spent.

But PPQT2 is built on Python 3.3 (well, probably 3.6 by the time it's done) and the Hunspell wrapper doesn't work for that. However, another user posted a diff file that, he claimed, made it work with Python 3. So I spent some hours today getting it compiled and installed.

It should be a one-liner, python setup.py install, but of course that don't work because there are things in the setup.py script that assume Linux, and a prior release of Hunspell. So you tweak that a while. Reviewing my notes from last fall, you run python setup.py build and it fails, then you manually run a compile command that works to actually create the module, then python setup.py install will install it. The compile command that worked for 2.7 (contributed to that wiki by another user, bless her heart) was:

gcc -fno-strict-aliasing -fno-common -g -fwrapv\
 -Os -Wall -Wstrict-prototypes -DENABLE_DTRACE -arch x86_64\
 -pipe -D_LINUX -I/usr/include/hunspell\
 -I/usr/include/python2.7 -lpython -lhunspell-1.2\
 -shared hunspell.c -o build/hunspell.so

But that doesn't work after the diff was applied for Python 3. It coughed up an unresolved symbol _PyModule_Create2 for no apparent reason. So, what's a search engine for if not to find obscure error messages? And Da Google turned up many people with this problem dating back to 2010. A stackoverflow response, although not directly responsive, pointed to lack of inclusion of the python3.3 library, and that was it. Here's the command that actually compiles and links hunspell for Python 3:

P=/Library/Frameworks/Python.framework/Versions/Current
gcc -fno-strict-aliasing -fno-common -g -fwrapv\
 -Os -Wall -Wstrict-prototypes -DENABLE_DTRACE -arch x86_64\
 -pipe -D_LINUX -I/usr/local/include/hunspell\
 -I$P/include/python3.3m\
 -L$P/lib -lpython3.3 -lhunspell-1.3 -shared\
 hunspell.c -o build/hunspell.so

So I now have a working Hunspell that I can start playing with, on Mac OS at least. Such a relief! OK, Ghu alone knows what it will take to make it work on other platforms, but that's months away. For now, my Huns can spell.

Thursday, March 27, 2014

Assisting the Upgradement

I got burned by one of what turned out to be quite a list of small incompatibilities between PyQt4 and PyQt5. Just fooling around I tried upgrading one of Mark Summerfield's utilities to PyQt5. It contained the following code:

        path = QFileDialog.getOpenFileName(self,
                "Make PyQt - Set Tool Path", label.text())
        if path:
            label.setText(QDir.toNativeSeparators(path))

Pretty obviously Mark expected getOpenFileName to return a path or a null string. But when executed, and I clicked Cancel in the file dialog, it caused an error in the label.setText statement. Whatever got into path evaluated to True, but wasn't a string.

It turned out to be a tuple with two strings. I documented this to the pyqt mailing list and was embarrassed when Phil just replied with the above link to the list of incompatibilities, one of which is a change to the API of the whole family of five "get..." methods supported by QFileDialog. What had happened to cause this seemingly arbitrary breaking of an existing API? It seems that PyQt4 had introduced some variant methods "to avoid the need for mutable strings". Now these extra "get...Filter" methods were being dropped and their function folded into the basic "get..." methods. And that entailed changing the return value of getOpenFileName from a simple string to a tuple of two strings.

It still seems arbitrary to me, breaking existing code in an unexpected way for no very good reason. But it's a done deal, so how to make sure that this incompatibility, and all the other subtle incompatibilities in the list, don't get overlooked? (And don't miss the fact that one item in the list is open-ended, saying "PyQt5 does not support any parts of the Qt API that are marked as deprecated or obsolete in Qt v5.0." What are those? Are they numerous?)

I decided it wouldn't be hard to write a tool to find and point out all, or anyway a lot of, these issues. In two afternoons of work I put together q45aide.py (click the link to see the Readme and get the code from Github). This is a straightforward source scanner that copies a program and inserts comments above any line that looks as if it will have an upgrade problem.

I'm particularly pleased with two features of this program. One is the way of finding out the modules that contain every Qt class. I needed this because one annoying change from Qt4 to Qt5 is that many classes moved from one import module to another. That invalidates most existing from PyQt4.module import (class-list) statements. I wanted to generate correct, minimal import statements from the class-names used in the program. But that meant having a dictionary whose keys were all the valid Qt class-names (over 880 of them, it turns out) and whose values were the module names that contain them.

I pondered quite a while over how to get such a list of class-names by module. I thought about manually or programatically scraping some pages from qt-project.org. But finally I realized, I could build a complete, accurate list dynamically in the program.

When you import a module, Python creates a namespace. And the names defined in a namespace can be interrogated by querying namespace.__dict__. So the program contains code like this:

    def load_namespace( ):
        global module_dict, import_dict
        # pick off QtXxxx from "PyQt5.QtXxxx"
        module_name = namespace.__name__.split('.')[1]
        for name in namespace.__dict__ :
            if name.startswith('Q') : # ignore e.g. __file__
                module_dict[name] = module_name

    import PyQt5.Qt as namespace ; load_namespace()
    import PyQt5.QtBluetooth as namespace ; load_namespace()
    ...

This loads module_dict with exactly the 880+ class-names related to their include modules, automatically updating should PyQt5 be updated with new or changed class-names.

The other thing that I got a kick out of writing was the way to write a list of class-names in either of two formats, in one statement. The program will generate one "from PyQt5.modulename import (class-name-list)" for each module that the input requires. A program option is -v, asking for the list to be stacked vertically. The only difference is that the class-name-list is either punctuated with comma-space, or with comma-newline-indent. And this is how it comes out:

                out_file.write('from PyQt5.{0} import\n   ('.format(mod_name))
                join_string = ',\n    ' if arg_v else ', '
                out_file.write(join_string.join(sorted(class_set)))
                out_file.write(')\n')

Badda-boom.

Tuesday, March 18, 2014

Unknown Unknowns

Yesterday I checked off pagedata as coded and tested. That's the last remaining support or background module needed to allow the editor to run. So the next thing to tackle is editview.py, the visual face of the editable document. I imagine this as a widget containing, principally, the QPlainTextEdit, and below it a bar with five items:

  • A QLabel containing the filename of the document. This will change its font style with the document's modified status, becoming perhaps bold and magenta when a save is needed (for the document or for metadata).
  • A QLabel with the current folio number—a label because it isn't changeable by the user; folios are changed by modifying the folio rules in the Pages panel.
  • A numeric text entry field with room for four digits, displaying the scan image number corresponding to the cursor position. Editable; you can type a new number to effect a jump.
  • A numeric text entry field with room for 6 digits, displaying the current text block (line) number, updating as the cursor moves. Again, type a new number to jump in the document.
  • A QLabel with the current "column" number in the current line. Not editable. (Use an arrow key or just click.)

The latter four items of course update dynamically as the cursor moves, on receipt of the cursor movement signal from the editor.

So, if I understand all this (not difficult given it's almost the same as the status bar area of the PPQT main window), where are the Unknown Unknowns of the title? Hah. Well, they are actually known, at least by category, but just the same I feel anxious about launching into this phase. They are:

1. Using Designer

The Qt Designer is a graphic tool for designing a layout. I played with it a bit a couple of years ago when starting PPQT but ended up doing all my widget layouts "by hand" with explicit code like this (one of the simplest ones)

        vbox = QVBoxLayout()
        # the image gets a high stretch and default alignment, the text
        # label hugs the bottom and doesn't stretch at all.
        vbox.addWidget(self.txLabel,0,Qt.AlignBottom)
        vbox.addWidget(self.scarea,10)
        vbox.addLayout(zhbox,0)
        self.setLayout(vbox)

It makes the __init__() rawther lengthy. With Qt Designer you supposedly separate your UI design from the code. Designer saves a file of UI info; you apply a PyQt utility to convert this into something Python can execute; you import it and execute it. Covered in Summerfield's book and in the Qt docs. But I have lots of questions, like: how are signals connected between elements; how are elements connected to the methods that update them; how do label texts set in Designer get tr() translated; just generally a fog of unknowns. But I'd like to give it a try and the editview widget should be a good test case.

I18N

Does anybody use that term any more? "I18N" was a thing back in the 80s, late 70s even at IBM. (It means "Internationalization", duh.) Anyway, I've committed to PGDP Canada that PPQT2 will be translatable. Meaning every damn user-visible text string has to be wrapped in a tr() call. And editview is the first module that has user-visible strings (log messages don't count). The tr() call is all I know about. I am anxious about the whole rest of Qt's I18N system. How do the tr'd strings get collected; how does a translator create an alternate translation; will I have to start using Qt Make; what the heck is Locale and how do I control it for testing purposes... gaaahhh. Much reading to do.

GUI Unit Tests

With this first UI module I enter the world of automated UI testing and I haven't a clue. Well, one clue: QTest. There's a multi-chapter writeup on QTest and simulation of GUI events. I presume I'll use that. There are third-party packages like FrogLogic's "Squish" but they are very expensive, at least that on is. There are open-source packages for test automation but the ones I've seen are single-platform. So I suppose I'll be rolling my own using QTest. But I really have no idea.

So: the known Unknowns are just packed with unknown unknowns. I will be learning as I go, and I will be posting what I learn to this blog. Because that's one way I have of consolidating what I've learned. You're welcome.

Sunday, March 16, 2014

Getting useful info from QFontDatabase

I'll get back to the Last(goddam)Resort font issue shortly. But in learning about it I've had to look a little closer at what Qt knows about fonts, and as a result I've got a function that might be useful to others.

The QFontDatabase object contains whatever Qt knows about fonts that are "available in the underlying window system". However it makes this information available in a rather awkward way. You can call its families() method to get a list of all font names:

from PyQt5.QtWidgets import QApplication
from PyQt5.QtGui import QFont, QFontDatabase
the_app = QApplication([])
f_db = QFontDatabase()
for family in f_db.families():
    print(family)

My macbook is rather over-endowed with fonts, and this prints a list of 238 names.

You can also ask for the recommended or default fixed and "general" fonts,

qf_fixed = f_db.systemFont(QFontDatabase.FixedFont)
print( 'Fixed font:',qf_fixed.family() )
qf_general = f_db.systemFont(QFontDatabase.GeneralFont)
print( 'General font:',qf_general.family() )

Which on my macbook prints

Fixed font: Monaco
General font: .Lucida Grande UI

Here's a surprise: what the heck is that leading dot doing in the name of the general font? There is definitely no font named ".Lucida Grande" in the list of families, nor displayed by the Fontbook app, nor (using find /System ".Lucida*') in the System folder. However, the font db will create a QFont for it:

>>> lgf = f_db.font('.Lucida Grande UI','',12)
>>> lgf.family()
'.Lucida Grande UI'
>>> lgf.pointSize()
13

So it's real in some sense, and your code can use it. Moving on...

You can ask the font db things about any single family name, for example isFixedPitch(family). But font family names aren't consistent from system to system. And often what you want is a font that meets certain criteria, such as: it's "Times" by some name, or it has a bold or italic variant, or it scales to 36pt. So I put together this little function that will return a (possibly empty) list of fonts that meet given criteria:

def font_filter( name = None, style = None, fixed = False, sizes = [], language = None ) :
    db = QFontDatabase()
    selection = db.families()
    if name :
        selection = [family for family in selection if name in family ]
    if style :
        selection = [family for family in selection if style in db.styles(family) ]
    if fixed :
        selection = [family for family in selection if db.isFixedPitch(family) ]
    if language :
        selection = [family for family in selection if language in db.writingSystems(family) ]
    if sizes :
        size_set = set(sizes)
        selection = [family for family in selection
                     if size_set.issubset( set( db.smoothSizes(family, '' ) ) ) ]
    return selection

For example,

>>> print( font_filter( name = 'Helvetica' ) )
['Helvetica', 'Helvetica CY', 'Helvetica Neue']
>>> print( font_filter( fixed = True ) )
['Anonymous Pro', 'Courier', 'Courier New', 'Inconsolata', 'PCMyungjo', 'PT Mono']
>>> print( font_filter(fixed = True, style='Bold', sizes=[12, 18]) )
['Courier', 'Courier New', 'PT Mono']
>>> font_filter(fixed=True, language=QFontDatabase.Hebrew)
['Courier New']

Next time: back to the issue of the Last(goddam)Resort font problem.

Thursday, March 13, 2014

Trapping Qt log messages

Using PyQt5, you can install your own handler for Qt's log messages and do with them as you wish, for example diverting them to a Python log file. There are (as usual) some surprises, but by and large, it works.

The context is CoBro, my little web-comic browser. It uses QWebKit to display single HTML pages. After displaying one particular comic (Two Guys and Guy) the webkit code likes to emit a couple of log messages like "error: Internal problem, this method must only be called once." Annoying since there is nothing you can do about it, and it doesn't seem to cause any harm. (BTW this is a known problem, see Qt Bug #30298.)

So I add the following code to Cobro, after creating the App and the main window and everything and we are just about ready to show the main window and enter the event loop:

    from PyQt5.QtCore import qInstallMessageHandler, QMessageLogContext
    from PyQt5.Qt import QtMsgType

    def myQtMsgHandler( msg_type, msg_log_context, msg_string ) :
        print('file:', msg_log_context.file)
        print('function:', msg_log_context.function)
        print('line:', msg_log_context.line)
        print('  txt:', msg_string)

    qInstallMessageHandler(myQtMsgHandler)

Now what comes out on stderr is this:

file: access/qnetworkreplyhttpimpl.cpp
function: void QNetworkReplyHttpImplPrivate::error(QNetworkReplyImpl::NetworkError, const QString &)
line: 1929
  txt: QNetworkReplyImplPrivate::error: Internal problem, this method must only be called once.

Ta-daaaa! We have intercepted a Qt log message and analyzed it to show where in the Qt code it originated. One surprise is that the "function" member of the QMessageLogContext object is not a simple function name, but the full C++ signature. Another surprise is that, when I stop on this code in a debugger and look at the msg_log_context item, its members are not strings but "sip reference" objects. Nevertheless by the magic of PyQt5 they print as strings.

Well, printing this bumf isn't a lot of use. What would be more useful, is to divert it into the Python log stream, like this:

    def myQtMsgHandler( msg_type, msg_log_context, msg_string ) :
        # Convert Qt msg type to logging level
        log_level = [logging.DEBUG,
                     logging.WARN,
                     logging.ERROR,
                     logging.FATAL] [ int(msg_type) ]
        logging.log(logging.DEBUG,
                    'Qt context file is '+msg_log_context.file
                    )
        logging.log(logging.DEBUG,
                    'Qt context line and function: {0} {1}'.format(
                        msg_log_context.line, msg_log_context.function)
                    )
        logging.log(log_level, 'Qt message: '+msg_string)

In other words, log the gritty details of the QMessageLogContext at the DEBUG level, but log its actual text at its own self-assigned severity, as translated into Python logging's values. The above code works and now I can redirect QWebKit's annoying messages into CoBro's log file.

Tuesday, March 11, 2014

Making a bad QTextCursor and a promising find

Working today on the unit testing and code finalization of pagedata, the module that keeps track of where the scanned OCR pages of a book each start. This module acts as the data model for several clients: every time the user moves the edit cursor, a bunch of different widgets will ask pagedata "which page is the cursor on now?" The imageview module asks it for the filename of the .png file to display, the scan# and folio# widgets under the edit window ask it for those items, and of course the Pages panel calls on it for the rows of data it displays as a table.

I thought I'd finalized the code but of course as soon as I started adding test calls into it from its unit-test module I found not only bugs but also things I hadn't thought of. It's schizophrenic, flipping back and forth between coding and testing. "What will it do if I throw it this?" the tester asks, and the coder is thinking "Oh shit, why didn't I plan for that?"

Anyway, one of the pieces of crap the test-monkey in me flang at the pagedata module opened a wonderful new prospect in Qt error control! It went down like this.

Important method in the PageData object is read_pages, which processes the page-data lines from the .meta file. When a book is saved, all the metadata goes in the bookname.meta file, including everything we know about page boundary locations. So at load time, read_pages gets called to rip through these saved lines and rebuild the page table as it was when the book was saved.

There are six items in each line, the first being the character offset to the start of the page. That gets turned into a QTextCursor so that Qt will maintain the position as it changes under user editing actions. The code is simple:

try:
    (P, fn, pfrs, rule, fmt, nbr) = line.split(' ')
    tc = QTextCursor(self.document)
    tc.setPosition(int(P))

and so forth. The test case had already flung a non-integer position P, and the failure of int(P) was caught by the try/except fine. So the next nastiness was a bad position value, first 1000000, much larger than the document, next -1. But neither of these tripped an exception! All that happened was that a message appeared on stderr, "QTextCursor::setPosition: Position '100000' out of range" and the QTextCursor was unchanged.

This opened two new questions: (1), how the heck can read_pages detect that it got a bad position?, and (B), how can we avoid having that message, about an error we've anticipated and dealt with, cluttering up stderr and getting the user all upset?

It was too late in the day to investigate (1), but (B) is a problem I've been plagued by for a long time. Qt is just full of unhelpful debugging errors. My other app, a simple web browser based on QWebKit, likes to throw out stuff like this:

QEventDispatcherUNIXPrivate(): Unable to create thread pipe: Too many open files
QEventDispatcherUNIXPrivate(): Can not continue without a thread pipe
QNetworkReplyImplPrivate::error: Internal problem, this method must only be called once.

And I've been wondering in a dazed sort of way if there mightn't be some way to stifle those. But PPQT2 is using proper Python logging. Every module creates its own logger and writes diagnostics, warnings and errors with the logging API. And I definitely plan not to let those log messages dribble out on stderr; they will eventually go into a file.

So it suddenly occurred to me, is there maybe some way to divert the Qt log messages into the Python logging system? I wasn't aware of anything but I started browsing in the index of the Qt Assistant and turned up this: qInstallMessageHandler. This appears to offer a way to accept and process all of Qt's messages.

I'm quite excited about this. If it is accessible under PyQt5, I see a clear path to capturing all Qt messages and converting them into Python log entries. That will let me stifle the WebKit chatter from my comics browser and also this text cursor message in PPQT. Tomorrow I spend the day at the museum but thursday afternoon I get to dig into this!

Tuesday, February 18, 2014

Model-View design and user expectations of performance

PPQT presents

  • A table of the words in the document with their properties such as uppercase, numeric, misspelled.
  • A table of the characters in the document with their counts and Unicode categories
  • A table of the book pages, derived from the original PGDP page-separator lines

Each of these tables is derived from a "census" in which every line, word-token, and character in the document is counted. In v.1 this census is done the first time a book is opened, and any time after when the user needs to "refresh" the display of word or character counts. It's very time-consuming, 5 to 20 seconds for a large book. Getting the time down for v.2 would be a good thing. So would avoiding a big delay during first opening of a new book.

In v.1 the census is done in one rather massive block of code that fetches each line from the QTextDocument in turn as a QString and parses each, counting the characters and using a massive regex to pick out wordlike tokens. This process is complicated by:

  • The need to handle the PG codes for non-Latin-1 chars such as [oe].
  • The need to recognize HTML-like productions: some like <i> and <sc> are common from the start, and later in the book-production process there might be thousands of HTML codes; we count them for characters but not for "words".
  • But also the need to spot the lang=code property embedded in HTML codes, to signal use of an alternate spelling dictionary.

For v.2 I want to break up the management of all these metadata along MVC lines, with a "data" module and a "view" module for each type, so worddata.py manages the list of words while wordview.py contains the code to present that data using a QTableView and assorted buttons. Similarly for chardata/charview and pagedata/pageview. But will this complicate the census process? Will it slow it down?

Complicate it? Not exactly; more like "distribute" it. I will move each type of census to its data model: worddata will take a word census, chardata a char census, pagedata a page census. So a full census could potentially entail three passes over the document.

However, when this separation is done, it becomes clear that the only census that really needs to be done the first time a book is opened, is the page census. That's because the module that displays the matching page scan image as the user moves through the text, needs to know the position of each page's start. In other words, pagedata is the data model for both the page table and the image-display panel. Images need to be displayed immediately, so the page data needs to be censused the first time a book is opened.

The word and char censii, however, can wait. The char data is the model only for the Char panel. If that panel is showing an empty table, the user knows to click its "Refresh" button to make a char census happen, so the table updates.

The word data is the model for the Word panel, and again, if the user opens a new book and goes to the Word panel, and sees an empty table, it's a no-brainer to click Refresh and update the table. In either case, the user knows they've asked for something, and should be content to wait while the progress bar turns and the census finishes.

The word data is also the model, however, for the display of misspelled words with a red underline, and the display of "scannos", highlighted document words that appear in a file of likely OCR errors. These features of the editor are turned on with a menu choice (? or perhaps a check box in v.2? TBS). If either highlighter is set ON when a new book is opened, the highlights won't happen because the word data isn't known until a census is taken.

Easy solution: we know when we are opening a new book (we don't see a matching metadata file from a prior save), and in that case we force OFF the spellcheck and scanno highlight choices. Then if/when the user clicks spelling or scanno highlights ON, we can run a census at that time. Again the potentially slow process is initiated by an explicit user action.

What about (perceived) performance? It should be snappier. If you Refresh the Chars panel it will rip through the document counting characters, but not spend time on the big word-token regex html-skipping process. Refresh the Words panel and its census will at least not be slowed by counting characters.

Great, but I already started coding worddata on the assumption it would base both chars and words. Now I have to split it up.

Monday, February 17, 2014

Python logging and unit testing

PPQT 2 is to be pretty much a complete rewrite of version 1. I built the first version in an ad-hoc way, adding features one at a time to the basic editor, and as a result its software structure is rather ramshackle. Information about different data structures and formats leaks all over. So now I know where it's going, the next version can be properly compartmentalized and structured.

And better-tested! V1 got "tested" by my using it. V2, I am determined, will have a separate unit-test driver for each module, and every added function means adding test code to exercise it. We be professional here!

And logging! V1 has no logging of any kind. There may be one or two places where an except clause has a print statement in it (blush) but that's it. So I read up on Python logging, and each module will have its named logger and log some occasional INFO lines, always WARN lines where the module is working around some problem, and occasionally ERROR lines.

So the first module finished (yay!) is metadata.py and it has several places where it detects and logs errors. So how, in the matching metadata_test.py, can I test whether the module wrote the expected thing to the log?

There may be better ways, but this is how I'm doing it. First, at the top of the test module is this, which I expect will be boilerplate repeated in every test driver.

# set up logging to a stream
import io
log_stream = io.StringIO()
import logging
logging.basicConfig(stream=log_stream,level=logging.INFO)
def check_log(text):
    global log_stream
    "check that the log_stream contains text, rewind the log, return T/F"
    log_data = log_stream.getvalue()
    x = log_stream.seek(0)
    x = log_stream.truncate()
    return (-1 < log_data.find(text))

During execution of the unit test, log output is directed to an in-memory stream. In the test code, the module under test is provoked into seeing an error that should cause it to write a log line. Then you can just code assert check_log('some text the test should have logged'). The assertion fails if the string isn't in the log. If it succeeds, execution continues with the log cleared out for the next test.

Looking at it now, I think maybe check_log() should take two parameters, the text and the level, so as to verify that the message is at the expected level:

assert check_log('whatever',logging.WARN)

I'll leave that as an exercise. Meaning, I'm too lazy to do it now.

Incidentally, another goal of V2 is to have localized (e.g. translated) text in the visible UI. Perhaps log messages should also be translated but... nah.