#!/usr/bin/env python
'''Creates an RPM package from an installed Slackware package

$Id: slackrpm.py,v 1.9 1999/02/27 23:08:07 dan Exp $

$Log: slackrpm.py,v $
Revision 1.9  1999/02/27 23:08:07  dan
Changed shell to /usr/bin/env python. Upped version number to 0.3

Revision 1.8  1998/07/13 20:25:43  dan
Separated dirprefix() and guessslacklocation() into separate functions.
Normalized all Slackware files to support improper packages with paths
starting with './'.

Revision 1.7  1998/07/07 18:01:29  dan
Changed filter to use lookslikedir()

Revision 1.6  1998/07/07 17:49:46  dan
Bumped version to 0.2.3. Moved isconfigfile(), isdocfile(), findver() to
classifyfile.py and renamed.

Revision 1.5  1998/07/07 17:16:15  dan
Removed --justdb comment (was added to rev. 1.4)

Revision 1.4  1998/07/02 18:33:05  dan
Added -L option to show software license. Consolidated printing
and executing system() calls by putting the command into a string.

Revision 1.3  1998/06/12  20:06:07  dan
Added slackrpm version number in Packager: line; added 'k' Slackware disk
set; removed / from description of 'tcl' disk set.

Revision 1.2  1998/06/12  20:04:00  dan
Added Id and Log keywords.

97-01-14
96-11-30
96-10-20 Dan Fandrich <dan@fch.wimsey.bc.ca>

Notes:
	Slackware source packages are not supported

	Typical usage is 'slackrpm -C packname'

	Occasionally, a Slackware package will be specified wrong and a file
	  will be listed both as a normal file and as a soft link. RPM will
	  complain. Just delete one of the lines (they will be identical in the
	  spec file)

	Slackware packages must have been installed in the root directory.

	To abort with -C, add a bogus filename to the %files during the
	  editing phase

To do:
	[--rcfile fn] 	use fn instead of /etc/rpmrc (remember to give to rpm)
	[-V version]	version of package (default guessed from description)
	[-N vendor]	vendor
	[-D distribution] distribution (e.g. 'Slackware 3.0')
	[-R release]	release number (default 1)

	Handle SIGINT gracefully

	Use rpmfilename line from rpmrc file

	Remove leading './' from Slackware paths, if found
	
	maybe needn't add sl- prefix; RPM will complain if a new package
	 with the same name is installed and user can manually check it, right?
	 - maybe recommend that only system-type packages have sl- prepended
	 - prepending sl- to everything makes it obvious that it's a Slackware
	   package and only means that the -U upgrade function isn't usable
	 - when installing converted RPM packages for multi-function Slackware
	   packages (like basic stuff, or networking), must use the --force
	   option when installing an RPM subset package
	
	what happens to an installed %config file when a upgraded package
	  doesn't replace it with a new one?
	  - probably backed up to *.rpmsave and erased--upgrading
	    is like installing, then erasing
'''

usage_msg = '''\
Convert a Slackware package to RedHat RPM format.

usage: %s [-bdeilrsxyCL] [-p prefix] package ...
 -b         build RPM package
 -d         list known Slackware disk sets
 -e         edit spec file after creation
 -i         install RPM converted package
 -l         list installed Slackware packages
 -p prefix  add prefix to RPM package names (default '%s')
 -r         remove RPM package file after successful install (requires -i)
 -s         write spec files in SPECS directory instead of to stdout
 -x         exclude install script (from %%files, %%post and %%preun sections)
 -y         overwrite spec file if it exists
 -C         convert package; equivalent to -sebir
 -L         display software license
  package   Slackware package name or path to package file
'''

license = '''\
   Copyright 1997-1998 by Daniel Fandrich <dan@fch.wimsey.bc.ca>
   This program is free software; you can redistribute it and/or modify
   it under the terms of the GNU General Public License as published by
   the Free Software Foundation; either version 2, or (at your option)
   any later version.

   This program is distributed in the hope that it will be useful,
   but WITHOUT ANY WARRANTY; without even the implied warranty of
   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
   GNU General Public License for more details.

   You should have received a copy of the GNU General Public License
   along with this program; if not, write to the Free Software
   Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
'''

version_msg = 'slackrpm ver. 0.3'

from string import strip, digits, atoi
from os import popen, stat, path, system, rename, unlink, error
from getopt import getopt
import regex
import sys
import posixpath

from slackpack import SlackPack
from rpmrc import RPMrc
from classifyfile import *
from danutil import findopen, NoSuchFile, printerr, getenviron, nullfile, \
				    editfile

FileExistsError = 'FileExistsError'
OSError = error		# "error" is too generic, so rename and
del error		# get rid of it

# Set global options

# Directory containing Slackware administration directories
slack_dir = '/var/adm'		# Slackware < 3.0
#slack_dir = '/var/log'		# Slackware >= 3.0
slack_dir = getenviron('ADMIN_DIR', notdefined=slack_dir)

# Directory containing Slackware package files
slack_packages = getenviron('PACKAGES', notdefined=slack_dir+'/packages')

# Directory containing Slackware installation scripts
slack_scripts = getenviron('SCRIPTS', notdefined=slack_dir+'/scripts')

# Default string to prepend to RPM package names
def_package_prefix = 'sl-'

rpmconfig = RPMrc()	# read RPM configuration

# Map from Slackware disk set abbreviations to full names
slackware_sets = {
	'a'	:'Base',
	'ap'	:'Applications',
	'd'	:'Development',
	'e'	:'Emacs',
	'f'	:'FAQs',
	'i'	:'Info',
	'iv'	:'Interviews',
	'k'	:'Kernel',
	'n'	:'Networking',
	'oi'	:'Object Builder',
	'oop'	:'OOP',
	'q'	:'Alpha Test Kernel',
	't'	:'TeX',
	'tcl'	:'Tcl Tk',
	'x'	:'X11',
	'xap'	:'X11 Applications',
	'xd'	:'X11 Development',
	'xv'	:'Xview',
	'y'	:'Games'
}


def findlinkline(l):
	'''See if the line from an install script creates a symbolic link

	Returns a tuple of the (sourcename, linkname) or None if the command
	  causes no important side effects (for our purposes).
	Raises ValueError if the command is unknown (and may cause side effects)
	This works on Slackware installation scripts that consist mainly
	  of rm and ln commands used to create symbolic links.
	Note that the sourcename may not be normalized.

	e.g.	'( cd usr/bin ; rm -rf cat )' => None
		'( cd bin ; ln -sf  gzip gunzip )' => ('bin/gzip', 'bin/gunzip')
		'( cd usr/bin ; ln -sf  ../../bin/gzip gunzip )' =>
			('usr/bin/../../bin/gzip', 'usr/bin/gunzip')
	'''

	rmline_re = regex.symcomp('^( *cd +\(<direct>[^ ]+\) *; *rm +-r?f? +\(<file>[^ ]+\) *)$')
	if rmline_re.search(l) >= 0:
		# Ignore rm commands
		return None

	lnline_re = regex.symcomp('^( *cd +\(<direct>[^ ]+\) *; *ln +-sf? +\(<source>[^ ]+\) +\(<dest>[^ ]+\) *)$')
	if lnline_re.search(l) >= 0:
		# This command removes the soft link created by the
		# ln -s command. This can create a dangerous command
		# if the ln command was bad and tried to create a
		# link with a wildcard name (should never happen)
		# The rm command uses '--' if the file name starts
		#  with '-'
		direct = lnline_re.group('direct')
		source = lnline_re.group('source')
		file = lnline_re.group('dest')
		return posixpath.join(direct,source),posixpath.join(direct,file)

	# Commands we can safely ignore
	ignoreline_re = regex.symcomp(
		'^#\|'			# comments
		'^echo \|'		# echo commands
		'^crunch \|'		# crunch commands
		'^dialog .*\(--msgbox\|--infobox\|--textbox\)\|' # fancy echo
		'^#! */bin/sh\|'	# /bin/sh specifier
		'^[A-Za-z0-9_]+='	# setting an env. variable
	)
	if ignoreline_re.search(l) >= 0:
		return None

	raise ValueError, 'Command cannot be ignored'


def convertinstalllinks(installscript):
	'''Search install script to find commands which create symbolic links

	Returns a list of tuples (file,linkfile) corresponding to the lists
	  created in the script.
	Raises ValueError if part of the install script isn't recognized.
	This is designed to handle Slackware installation scripts only.

	installscript is a readlines() style list
	'''

	links = []
	in_crunch = None	# whether we're in the crunch() definition
	for l in installscript:
		# Skip over the crunch() Bourne shell command definition
		#   which always does stuff we can ignore during an uninstall
		if strip(l) == 'crunch() {':
			in_crunch = 1
			continue
		if in_crunch:
			if strip(l) == '}':
				in_crunch = None
			continue

		linkline = findlinkline(strip(l))
		if linkline:
			links.append(linkline)
	return links

def reverseinstallscript(installscript):
	'''Reverse effects of an install script to create an uninstall script

	This is designed to handle Slackware installation scripts only.
	As soon as it encounters a command it does not know how to handle, it
	  stops and inserts a warning message.

	installscript is a readlines() style list
	'''

	revscript = []
	in_crunch = None	# whether we're in the crunch() definition
	for l in installscript:
		# Skip over the crunch() Bourne shell command definition
		#   which always does stuff we can ignore during an uninstall
		if strip(l) == 'crunch() {':
			in_crunch = 1
			continue
		if in_crunch:
			if strip(l) == '}':
				in_crunch = None
			continue

		try:
			link = findlinkline(strip(l))
		except ValueError:
			# This printerr should really be done by the caller
			printerr("Warning: package won't be able to be fully uninstalled")
			revscript.append('echo "Warning: remnants of the package ' \
					 'may still exist on the system"\n')
			break
		if link:
			linkfrom,linkto = link
			if linkto[0] == '-':
				dashsep = '-- '
			else:
				dashsep = ''
			unline = 'rm -f '+dashsep+linkto+'\n'
			revscript.append(unline)
	return revscript

def guessslacklocation(location):
	'''Guess at what diskset a package came from, whether the package was
	custom (non-Slackware), based on the package location string.

	returns: location or '' if from a non-Slackware supplied package
			 copyright
	'''
	copyright = 'unknown'
	if location:
		if location[-4:] == '.tgz' or \
		   location[-7:] == '.tar.gz' or \
		   location[-6:] == '.tar.z' or \
		   location[-6:] == '.tar.Z':
			location = 'Unknown'
		else:
			if location[:4] == 'disk':
				# 'diskXX' means an original Slackware package
				location = location[4:]
				copyright = 'distributable'

			# remove trailing disk number, if present
			if location and location[-1] in digits:
				location = location[:-1]
			if location and location[-1] in digits:
				location = location[:-1]
			try:
				location = slackware_sets[location]
			except KeyError:
				#location = 'Unknown'
				pass
	return location, copyright

def slack2spec(slack,outfile,postinstallfile=None,prefix=''):
	'''Converts Slackware package info to RPM spec format

	slack is an instance of SlackPack
	outfile is an output file
	postinstallfile is an optional post-installation script file
	prefix is a string to prepend to the package name

	Returns tuple of the Version: and Release: strings used, which
	 can be used to recreate the rpm binary file name
	'''
	o = outfile
	release = '1'
	distribution = 'Slackware'

	# Go through Slackware files list and remove directories
	onlyfiles = filter(lambda x: not lookslikedir(x), slack['files'])

	# Normalize and make absolute each Slackware file name
	onlyfiles = map(path.normpath, map(lambda f: path.join('/',f), onlyfiles))

	# Remove Slackware package install files
	onlyfiles = filter(lambda x: x != 'install/doinst.sh', onlyfiles)

	# Find the symbolic links created in the install script
	includefullscript = None
	if postinstallfile:
		try:
			linkfiles = convertinstalllinks(postinstallfile.readlines())

			# Put the symbolic links into the regular %files list
			onlyfiles = onlyfiles + map(lambda x: x[1], linkfiles)

		except ValueError:
			# Script does something besides create symbolic links
			printerr("Warning: full install script is being included")
			includefullscript = 1

	# find empty directories which are part of install
	dirs = []
	for f in slack['files']:
		if lookslikedir(f):		# it's a directory
			if f == './' or f == 'install/':
				continue	# ignore these two special cases

			# Normalize and make absolute the directory name
			f = path.normpath(path.join('/',f))

			# See if this directory is referenced in any file
			# If not, it's considered a separate install item
			# This will list each component of the directory
			#  separately--it should be made smarter
			if not filter(lambda st,f=f: st[:len(f)] == f, onlyfiles):
				dirs.append(f)

	# All files which are part of package
	allfiles = onlyfiles + dirs

	# Start writing RPM spec file

	# installpkg sometimes puts the archive extension as part of the name
	# Remove the common extensions
	slackname = slack['name']
	if slackname[-7:] == '.tar.gz' and len(slackname) > 7:
		slackname = slackname[:-7]
	elif slackname[-4:] == '.tgz' and len(slackname) > 4:
		slackname = slackname[:-4]

	# Summary line is supposed to be only 50 characters or so.
	# We're taking the first line of the description which is usually
	#  50-60 characters or so.
	if slack['description']:
		desc = strip(slack['description'][0])
		o.write('Summary: %s\n' % desc)
		if len(desc) > 50:
			o.write('# The Summary: line should be shortened to about here ----^\n')
	else:
		o.write('Summary: %s\n' % slackname)
		o.write('# The Summary: line should be expanded\n')

	rpmname = prefix + slackname
	o.write('Name: %s\n' % rpmname)		# required line

	version = 'unknown'
	if slack['description']:
		ver = extractver(slack['description'][0])
		if ver:
			version = ver
	o.write('Version: %s\n' % version)	# required line

	o.write('Release: %s\n' % release)	# required line
	o.write('Distribution: %s\n' % distribution)

	location, copyright = guessslacklocation(slack['location'])

	if not location:
		# Package was probably installed from a dir with installpkg -r
		location = 'Custom'
	o.write('Group: %s/%s\n' % (distribution, location))	# required line
	o.write('Copyright: %s\n' % copyright)		# required line
	o.write('Packager: Converted from %s package by %s\n' %
	        (distribution, version_msg))

	# Some software uses hard-coded paths and isn't relocatable, so Prefix
	# is commented out and must be manually edited if user determines
	# it's safe to use.
	# Also, symbolic links are added to the files list later and are not
	# taken into consideration here, so this Prefix: may be rejected by
	# RPM if commented out by the user.
	prefix = dirprefix(allfiles)
	if prefix != '/':
		o.write('# Changes to Prefix: here must also be made ' \
			'in the %post and %preun sections\n')
		o.write('#Prefix: %s\n' % prefix)

	# End of header section
	o.write('\n')

	# Description is required
	# Should probably quote all % in description, but is this ever needed?
	o.write('%description\n')
	if slack['description']:
		for l in slack['description']:
			o.write(l)
			o.write('\n')
	else:
		o.write(slack['name'])
		o.write('\n')
	o.write('\n')

	# Add post-installation script if needed
	if includefullscript:
		o.write('%post\n')
		# The current directory at %post time is whatever the user
		# was in when he invoked rpm -i, so we must change to the
		# install root directory
		# $RPM_INSTALL_PREFIX points to the contents of Prefix: or
		# --prefix and not the root directory of the install
		# The Prefix: is stripped with sed in such a way that a failure
		# causes a cd /, a fail-safe which will work in most cases
		o.write('# Any changes to Prefix: above must be reflected ' \
			'between # and $ on next line\n')
		o.write("cd `echo $RPM_INSTALL_PREFIX | sed 's#%s$##'`/\n"
			 % prefix)
		# Write installation script verbatim
		postinstallfile.seek(0)
		for l in postinstallfile.readlines():
			o.write(l)
		o.write('\n')

		# Add pre-uninstallation script
		o.write('%preun\n')
		o.write('# Any changes to Prefix: above must be reflected ' \
			'between # and $ on next line\n')
		o.write("cd `echo $RPM_INSTALL_PREFIX | sed 's#%s$##'`/\n"
			 % prefix)

		# Convert installation script to an uninstallation script
		postinstallfile.seek(0)
		for l in reverseinstallscript(postinstallfile.readlines()):
			o.write(l)
		o.write('\n')

	# List all directories and files which are part of package
	o.write('%files\n')

	# Empty directories
	for l in dirs:
		o.write('%dir ')
		o.write(l)
		o.write('\n')

	# Files
	# Note that some of these files are symbolic links to other files,
	#   but that information is ignored when determining %config or
	#   %doc file status
	for l in onlyfiles:
		if lookslikeconfigfile(l):
			o.write('%config ')	# it's probably a config file
		elif lookslikedocfile(l):
			o.write('%doc ')	# it's probably a doc file
		if l[0] != '/':
			o.write('/')		# make it start at root
		o.write(l)
		o.write('\n')

	return rpmname, version, release

def show_slackware_packages():
	print 'Slackware installed packages:'
	sys.stdout.flush()
	system('/bin/ls -C ' + slack_packages)

def show_slackware_disks():
	print 'Slackware disk sets:'
	print
	disks = slackware_sets.keys()
	disks.sort()
	for disk in disks:
		print "%s\t%s" % (disk, slackware_sets[disk])

def usage():
	'Display program usage message and exit with error code'
	sys.stdout.write(version_msg+'\n')
	sys.stdout.write(usage_msg % \
		(path.basename(sys.argv[0]), def_package_prefix))
	sys.exit(1)

def show_license():
	'Display software license'
	sys.stdout.write(version_msg+'\n')
	sys.stdout.write(license)

def slackrpm():
	'Main command line interface to slackrpm'

	package_prefix = def_package_prefix	# prepend to RPM package names
	include_script = 1	# put install script into RPM package
	create_spec_file = None	# write spec to file (instead of stdout)
	edit_spec_file = None	# edit spec after creating it
	display_spec = 1	# write spec to stdout or file
	build_rpm = None	# invoke rpm to create .rpm package
	install_rpm = None	# invoke rpm to install .rpm package
	remove_rpm = None	# erase .rpm package after install
	overwrite_file = None	# overwrite spec file if it exists

	try:
		optlist, args = getopt(sys.argv[1:], 'bdehilLrsp:xy?C')
	except:
		printerr('Unknown option')
		usage()		# display usage and exit

	# Look at all options
	for opt,arg in optlist:
		if opt == '-h' or \
		   opt == '-?':
			usage()	# display usage and exit
		elif opt == '-p':
			package_prefix = arg
		elif opt == '-x':
			include_script = None
		elif opt == '-s':
			create_spec_file = 1
		elif opt == '-b':
			build_rpm = 1
			display_spec = None
		elif opt == '-e':
			edit_spec_file = 1
			display_spec = None
		elif opt == '-i':
			install_rpm = 1
			display_spec = None
		elif opt == '-r':
			remove_rpm = 1
		elif opt == '-y':
			overwrite_file = 1
		elif opt == '-d':
			show_slackware_disks()
			sys.exit(0)
		elif opt == '-l':
			show_slackware_packages()
			sys.exit(0)
		elif opt == '-L':
			show_license()
			sys.exit(0)
		elif opt == '-C':
			# equivalent to -sebir
			create_spec_file = 1
			edit_spec_file = 1
			build_rpm = 1
			install_rpm = 1
			remove_rpm = 1
			display_spec = None
		else:	# should never get here
			printerr('Unknown option ' + opt)
			usage()	# display usage and exit

	# No files specified
	if not args:
		usage()		# display usage and exit

	# Handle all names on command line
	for fn in args:
		basefn = path.basename(fn)	# package name without path
		packname = package_prefix + basefn
		try:
			pack_file = findopen(fn,['.',slack_packages])
		except NoSuchFile:
			printerr('Cannot open Slackware package file %s' % fn)
			continue

		# Try to open the script file unless a path was given in the
		# package name
		script_file = None
		if include_script and not path.split(fn)[0]:
			try:
				script_file = findopen(fn,[slack_scripts])
			except NoSuchFile:
				pass	# no script available

		if not script_file:
			printerr('Warning: no install script is being included')

		# create Slackware package info class instance
		sl = SlackPack(pack_file)

		# Create a new spec file if necessary
		# Should really put version number into spec file name
		spec_fn = path.join(rpmconfig['spec_dir'],packname+'.spec')
		if create_spec_file:
			try:
				if overwrite_file:
					raise IOError	# fake a file not found
				spec_file = open(spec_fn)
			except IOError:
				try:
					spec_file = open(spec_fn, 'w')
				except IOError:
					printerr('Cannot create file %s' % spec_fn)
					continue
			else:
				printerr('File %s already exists' % spec_fn)
				continue
		else:
			if display_spec:
				spec_file = sys.stdout
			else:
				spec_file = nullfile()

		# Write a spec file from the Slackware package info
		rpmname, version, release = \
		  slack2spec(sl,spec_file,script_file,prefix=package_prefix)

		# Close the files unless needed for more output
		if create_spec_file:
			spec_file.close()
		if script_file:
			script_file.close()

		# Rename spec file to include the version number
		new_spec_fn = path.join(rpmconfig['spec_dir'],packname+'-'+version+'.spec')
		if create_spec_file:
			try:
				if path.exists(new_spec_fn):
					if overwrite_file:
						unlink(new_spec_fn)
					else:
						raise FileExistsError
				rename(spec_fn, new_spec_fn)	# will overwrite
				spec_fn = new_spec_fn
			except OSError:
				# Couldn't rename file
				# Kill the one we just created and abort
				unlink(spec_fn)
				printerr('Cannot rename spec file to %s' % new_spec_fn)
				continue
			except FileExistsError:
				# Rename file exists
				# Kill the one we just created and abort
				unlink(spec_fn)
				printerr('File %s already exists' % new_spec_fn)
				continue
		else:
			# Assume existing spec file was renamed successfully
			# the last time around
			spec_fn = new_spec_fn

		# Edit the spec file
		if edit_spec_file:
			if not create_spec_file:
				printerr('Warning: editing existing spec file')
			# Edit the spec file we just created
			# Ignore the return status because it may just tell us
			# whether the user saved the file or not.
			editfile(spec_fn)

		# Build RPM package
		buildok = 1	# assume build succeeds
		if build_rpm:
			if not create_spec_file and not edit_spec_file:
				printerr('Warning: using existing spec file')

			cmd = 'rpm -bb --quiet %s' % spec_fn
			printerr(cmd)
			if system(cmd):
				printerr("Error: couldn't create RPM package")
				buildok = None

		# Install RPM package
		if install_rpm:
			if not buildok:
				printerr("Build failed--skipping install")
			else:
				if not build_rpm:
					printerr('Warning: installing existing RPM package')

				binrpmname = path.join(rpmconfig['binrpm_dir'],rpmname)
				binrpmname = binrpmname + '-' + version + '-' + release

				# get architecture code, if necessary
				arch = rpmconfig['arch']
				if arch:
					archtranslate = rpmconfig.gettagsdict('buildarchtranslate')
					if archtranslate and archtranslate.has_key(arch):
						arch = archtranslate[arch]
					binrpmname = binrpmname + '.' + arch
				binrpmname = binrpmname + '.rpm'

				# Need to provide exact path to RPM name
				cmd = 'rpm -i -v -h --noscripts --nodeps --justdb %s' % binrpmname
				printerr(cmd)
				if system(cmd):
					printerr("Error: couldn't install RPM package")

				else:
					# Remove RPM package after a successful install
					if remove_rpm:
						if unlink(binrpmname):
							printerr("Error: couldn't remove RPM package")

if __name__=='__main__':
	slackrpm()
