open_toontown_panda3d/direct/src/p3d/PackageInfo.py

856 lines
32 KiB
Python

from pandac.PandaModules import Filename, URLSpec, DocumentSpec, Ramfile, TiXmlDocument, Multifile, Decompressor, EUOk, EUSuccess, VirtualFileSystem, Thread, getModelPath, Patchfile, ExecutionEnvironment
from direct.p3d.FileSpec import FileSpec
from direct.showbase import VFSImporter
import os
import sys
import random
import time
class PackageInfo:
""" This class represents a downloadable Panda3D package file that
can be (or has been) installed into the current runtime. It is
the Python equivalent of the P3DPackage class in the core API. """
# Weight factors for computing download progress. This
# attempts to reflect the relative time-per-byte of each of
# these operations.
downloadFactor = 1
uncompressFactor = 0.01
unpackFactor = 0.01
patchFactor = 0.01
# These tokens are returned by __downloadFile() and other
# InstallStep functions.
stepComplete = 1
stepFailed = 2
restartDownload = 3
class InstallStep:
""" This class is one step of the installPlan list; it
represents a single atomic piece of the installation step, and
the relative effort of that piece. When the plan is executed,
it will call the saved function pointer here. """
def __init__(self, func, bytes, factor):
self.func = func
self.bytesNeeded = bytes
self.bytesDone = 0
self.bytesFactor = factor
def getEffort(self):
""" Returns the relative amount of effort of this step. """
return self.bytesNeeded * self.bytesFactor
def getProgress(self):
""" Returns the progress of this step, in the range
0..1. """
if self.bytesNeeded == 0:
return 1
return min(float(self.bytesDone) / float(self.bytesNeeded), 1)
def __init__(self, host, packageName, packageVersion, platform = None,
solo = False, asMirror = False):
self.host = host
self.packageName = packageName
self.packageVersion = packageVersion
self.platform = platform
self.solo = solo
self.asMirror = asMirror
self.packageDir = Filename(host.hostDir, self.packageName)
if self.packageVersion:
self.packageDir = Filename(self.packageDir, self.packageVersion)
if self.asMirror:
# The server directory contains the platform name, though
# the client directory doesn't.
if self.platform:
self.packageDir = Filename(self.packageDir, self.platform)
# These will be filled in by HostInfo when the package is read
# from contents.xml.
self.descFile = None
self.importDescFile = None
# These are filled in when the desc file is successfully read.
self.hasDescFile = False
self.patchVersion = None
self.displayName = None
self.uncompressedArchive = None
self.compressedArchive = None
self.extracts = []
self.installPlans = None
# This is updated during downloadPackage(). It is in the
# range 0..1.
self.downloadProgress = 0
# This is set true when the package file has been fully
# downloaded and unpacked.
self.hasPackage = False
# This is set true when the package has been "installed",
# meaning it's been added to the paths and all.
self.installed = False
def getDownloadEffort(self):
""" Returns the relative amount of effort it will take to
download this package. The units are meaningless, except
relative to other packges."""
if not self.installPlans:
return 0
# Return the size of plan A, assuming it will work.
plan = self.installPlans[0]
size = sum(map(lambda step: step.getEffort(), plan))
return size
def getFormattedName(self):
""" Returns the name of this package, for output to the user.
This will be the "public" name of the package, as formatted
for user consumption; it will include capital letters and
spaces where appropriate. """
if self.displayName:
name = self.displayName
else:
name = self.packageName
if self.packageVersion:
name += ' %s' % (self.packageVersion)
if self.patchVersion:
name += ' rev %s' % (self.patchVersion)
return name
def setupFilenames(self):
""" This is called by the HostInfo when the package is read
from contents.xml, to set up the internal filenames and such
that rely on some of the information from contents.xml. """
dirname, basename = self.descFile.filename.rsplit('/', 1)
self.descFileDirname = dirname
self.descFileBasename = basename
def checkStatus(self):
""" Checks the current status of the desc file and the package
contents on disk. """
if self.hasPackage:
return True
if not self.hasDescFile:
filename = Filename(self.packageDir, self.descFileBasename)
if self.descFile.quickVerify(self.packageDir, pathname = filename):
if self.__readDescFile():
# Successfully read. We don't need to call
# checkArchiveStatus again, since readDescFile()
# has just done it.
return self.hasPackage
if self.hasDescFile:
if self.__checkArchiveStatus():
# It's all good.
self.hasPackage = True
return self.hasPackage
def downloadDescFile(self, http):
""" Downloads the desc file for this particular package,
synchronously, and then reads it. Returns true on success,
false on failure. """
assert self.descFile
if self.hasDescFile:
# We've already got one.
return True
self.http = http
token = self.__downloadFile(
None, self.descFile,
urlbase = self.descFile.filename,
filename = self.descFileBasename)
while token == self.restartDownload:
# Try again.
token = self.__downloadFile(
None, self.descFile,
urlbase = self.descFile.filename,
filename = self.descFileBasename)
if token == self.stepFailed:
# Couldn't download the desc file.
return False
assert token == self.stepComplete
filename = Filename(self.packageDir, self.descFileBasename)
# Now that we've written the desc file, make it read-only.
os.chmod(filename.toOsSpecific(), 0444)
if not self.__readDescFile():
# Weird, it passed the hash check, but we still can't read
# it.
print "Failure reading %s" % (filename)
return False
return True
def __readDescFile(self):
""" Reads the desc xml file for this particular package,
assuming it's been already downloaded and verified. Returns
true on success, false on failure. """
if self.hasDescFile:
# No need to read it again.
return True
if self.solo:
# If this is a "solo" package, we don't actually "read"
# the desc file; that's the entire contents of the
# package.
self.hasDescFile = True
self.hasPackage = True
return True
filename = Filename(self.packageDir, self.descFileBasename)
doc = TiXmlDocument(filename.toOsSpecific())
if not doc.LoadFile():
return False
xpackage = doc.FirstChildElement('package')
if not xpackage:
return False
try:
self.patchVersion = int(xpackage.Attribute('patch_version') or '')
except ValueError:
self.patchVersion = None
self.displayName = None
xconfig = xpackage.FirstChildElement('config')
if xconfig:
# The name for display to an English-speaking user.
self.displayName = xconfig.Attribute('display_name')
# The uncompressed archive, which will be mounted directly,
# and also used for patching.
xuncompressedArchive = xpackage.FirstChildElement('uncompressed_archive')
if xuncompressedArchive:
self.uncompressedArchive = FileSpec()
self.uncompressedArchive.loadXml(xuncompressedArchive)
# The compressed archive, which is what is downloaded.
xcompressedArchive = xpackage.FirstChildElement('compressed_archive')
if xcompressedArchive:
self.compressedArchive = FileSpec()
self.compressedArchive.loadXml(xcompressedArchive)
# The list of files that should be extracted to disk.
xextract = xpackage.FirstChildElement('extract')
while xextract:
file = FileSpec()
file.loadXml(xextract)
self.extracts.append(file)
xextract = xextract.NextSiblingElement('extract')
self.hasDescFile = True
# Now that we've read the desc file, go ahead and use it to
# verify the download status.
if self.__checkArchiveStatus():
# It's all fully downloaded, unpacked, and ready.
self.hasPackage = True
return True
# Still have to download it.
self.__buildInstallPlans()
return True
def __buildInstallPlans(self):
""" Sets up self.installPlans, a list of one or more "plans"
to download and install the package. """
self.hasPackage = False
if self.asMirror:
# If we're just downloading a mirror archive, we only need
# to get the compressed archive file.
# Build a one-item install plan to download the compressed
# archive.
downloadSize = self.compressedArchive.size
func = lambda step, fileSpec = self.compressedArchive: self.__downloadFile(step, fileSpec, allowPartial = True)
step = self.InstallStep(func, downloadSize, self.downloadFactor)
installPlan = [step]
self.installPlans = [installPlan]
return
# The normal download process. Determine what we will need to
# download, and build a plan (or two) to download it all.
self.installPlans = None
# We know we will at least need to unpack the archive contents
# at the end.
unpackSize = 0
for file in self.extracts:
unpackSize += file.size
step = self.InstallStep(self.__unpackArchive, unpackSize, self.unpackFactor)
planA = [step]
# If the uncompressed archive file is good, that's all we'll
# need to do.
self.uncompressedArchive.actualFile = None
if self.uncompressedArchive.quickVerify(self.packageDir):
self.installPlans = [planA]
return
# Maybe the compressed archive file is good.
if self.compressedArchive.quickVerify(self.packageDir):
uncompressSize = self.uncompressedArchive.size
step = self.InstallStep(self.__uncompressArchive, uncompressSize, self.uncompressFactor)
planA = [step] + planA
self.installPlans = [planA]
return
# Maybe we can download one or more patches. We'll come back
# to that in a minute as plan A. For now, construct plan B,
# which will be to download the whole archive.
planB = planA[:]
uncompressSize = self.uncompressedArchive.size
step = self.InstallStep(self.__uncompressArchive, uncompressSize, self.uncompressFactor)
planB = [step] + planB
downloadSize = self.compressedArchive.size
func = lambda step, fileSpec = self.compressedArchive: self.__downloadFile(step, fileSpec, allowPartial = True)
step = self.InstallStep(func, downloadSize, self.downloadFactor)
planB = [step] + planB
# Now look for patches. Start with the md5 hash from the
# uncompressedArchive file we have on disk, and see if we can
# find a patch chain from this file to our target.
pathname = Filename(self.packageDir, self.uncompressedArchive.filename)
fileSpec = self.uncompressedArchive.actualFile
if fileSpec is None and pathname.exists():
fileSpec = FileSpec()
fileSpec.fromFile(self.packageDir, self.uncompressedArchive.filename)
plan = None
if fileSpec:
plan = self.__findPatchChain(fileSpec)
if plan:
# We can download patches. Great! That means this is
# plan A, and the full download is plan B (in case
# something goes wrong with the patching).
planA = plan + planA
self.installPlans = [planA, planB]
else:
# There are no patches to download, oh well. Stick with
# plan B as the only plan.
self.installPlans = [planB]
def __scanDirectoryRecursively(self, dirname):
""" Generates a list of Filename objects: all of the files
(not directories) within and below the indicated dirname. """
contents = []
for dirpath, dirnames, filenames in os.walk(dirname.toOsSpecific()):
dirpath = Filename.fromOsSpecific(dirpath)
if dirpath == dirname:
dirpath = Filename('')
else:
dirpath.makeRelativeTo(dirname)
for filename in filenames:
contents.append(Filename(dirpath, filename))
return contents
def __removeFileFromList(self, contents, filename):
""" Removes the indicated filename from the given list, if it is
present. """
try:
contents.remove(Filename(filename))
except ValueError:
pass
def __checkArchiveStatus(self):
""" Returns true if the archive and all extractable files are
already correct on disk, false otherwise. """
# Get a list of all of the files in the directory, so we can
# remove files that don't belong.
contents = self.__scanDirectoryRecursively(self.packageDir)
self.__removeFileFromList(contents, self.descFileBasename)
self.__removeFileFromList(contents, self.compressedArchive.filename)
if not self.asMirror:
self.__removeFileFromList(contents, self.uncompressedArchive.filename)
for file in self.extracts:
self.__removeFileFromList(contents, file.filename)
# Now, any files that are still in the contents list don't
# belong. It's important to remove these files before we
# start verifying the files that we expect to find here, in
# case there is a problem with ambiguous filenames or
# something (e.g. case insensitivity).
for filename in contents:
print "Removing %s" % (filename)
pathname = Filename(self.packageDir, filename)
pathname.unlink()
if self.asMirror:
return self.compressedArchive.quickVerify(self.packageDir)
allExtractsOk = True
if not self.uncompressedArchive.quickVerify(self.packageDir):
#print "File is incorrect: %s" % (self.uncompressedArchive.filename)
allExtractsOk = False
if allExtractsOk:
# OK, the uncompressed archive is good; that means there
# shouldn't be a compressed archive file here.
pathname = Filename(self.packageDir, self.compressedArchive.filename)
pathname.unlink()
for file in self.extracts:
if not file.quickVerify(self.packageDir):
#print "File is incorrect: %s" % (file.filename)
allExtractsOk = False
break
## if allExtractsOk:
## print "All %s extracts of %s seem good." % (
## len(self.extracts), self.packageName)
return allExtractsOk
def __updateStepProgress(self, step):
""" This callback is made from within the several step
functions as the download step proceeds. It updates
self.downloadProgress with the current progress, so the caller
can asynchronously query this value. """
size = self.totalPlanCompleted + self.currentStepEffort * step.getProgress()
self.downloadProgress = min(float(size) / float(self.totalPlanSize), 1)
def downloadPackage(self, http):
""" Downloads the package file, synchronously, then
uncompresses and unpacks it. Returns true on success, false
on failure.
This assumes that self.installPlans has already been filled
in, which will have been done by self.__readDescFile().
"""
assert self.hasDescFile
if self.hasPackage:
# We've already got one.
return True
# We should have an install plan by the time we get here.
assert self.installPlans
self.http = http
token = self.__followInstallPlans()
while token == self.restartDownload:
# Try again.
if not self.downloadDescFile(http):
return False
assert self.installPlans
token = self.__followInstallPlans()
if token == self.stepFailed:
return False
assert token == self.stepComplete
return True
def __followInstallPlans(self):
""" Performs all of the steps in self.installPlans. Returns
one of stepComplete, stepFailed, or restartDownload. """
installPlans = self.installPlans
self.installPlans = None
for plan in installPlans:
self.totalPlanSize = sum(map(lambda step: step.getEffort(), plan))
self.totalPlanCompleted = 0
self.downloadProgress = 0
planFailed = False
for step in plan:
self.currentStepEffort = step.getEffort()
token = step.func(step)
if token == self.restartDownload:
return token
if token == self.stepFailed:
planFailed = True
break
assert token == self.stepComplete
self.totalPlanCompleted += self.currentStepEffort
if not planFailed:
# Successfully downloaded!
return self.stepComplete
# All plans failed.
return self.stepFailed
def __findPatchChain(self, fileSpec):
""" Finds the chain of patches that leads from the indicated
patch version to the current patch version. If found,
constructs an installPlan that represents the steps of the
patch installation; otherwise, returns None. """
from direct.p3d.PatchMaker import PatchMaker
patchMaker = PatchMaker(self.packageDir)
patchChain = patchMaker.getPatchChainToCurrent(self.descFileBasename, fileSpec)
if patchChain is None:
# No path.
patchMaker.cleanup()
return None
plan = []
for patchfile in patchChain:
downloadSize = patchfile.file.size
func = lambda step, fileSpec = patchfile.file: self.__downloadFile(step, fileSpec, allowPartial = True)
step = self.InstallStep(func, downloadSize, self.downloadFactor)
plan.append(step)
patchSize = patchfile.targetFile.size
func = lambda step, patchfile = patchfile: self.__applyPatch(step, patchfile)
step = self.InstallStep(func, patchSize, self.patchFactor)
plan.append(step)
patchMaker.cleanup()
return plan
def __downloadFile(self, step, fileSpec, urlbase = None, filename = None,
allowPartial = False):
""" Downloads the indicated file from the host into
packageDir. Returns one of stepComplete, stepFailed, or
restartDownload. """
if not urlbase:
urlbase = self.descFileDirname + '/' + fileSpec.filename
# Build up a list of URL's to try downloading from. Unlike
# the C++ implementation in P3DPackage.cxx, here we build the
# URL's in forward order.
tryUrls = []
if self.host.appRunner and self.host.appRunner.superMirrorUrl:
# We start with the "super mirror", if it's defined.
url = self.host.appRunner.superMirrorUrl + urlbase
tryUrls.append((url, False))
if self.host.mirrors:
# Choose two mirrors at random.
mirrors = self.host.mirrors[:]
for i in range(2):
mirror = random.choice(mirrors)
mirrors.remove(mirror)
url = mirror + urlbase
tryUrls.append((url, False))
if not mirrors:
break
# After trying two mirrors and failing (or if there are no
# mirrors), go get it from the original host.
url = self.host.downloadUrlPrefix + urlbase
tryUrls.append((url, False))
# And finally, if the original host also fails, try again with
# a cache-buster.
tryUrls.append((url, True))
for url, cacheBust in tryUrls:
request = DocumentSpec(url)
if cacheBust:
# On the last attempt to download a particular file,
# we bust through the cache: append a query string to
# do this.
url += '?' + str(int(time.time()))
request = DocumentSpec(url)
request.setCacheControl(DocumentSpec.CCNoCache)
print "%s downloading %s" % (self.packageName, url)
if not filename:
filename = fileSpec.filename
targetPathname = Filename(self.packageDir, filename)
targetPathname.setBinary()
channel = self.http.makeChannel(False)
# If there's a previous partial download, attempt to resume it.
bytesStarted = 0
if allowPartial and not cacheBust and targetPathname.exists():
bytesStarted = targetPathname.getFileSize()
if bytesStarted < 1024*1024:
# Not enough bytes downloaded to be worth the risk of
# a partial download.
bytesStarted = 0
elif bytesStarted >= fileSpec.size:
# Couldn't possibly be our file.
bytesStarted = 0
if bytesStarted:
print "Resuming %s after %s bytes already downloaded" % (url, bytesStarted)
# Make sure the file is writable.
os.chmod(targetPathname.toOsSpecific(), 0644)
channel.beginGetSubdocument(request, bytesStarted, 0)
else:
# No partial download possible; get the whole file.
targetPathname.makeDir()
targetPathname.unlink()
channel.beginGetDocument(request)
channel.downloadToFile(targetPathname)
while channel.run():
if step:
step.bytesDone = channel.getBytesDownloaded() + channel.getFirstByteDelivered()
if step.bytesDone > step.bytesNeeded:
# Oops, too much data. Might as well abort;
# it's the wrong file.
break
self.__updateStepProgress(step)
Thread.considerYield()
if step:
step.bytesDone = channel.getBytesDownloaded() + channel.getFirstByteDelivered()
self.__updateStepProgress(step)
if not channel.isValid():
print "Failed to download %s" % (url)
elif not fileSpec.fullVerify(self.packageDir, pathname = targetPathname):
print "After downloading, %s incorrect" % (Filename(fileSpec.filename).getBasename())
else:
# Success!
return self.stepComplete
# This attempt failed. Maybe the original contents.xml
# file is stale. Try re-downloading it now, just to be
# sure.
if self.host.redownloadContentsFile(self.http):
# Yes! Go back and start over from the beginning.
return self.restartDownload
# Well, that wasn't the problem. Maybe the mirror is bad.
# Go back and try the next mirror.
# All mirrors failed; the server (or the internet connection)
# must be just fubar.
return self.stepFailed
def __applyPatch(self, step, patchfile):
""" Applies the indicated patching in-place to the current
uncompressed archive. The patchfile is removed after the
operation. Returns one of stepComplete, stepFailed, or
restartDownload. """
origPathname = Filename(self.packageDir, self.uncompressedArchive.filename)
patchPathname = Filename(self.packageDir, patchfile.file.filename)
result = Filename.temporary('', 'patch_')
print "Patching %s with %s" % (origPathname, patchPathname)
p = Patchfile() # The C++ class
ret = p.initiate(patchPathname, origPathname, result)
if ret == EUSuccess:
ret = p.run()
while ret == EUOk:
step.bytesDone = step.bytesNeeded * p.getProgress()
self.__updateStepProgress(step)
Thread.considerYield()
ret = p.run()
del p
patchPathname.unlink()
if ret < 0:
print "Patching failed."
result.unlink()
return self.stepFailed
if not result.renameTo(origPathname):
print "Couldn't rename %s to %s" % (result, origPathname)
return self.stepFailed
return self.stepComplete
def __uncompressArchive(self, step):
""" Turns the compressed archive into the uncompressed
archive. Returns one of stepComplete, stepFailed, or
restartDownload. """
sourcePathname = Filename(self.packageDir, self.compressedArchive.filename)
targetPathname = Filename(self.packageDir, self.uncompressedArchive.filename)
targetPathname.unlink()
print "Uncompressing %s to %s" % (sourcePathname, targetPathname)
decompressor = Decompressor()
decompressor.initiate(sourcePathname, targetPathname)
totalBytes = self.uncompressedArchive.size
result = decompressor.run()
while result == EUOk:
step.bytesDone = int(totalBytes * decompressor.getProgress())
self.__updateStepProgress(step)
result = decompressor.run()
Thread.considerYield()
if result != EUSuccess:
return self.stepFailed
step.bytesDone = totalBytes
self.__updateStepProgress(step)
if not self.uncompressedArchive.quickVerify(self.packageDir):
print "after uncompressing, %s still incorrect" % (
self.uncompressedArchive.filename)
return self.stepFailed
# Now that we've verified the archive, make it read-only.
os.chmod(targetPathname.toOsSpecific(), 0444)
# Now we can safely remove the compressed archive.
sourcePathname.unlink()
return self.stepComplete
def __unpackArchive(self, step):
""" Unpacks any files in the archive that want to be unpacked
to disk. Returns one of stepComplete, stepFailed, or
restartDownload. """
if not self.extracts:
# Nothing to extract.
self.hasPackage = True
return self.stepComplete
mfPathname = Filename(self.packageDir, self.uncompressedArchive.filename)
print "Unpacking %s" % (mfPathname)
mf = Multifile()
if not mf.openRead(mfPathname):
print "Couldn't open %s" % (mfPathname)
return self.stepFailed
allExtractsOk = True
step.bytesDone = 0
for file in self.extracts:
i = mf.findSubfile(file.filename)
if i == -1:
print "Not in Multifile: %s" % (file.filename)
allExtractsOk = False
continue
targetPathname = Filename(self.packageDir, file.filename)
targetPathname.unlink()
if not mf.extractSubfile(i, targetPathname):
print "Couldn't extract: %s" % (file.filename)
allExtractsOk = False
continue
if not file.quickVerify(self.packageDir):
print "After extracting, still incorrect: %s" % (file.filename)
allExtractsOk = False
continue
# Make sure it's executable, and not writable.
os.chmod(targetPathname.toOsSpecific(), 0555)
step.bytesDone += file.size
self.__updateStepProgress(step)
Thread.considerYield()
if not allExtractsOk:
return self.stepFailed
self.hasPackage = True
return self.stepComplete
def installPackage(self, appRunner):
""" Mounts the package and sets up system paths so it becomes
available for use. """
assert self.hasPackage
if self.installed:
# Already installed.
return True
assert self not in appRunner.installedPackages
mfPathname = Filename(self.packageDir, self.uncompressedArchive.filename)
mf = Multifile()
if not mf.openRead(mfPathname):
print "Couldn't open %s" % (mfPathname)
return False
# We mount it under its actual location on disk.
root = self.packageDir.cStr()
vfs = VirtualFileSystem.getGlobalPtr()
vfs.mount(mf, root, vfs.MFReadOnly)
appRunner.loadMultifilePrcFiles(mf, root)
# Add this to the Python search path, if it's not already
# there. We have to take a bit of care to check if it's
# already there, since there can be some ambiguity in
# os-specific path strings.
root = self.packageDir.toOsSpecific()
foundOnPath = False
for p in sys.path:
if root == p:
# Already here, exactly.
foundOnPath = True
break
elif root == Filename.fromOsSpecific(p).toOsSpecific():
# Already here, with some futzing.
foundOnPath = True
break
if not foundOnPath:
# Not already here; add it.
sys.path.append(root)
# Put it on the model-path, too. We do this indiscriminantly,
# because the Panda3D runtime won't be adding things to the
# model-path, so it shouldn't be already there.
getModelPath().appendDirectory(self.packageDir)
# Set the environment variable to reference the package root.
envvar = '%s_ROOT' % (self.packageName.upper())
ExecutionEnvironment.setEnvironmentVariable(envvar, self.packageDir.toOsSpecific())
# Also, find any toplevel Python packages, and add these as
# shared packages. This will allow different packages
# installed in different directories to share Python files as
# if they were all in the same directory.
for filename in mf.getSubfileNames():
if filename.endswith('/__init__.pyc') or \
filename.endswith('/__init__.pyo') or \
filename.endswith('/__init__.py'):
components = filename.split('/')[:-1]
moduleName = '.'.join(components)
VFSImporter.sharedPackages[moduleName] = True
# Fix up any shared directories so we can load packages from
# disparate locations.
VFSImporter.reloadSharedPackages()
self.installed = True
appRunner.installedPackages.append(self)
return True