2 This module implements a means of interacting with and acting on package
3 data. Notable is the Package class that encapsulates all available versions of
4 a package, also allowing for installation and removal. Also, the get_updates
8 import options, database_update, sqlite3, os, shutil, urllib2, glob
9 from hashlib import md5
10 from distutils.version import LooseVersion
11 from weakref import WeakValueDictionary
12 from database_update import LOCAL_TABLE, REPO_INDEX_TABLE, SEPCHAR
15 class PackageError(Exception): pass
19 class PNDVersion(LooseVersion):
20 """Gives the flexibility of distutils.version.LooseVersion, but ensures that
21 any text is always considered less than anything else (including nothing)."""
22 def __cmp__(self, other):
23 if isinstance(other, str):
24 other = self.__class__(other)
26 for i,j in map(None, self.version, other.version):
27 iStr = isinstance(i, basestring)
28 jStr = isinstance(j, basestring)
29 if iStr and not jStr: return -1
30 elif jStr and not iStr: return 1
34 return 0 # If it hasn't returned yet, they're the same.
38 def get_remote_tables():
39 """Checks the remote index table to find the names of all tables containing
40 data from remote databases. Returns a list of strings."""
41 with sqlite3.connect(options.get_database()) as db:
42 c = db.execute('Select url From "%s"' % REPO_INDEX_TABLE)
43 return [ i[0] for i in c ]
46 def get_searchpath_full():
48 for p in options.get_searchpath():
49 paths.extend(glob.iglob(p))
54 class PackageInstance(object):
55 """Gives information on a package as available from a specific source.
56 This should not generally used by external applications. The Package class
57 should cover all needs."""
59 def __init__(self, sourceid, pkgid):
60 "sourceid should be the name of the table in which to look for this package."
61 self.sourceid = sourceid
64 with sqlite3.connect(options.get_database()) as db:
65 db.row_factory = sqlite3.Row
66 # Will set db_entry to None if entry or table doesn't exist.
68 self.db_entry = db.execute('Select * From "%s" Where id=?'
69 % database_update.sanitize_sql(sourceid), (pkgid,)).fetchone()
70 except sqlite3.OperationalError:
73 self.exists = self.db_entry is not None
74 self.version = PNDVersion(self.db_entry['version'] if self.exists
75 else 'A') # This should be the lowest possible version.
78 def install(self, installdir):
79 # Check if this is actually a locally installed file already.
80 if os.path.exists(self.db_entry['uri']):
81 raise PackageError('Package is already installed.')
82 # Or maybe skip the rest of the function without erroring.
84 # Make connection and determine filename.
85 p = urllib2.urlopen(self.db_entry['uri'])
86 header = p.info().getheader('content-disposition')
88 if header and (fkey in header):
89 n = header.find(fkey) + len(fkey)
90 filename = header[n:].split('"')[0]
92 filename = os.path.basename(p.geturl())
93 path = os.path.join(installdir, filename)
95 # Put file in place. No need to check if it already exists; if it
96 # does, we probably want to replace it anyways.
97 # In the process, check its MD5 sum against the one given in the repo.
98 # MD5 is optional in the spec, so only calculate it if it's not given.
99 m_target = self.db_entry['md5']
100 if m_target: m = md5()
101 with open(path, 'wb') as dest:
102 for chunk in iter(lambda: p.read(128*m.block_size), ''):
103 if m_target: m.update(chunk)
105 if m_target and (m.hexdigest() != m_target):
106 raise PackageError("File corrupted. MD5 sums do not match.")
108 # Update local database with new info.
109 with sqlite3.connect(options.get_database()) as db:
110 database_update.update_local_file(path, db)
115 class Package(object):
116 """Informs on and modifies any package defined by a package id. Includes
117 all locally-installed and remotely-available versions of that package."""
119 # This ensures that only one Package object can exist for a given ID (a
120 # variant of the Singleton pattern?). This lets Package objects be
121 # considered the same even if created independently of each other (such as
122 # through multiple calls to search_local_packages). Also ensures that one
123 # instance installing or upgrading will not cause another instance to
124 # become out-of-date.
125 _existing = WeakValueDictionary()
126 def __new__(cls, pkgid):
128 return cls._existing[pkgid]
130 p = object.__new__(cls)
131 cls._existing[pkgid] = p
134 def __init__(self, pkgid):
137 self.local = PackageInstance(LOCAL_TABLE, pkgid)
138 self.remote = [PackageInstance(i, pkgid) for i in get_remote_tables()]
141 def get_latest_remote(self):
142 return max(self.remote, key=lambda x: x.version)
145 def get_latest(self):
146 """Returns PackageInstance of the most recent available version.
147 Gives preference to locally installed version."""
148 m = self.get_latest_remote()
149 return self.local.version >= m.version and self.local or m
152 def install(self, installdir):
153 """Installs the latest available version of the package to installdir.
154 Fails if package is already installed (which would create conflict in
155 libpnd) or if installdir is not on the searchpath (which would confuse
157 # TODO: Repository selection (not just the most up-to-date one).
159 installdir = os.path.abspath(installdir)
161 if self.local.exists:
162 raise PackageError("Locally installed version of %s already exists. Use upgrade method to reinstall." % self.id)
164 elif not os.path.isdir(installdir):
165 raise PackageError("%s is not a directory." % installdir)
167 elif installdir not in get_searchpath_full():
168 raise PackageError("Cannot install to %s since it's not on the searchpath."
171 # Install the latest remote.
172 m = self.get_latest_remote()
174 raise PackageError('No remote from which to install %s.' % self.id)
175 m.install(installdir)
176 # Local table has changed, so update the local PackageInstance.
177 self.local = PackageInstance(LOCAL_TABLE, self.id)
181 oldname = self.local.db_entry['uri']
182 installdir = os.path.dirname(oldname)
184 # Move old version to a temporary filename (that doesn't yet exist).
185 newname = oldname + '.temp'
186 while os.path.exists(newname):
188 shutil.move(oldname, newname)
191 # Install the latest remote.
192 m = self.get_latest_remote()
194 raise PackageError('No remote from which to upgrade %s.' % self.id)
195 m.install(installdir)
196 except Exception as e:
197 # If upgrade fails, move old version back to where it was.
198 shutil.move(newname, oldname)
201 # If upgrade succeeds, get rid of old version.
203 # Local table has changed, so update the local PackageInstance.
204 self.local = PackageInstance(LOCAL_TABLE, self.id)
208 "Remove any locally-installed copy of this package."
209 # Check if it's even locally installed.
210 if not self.local.exists:
211 raise PackageError("%s can't be removed since it's not installed." % self.id)
213 os.remove(self.local.db_entry['uri'])
214 # Remove it from the local database.
215 with sqlite3.connect(options.get_database()) as db:
216 db.execute('Delete From "%s" Where id=?' % LOCAL_TABLE, (self.id,))
218 # Local table has changed, so update the local PackageInstance.
219 self.local = PackageInstance(LOCAL_TABLE, self.id)
222 def remove_appdatas(self):
223 # Use libpnd to find location of all appdatas.
224 # shutil.rmtree all of them
225 # Maybe create a way to remove appdatas of individual apps?
230 def search_local_packages(col, val):
231 """Find all packages containing the given value in the given column.
232 Also handles columns containing lists of data, ensuring that the given
233 value is an entry of that list, not just a substring of an entry."""
234 with sqlite3.connect(options.get_database()) as db:
235 c = db.execute( '''Select id From "%(tab)s" Where %(col)s Like ?
236 Or %(col)s Like ? Or %(col)s Like ? Or %(col)s Like ?'''
237 % {'tab':LOCAL_TABLE, 'col':col},
238 (val, val+SEPCHAR+'%', '%'+SEPCHAR+val,
239 '%'+SEPCHAR+val+SEPCHAR+'%') )
241 return [ Package(i[0]) for i in c ]
245 "Returns Package object for every available package, local or remote."
246 tables = get_remote_tables()
247 tables.append(LOCAL_TABLE)
248 with sqlite3.connect(options.get_database()) as db:
250 ' Union '.join([ 'Select id From "%s"'%t for t in tables ]) )
251 return [ Package(i[0]) for i in c ]
255 """Returns Package object for every installed package."""
256 with sqlite3.connect(options.get_database()) as db:
257 c = db.execute('Select id From "%s"' % LOCAL_TABLE)
258 return [ Package(i[0]) for i in c ]
262 """Checks for updates for all installed packages.
263 Returns a list of Package objects for which a remote version is newer than
264 the installed version. Does not include packages that are not locally installed."""
265 # TODO: Possibly optimize by using SQL Joins to only check packages that
266 # are both locally and remotely available.
267 return [ i for i in get_all_local() if i.local is not i.get_latest() ]