Portfolio: Font Lister

By
This content is obsolete and is unlikely to be brought up to date. It might be of historical interest, but you have been warned.

View raw: font_lister.py

#!/usr/bin/env python2
# -*- coding: utf-8 -*-
# 
# Copyright by Scott Severance
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.

################################################################################
# Invoke this program with --help for an abbreviated help message.             #
#                                                                              #
# Compatibility note: This program has only been tested with Python 2. It will #
# not work without modification in Python 3.                                   #
################################################################################

'''
Tool for synchronizing fonts installed on different Ubuntu machines

Consider the following scenario: You have multiple Ubuntu machines (other
OSes that use the APT package management system might work as well, but they
haven't been tested). You need to have a common set of fonts on all the machines
and you want each machine to have every font installed on at least one other
machine. Furthermore, each machine has write access to a common shared
directory.

If the above scenario describes your situation, then this tool can help. Using
it is a two-step process:

1. Invoke this tool on each machine, passing the --write option and a target
   directory (which will be created if it doesn't exist). The best target is one
   that all the machines concerned can write to. This will produce a file  in
   the target directory named after the machine's hostname which contains a list
   of all[1] the font packages installed on the system.

   [1]: This tool doesn't recognize absolutely all font packages. Some tex/latex
        and Type 1 fonts aren't included. This is because the package names are
        inconsistent and I don't care about those fonts anyway. If you care
        about them, you'll have to manually add them to installed_fonts().

2. Invoke this tool a second time on each machine, passing the --read option. It
   will print a list of font packages that need to be installed in order to
   bring the machine up to date. The easiest way to do this is to make the
   output the input to apt-get. See the example below.

EXAMPLE
-------
This example assumes the following:
1. Each machine is accessible via SSH
2. font_lister.py is already available on each machine's $PATH
3. The common shared directory is available to each machine on the path
   /media/shared/font_lister
4. The machine hostnames are foo, bar, and baz

You might throw together a bash script similar to the following:

#!/bin/bash
machines="foo bar baz"
for i in $machines; do
  ssh "$i" font_lister.py -w /media/shared/font_lister
done
for i in $machines; do
  ssh "$i" 'bash -c "sudo apt-get install $(font_lister.py --read /media/shared/font_lister)"'
done
'''

import apt
import argparse
import os
import re
import socket
import sys

def installed_fonts():
    '''
    Returns a list of the font packages installed on the system.

    Some tex/latex fonts are missing from this list. The package names are
    inconsistant and thus difficult to list, and I don't care about tex/latex
    anyway. Also, some Type 1 fonts are missing for the same reason.
    '''
    regex1 = re.compile(r'^([x]?fonts|ttf|language-support-fonts|cm-super|ko.tex|ming-fonts|texlive-fonts)-')
    regex2 = re.compile(r'-fonts$')
    others = ('culmus','culmus-fancy','gsfonts-x11','lmodern','cm-super',
              'fonty-rg','gsfonts-other','jsmath-fonts-sprite','konfont',
              'msttcorefonts','otf-freefont','tex-gyre','ubuntu-studio-font-meta',
              'unifont','vfdata-morisawa5')
    return [i.name for i in apt.Cache() if i.is_installed and (
            i.name in others or regex1.search(i.name) or regex2.search(i.name))]

def write_file(target_dir, data):
    '''
    Writes data to a file in target_dir.

    The file is called hostname.fonts.list, where hostname is the machine's
    hostname as reported by socket.gethostname().
    '''
    output_name = os.path.join(target_dir, socket.gethostname() + '.fonts.list')
    try:
        with open(output_name, 'w') as f:
            f.write(data)
    except IOError:
        error_exit('Failed to write the file "%s".' % output_name)

def error_exit(msg, status=1):
    '''
    Prints an error to stderr including msg, then exits with the given status.
    '''
    sys.stderr.write('ERROR: ' + msg + '\n')
    sys.exit(status)

def write_installed_fonts(target_dir, fonts):
    '''
    Prepares to write the fonts, one per line, to target_dir, creating it if necessary.
    '''
    target_dir = os.path.abspath(target_dir)
    data = '\n'.join(fonts)
    if os.path.exists(target_dir):
        if os.path.isdir(target_dir):
            write_file(target_dir, data)
        else:
            error_exit('Target destination is not a directory.')
    else:
        try:
            os.makedirs(target_dir)
        except OSError:
            error_exit('Could not create the directory "%s".' % target_dir)
        write_file(target_dir, data)

def list_fonts_to_install(target_dir):
    '''
    Returns a list of fonts to be installed to bring this machine into sync.

    This function reads all the ".fonts.list" files in target_dir, merges them
    into a single list, weeds out duplicates, and removes those fonts that are
    already installed on this system. It then returns a list containing the
    remaining fonts, which represents the fonts that need to be installed in
    order to synchronize this machine.
    '''
    target_dir = os.path.abspath(target_dir)
    name_re = re.compile(r'\.fonts\.list$')
    try:
        machines = [open(os.path.join(target_dir, i)).read().split('\n') for i in os.listdir(target_dir) if name_re.search(i)]
    except IOError:
        error_exit("Couldn't read one or more files in \"%s\"." % target_dir)
    except OSError:
        error_exit('The directory "%s" doesn\'t exist or is otherwise inaccessible.' % target_dir)
    all_fonts = []
    # flatten the multiple lists in machines into a single list:
    [[all_fonts.append(i) for i in j] for j in machines]
    all_fonts = sorted(list(set(all_fonts))) # remove duplicates
    local_fonts = installed_fonts()
    return [i for i in all_fonts if i not in local_fonts]

def main():
    '''
    Processes the command line arguments and takes action as directed by those options.
    '''
    parser = argparse.ArgumentParser(
             description='A tool for synchronizing the fonts installed on ' +
             'different Ubuntu machines. You MUST specify either --write or ' +
             '--read.')
    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument('-w', '--write', metavar='target_dir',
                       help='Write the installed fonts to a file in target_dir, ' +
                       'which will be created if necessary. This directory ' +
                       'shouldn\'t be used by any other program.')
    group.add_argument('-r', '--read', metavar='target_dir',
                       help='Read all machines\' installed fonts and prepare a ' +
                       'list of fonts to install on this machine to bring it in ' +
                       'sync. Specify target_dir, which is the directory where ' +
                       'the data files live.\nWARNING: The data files aren\'t ' +
                       'checked to ensure that they don\'t contain malicious ' +
                       'or invalid contents. You have been warned.')
    args = parser.parse_args()
    if args.write: # we were called with --write
        write_installed_fonts(args.write, installed_fonts())
    else: # we were called with --read
        print '\n'.join(list_fonts_to_install(args.read))

if __name__ == '__main__':
    main()