Completely rewrote the FTP upload routine to allow more than one session to be active in the same local directory at the same time. Cut down on the number of hits to the server, so it might be a bit faster. Also, allow retries under more error cases.
This commit is contained in:
Tom Keffer
2010-03-25 15:49:11 +00:00
parent 635b8b1c95
commit 3ce3c0fd69
9 changed files with 266 additions and 410 deletions

View File

@@ -1,7 +1,7 @@
CHANGE HISTORY
--------------------------------
1.6.0 03/24/10
1.6.0 03/25/10
Now supports skins. A skin is a collection of templates, under the
control of a 'skin configuration file.' Presentation layer specific
@@ -21,20 +21,16 @@ FTP is treated as just another report, albeit with an unusual report
generator. You can have multiple FTP sessions, each to a different
server, or uploading to or from a different area.
Rewrote the FTP upload package so that it allows more than one FTP
session to be active in the same local directory. This version also does
fewer hits on the server, so it might be a bit faster.
Data files used in reports (such as weewx.css) are copied over to the
HTML directory on program startup.
Moved the URL for the radar image from the configuration file to the
template index.html.tmpl.
setup.py no longer deletes public_html/#upstream.last as part of the
install process.
Now requires configobj v4.6 (instead of V4.5). As this version was
released nearly a year ago, this should not be a problem for most
users. If it is the following command will take care of it:
easy_install --upgrade configobj
1.5.0 03/07/10
Added support for other units besides the U.S. Customary. Plots and

View File

@@ -6,7 +6,6 @@ configure.py
daemon.py
setup.cfg
setup.py
upload.py
weewx.conf
weewxd.py
docs/customizing.htm
@@ -40,13 +39,13 @@ weeutil/Almanac.py
weeutil/Moon.py
weeutil/Sun.py
weeutil/__init__.py
weeutil/ftpupload.py
weeutil/weeutil.py
weewx/VantagePro.py
weewx/__init__.py
weewx/archive.py
weewx/crc16.py
weewx/formatter.py
weewx/ftpdata.py
weewx/genfiles.py
weewx/genimages.py
weewx/reportengine.py

View File

@@ -89,6 +89,12 @@ class My_install_data(install_data):
# Run the superclass's run():
install_data.run(self)
# If the file #upstream.last exists, delete it, as it is no longer used.
try:
os.remove(os.path.join(self.install_dir, 'public_html/#upstream.last'))
except:
pass
# If the file $WEEWX_ROOT/readme.htm exists, delete it. It's
# the old readme (since replaced with docs/readme.htm)
try:
@@ -217,7 +223,7 @@ setup(name='weewx',
author_email='tkeffer@gmail.com',
url='http://www.weewx.com',
packages = ['weewx', 'weeplot', 'weeutil', 'examples'],
py_modules = ['upload', 'daemon'],
py_modules = ['daemon'],
scripts = ['configure.py', 'weewxd.py'],
data_files = [('', ['CHANGES.txt', 'LICENSE.txt', 'README', 'weewx.conf']),
('docs', ['docs/customizing.htm', 'docs/readme.htm',
@@ -229,7 +235,7 @@ setup(name='weewx',
'skins/Standard/skin.conf', 'skins/Standard/week.html.tmpl',
'skins/Standard/weewx.css', 'skins/Standard/year.html.tmpl']),
('start_scripts', ['start_scripts/Debian/weewx', 'start_scripts/SuSE/weewx'])],
requires = ['configobj(>=4.6)', 'pyserial(>=1.35)', 'Cheetah(>=2.0)', 'pysqlite(>=2.5)', 'PIL(>=1.1.6)'],
requires = ['configobj(>=4.5)', 'pyserial(>=1.35)', 'Cheetah(>=2.0)', 'pysqlite(>=2.5)', 'PIL(>=1.1.6)'],
cmdclass = {"install_data" : My_install_data,
"sdist" : My_sdist}
)

306
upload.py
View File

@@ -1,306 +0,0 @@
#!/usr/bin/python
#
# upload.py -- a script to upload files to FTP server only as-needed
#
# Copyright (c) 2002, Silverback Software, LLC
#
# Brian St. Pierre, <brian @ silverback-software.com>
#
# Permission to use, copy, modify, and distribute this software and its
# documentation for any purpose, without fee, and without a written agreement
# is hereby granted, provided that the above copyright notice and this
# paragraph and the following two paragraphs appear in all copies.
#
# IN NO EVENT SHALL THE AUTHOR BE LIABLE TO ANY PARTY FOR DIRECT,
# INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING LOST
# PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION,
# EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
# THE AUTHOR SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS ON AN "AS IS"
# BASIS, AND THE AUTHOR HAS NO OBLIGATIONS TO PROVIDE MAINTENANCE,
# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
#
# $Revision$
# $Author$
# $Date$
# HISTORY:
#
# 2002-12-20 -- Original
# 2004-05-25 -- Fixed bug creating new dirs where parent did not exist
# 2004-06-15 -- Put ".svn" in default ignore list.
# 2006-09-24 -- Handle FTP error during 'quit'.
# 2009-04-23 -- Modified the member function 'upstream' to return the number
# of files uploaded. -Tom Keffer
# 2009-08-25 -- Added an optional passive mode. -Tom Keffer
# 2009-10-12 -- Allows up to max_retries attempts at uploading a file to the
# server before giving up. Logs a failed transfer to syslog. -Tom Keffer
#
####
#
# How this works:
#
# 1. There is a file called #upstream.xml in the root directory.
# This file contains information about how and where to put files
# on the FTP server.
# We don't look for #upstream.xml in subdirectories -- there is no
# way to override the root file.
# #upstream.xml is based on Radio UserLand's #upstream.xml, except
# that we store passwords in plaintext in the <password> element.
# This is insecure, but it works.
#
# 2. We look for #upstream.last in the root directory.
# This is a data file that contains the time each file was last
# uploaded.
#
# 3. We compare every file's timestamp to the info in
# .upload-last. If the file is newer, it needs to be uploaded.
# If the .upload-last does not exist, all files need to be
# uploaded.
#
# 4. We gather all the files to upload and then send everything at the
# end when we've had a chance to walk the entire directory tree.
#
# 5. We skip the following:
# - anything ending with ~ (emacs backups)
# - anything begginning with # (control files)
# - directories named CVS (don't examine them at all)
# - this script (which we assume is named upload.py)
#
# From http://bstpierre.org/Projects/upload.py
import ftplib
import os
import os.path
import pickle
import socket
import sys
import time
import syslog
from xml.dom import minidom
class UpstreamerFactory:
def create(self):
self.doc = minidom.parse(open('#upstream.xml', 'r'))
upstream = self.doc.getElementsByTagName('upstream')[0]
self.type = upstream.getAttribute('type')
server = self._get_simple_text('server')
user = self._get_simple_text('username')
password = self._get_simple_text('password')
path = self._get_simple_text('path')
url = self._get_simple_text('url')
if self.type == 'ftp':
u = Upstreamer(server, user, password, path)
elif self.type == 'copy':
u = LocalUpstreamer(path)
return u
def _get_simple_text(self, tag):
elems = self.doc.getElementsByTagName(tag)
if len(elems) > 0 and len(elems[0].childNodes) > 0:
return elems[0].childNodes[0].data
return ''
class Upstreamer:
def __init__(self, server, user, password, path, passive=False, max_retries = 3):
self.server = server
self.user = user
self.password = password
self.path = path
self.passive = passive
self.max_retries = max_retries
self._normalize_path()
return
def _normalize_path(self):
self.path = self.path.replace('\\', '/')
if self.path[-1] == '/':
self.path = self.path[:-1]
return
def get_dest_dir(self, destfile):
return destfile[:destfile.rfind('/')]
def check_dir_and_create(self, ftp, dir):
dir = dir.replace(self.path, '')
path = self.path
for d in dir.split('/'):
if d == '':
continue
cur = path + '/' + d
try:
ftp.cwd(cur)
path = cur
except ftplib.error_perm:
# print 'MKD', cur
ftp.mkd(cur)
ftp.cwd(cur)
path = cur
try:
ftp.cwd(self.path)
except ftplib.error_perm:
# print 'MKD', self.path
ftp.mkd(self.path)
ftp.cwd(self.path)
return
def upstream(self, files):
n_uploaded = 0
if len(files) == 0:
return n_uploaded
ftp = ftplib.FTP(self.server)
#ftp.set_debuglevel(1)
ftp.login(self.user, self.password)
ftp.set_pasv(self.passive)
self.check_dir_and_create(ftp, self.path)
for file in files:
if file[0] == '.':
destfile = file[1:].replace('\\', '/')
if destfile[0] == '/':
destfile = destfile[1:]
destfile = self.path + '/' + destfile
cmd = 'STOR ' + destfile
f = open(file, 'r')
for _count in range(self.max_retries):
try:
self.check_dir_and_create(ftp,
self.get_dest_dir(destfile))
except (socket.error, ftplib.Error), e:
syslog.syslog(syslog.LOG_ERR, "upload: attempt #%d; got exception while changing/making directory. Reason: %s" % (_count+1, e,))
if _count == self.max_retries-1:
syslog.syslog(syslog.LOG_ERR, "upload: max retries (%d) exceeded while changing/making directory. Giving up." % (self.max_retries,))
raise
else:
break
for _count in range(self.max_retries):
try:
ftp.storbinary(cmd, f)
except socket.error, e:
syslog.syslog(syslog.LOG_ERR, "upload: attempt #%d. failed uploading %s. Reason: %s" % (_count+1, destfile, e))
else:
n_uploaded += 1
syslog.syslog(syslog.LOG_DEBUG, "upload: attempt #%d. uploaded file %s." % (_count+1, destfile))
break
else:
syslog.syslog(syslog.LOG_ERR,
"upload: max retries (%d) exceeded while ftp'ing file %s. Giving up." % (self.max_retries,file))
try:
ftp.quit()
except socket.error:
# My FTP server started causing "connection reset"
# errors. I think it is closing the far end socket too
# quickly.
pass
return n_uploaded
class LocalUpstreamer(Upstreamer):
"""This Upstreamer uses a local file copy rather than FTP."""
def __init__(self, path):
Upstreamer.__init__(self, '', '', '', path)
return
def check_dir_and_create(self, ftp, dir):
if not os.path.isdir(dir):
print 'mkdir', dir
os.makedirs(dir)
return
def upstream(self, files):
if len(files) == 0:
return
self.check_dir_and_create(None, self.path)
for file in files:
if file[0] == '.':
destfile = file[1:].replace('\\', '/')
if destfile[0] == '/':
destfile = destfile[1:]
destfile = os.path.join(self.path, destfile)
self.check_dir_and_create(None,
self.get_dest_dir(destfile))
print 'copy %s' % (file, )
destfd = open(destfile, 'w')
srcfd = open(file, 'r')
destfd.write(srcfd.read())
srcfd.close()
destfd.close()
return
class Finder:
def __init__(self):
if os.access('#upstream.last', os.R_OK):
self.last = pickle.load(open('#upstream.last', 'r'))
else:
self.last = {}
self.now = {}
self.find_all_files()
return
def _os_walk_callback(self, dummy, dir, names):
if dir[-3:] == 'CVS' or dir.find('.svn') != -1:
return
if dir.find('/#') != -1:
return
for name in names:
filename = os.path.join(dir, name)
if name in ['CVS', '.svn']:
continue
elif name[-1] == '~':
continue
elif name[0] == '#':
continue
elif name == 'upload.py':
continue
elif os.path.isdir(filename):
continue
else:
stamp = os.stat(filename).st_mtime
self.now[filename] = stamp
return
def find_all_files(self):
os.path.walk('.', self._os_walk_callback, None)
return
def get_new_files(self):
new = []
for file in self.now.keys():
if not self.last.has_key(file):
new.append(file)
elif self.last[file] < self.now[file]:
new.append(file)
return new
def reset_stamp(self, when):
for file in self.now.keys():
self.now[file] = when
return
def save(self):
pickle.dump(self.now, open('#upstream.last', 'w'))
return
if __name__ == '__main__':
f = UpstreamerFactory()
upstr = f.create()
finder = Finder()
finder.find_all_files()
if len(sys.argv) == 2:
if sys.argv[1] == '-t': ## test
print finder.get_new_files()
if sys.argv[1] == '-f': ## fake
print finder.get_new_files()
finder.reset_stamp(time.time())
finder.save()
print "No upload: timestamps reset."
else:
upstr.upstream(finder.get_new_files())
finder.reset_stamp(time.time())
finder.save()

229
weeutil/ftpupload.py Normal file
View File

@@ -0,0 +1,229 @@
#
# Copyright (c) 2009, 2010 Tom Keffer <tkeffer@gmail.com>
#
# See the file LICENSE.txt for your full rights.
#
# $Revision$
# $Author$
# $Date$
#
"""For uploading files to a remove server via FTP"""
import os
import sys
import ftplib
import cPickle
import time
import syslog
class FtpUpload:
"""Uploads a directory and all its descendents to a remote server.
Keeps track of when a file was last uploaded, so it is uploaded only
if its modification time is newer."""
def __init__(self, server,
user, password,
local_root, remote_root,
name = "FTP",
passive = True,
max_tries = 3):
"""Initialize an instance of FtpUpload.
After initializing, call method run() to perform the upload.
server: The remote server to which the files are to be uploaded.
user,
password : The user name and password that are to be used.
name: A unique name to be given for this FTP session. This allows more
than one session to be uploading from the same local directory. [Optional.
Default is 'FTP'.]
passive: True to use passive mode; False to use active mode. [Optional.
Default is True (passive mode)]
max_tries: How many times to try creating a directory or uploading
a file before giving up [Optional. Default is 3]
"""
self.server = server
self.user = user
self.password = password
self.local_root = os.path.normpath(local_root)
self.remote_root = os.path.normpath(remote_root)
self.name = name
self.passive = passive
self.max_tries = max_tries
def run(self):
"""Perform the actual upload.
returns: the number of files uploaded."""
# Get the timestamp and members of the last upload:
(timestamp, fileset) = self.getLastUpload()
n_uploaded = 0
ftp_server = ftplib.FTP(self.server)
#ftp_server.set_debuglevel(1)
ftp_server.login(self.user, self.password)
ftp_server.set_pasv(self.passive)
# Walk the local directory structure
for (dirpath, dirnames, filenames) in os.walk(self.local_root):
# Strip out the common local root directory. What is left
# will be the relative directory both locally and remotely.
local_rel_dir_path = dirpath.replace(self.local_root, '.')
if self._skipThisDir(local_rel_dir_path):
continue
# This is the absolute path to the remote directory:
remote_dir_path = os.path.normpath(os.path.join(self.remote_root, local_rel_dir_path))
# Make the remote directory if necessary:
self._make_remote_dir(ftp_server, remote_dir_path)
# Now iterate over all members of the local directory:
for filename in filenames:
full_local_path = os.path.join(dirpath, filename)
# See if this file can be skipped:
if self._skipThisFile(timestamp, fileset, full_local_path):
continue
full_remote_path = os.path.join(remote_dir_path, filename)
STOR_cmd = "STOR %s" % full_remote_path
fd = open(full_local_path, "r")
for count in range(self.max_tries):
try:
ftp_server.storbinary(STOR_cmd, fd)
except ftplib.all_errors, e:
syslog.syslog(syslog.LOG_ERR, "ftpupload: attempt #%d. Failed uploading %s. Reason: %s" % (count+1, full_remote_path, e))
if count >= self.max_tries -1 :
syslog.syslog(syslog.LOG_ERR, "ftpupload: Failed to upload file %s" % full_remote_path)
raise
else:
fd.close()
n_uploaded += 1
syslog.syslog(syslog.LOG_DEBUG, "ftpupload: Uploaded file %s" % full_remote_path)
break
fileset.add(full_local_path)
try:
ftp_server.quit()
except socket.error:
pass
timestamp = time.time()
self.saveLastUpload(timestamp, fileset)
return n_uploaded
def getLastUpload(self):
"""Reads the time and members of the last upload from the local root"""
timeStampFile = os.path.join(self.local_root, "#%s.last" % self.name )
try:
f = open(timeStampFile, "r")
timestamp = cPickle.load(f)
fileset = cPickle.load(f)
f.close()
except IOError:
timestamp = 0
fileset = set()
return (timestamp, fileset)
def saveLastUpload(self, timestamp, fileset):
"""Saves the time and members of the last upload in the local root."""
timeStampFile = os.path.join(self.local_root, "#%s.last" % self.name )
try:
f = open(timeStampFile, "w")
cPickle.dump(timestamp, f)
cPickle.dump(fileset, f)
f.close()
except IOError:
pass
def _make_remote_dir(self, ftp_server, remote_dir_path):
"""Make a remote directory if necessary."""
# Try to make the remote directory up max_tries times, then give up.
for count in range(self.max_tries):
try:
ftp_server.mkd(remote_dir_path)
except ftplib.all_errors, e:
# Got an exception. It might be because the remote directory already exists:
if sys.exc_info()[0] is ftplib.error_perm and str(e).strip().startswith('550'):
# Directory already exists
return
syslog.syslog(syslog.LOG_ERR, "ftpupload: Got error while attempting to make remote directory %s" % remote_dir_path)
syslog.syslog(syslog.LOG_ERR, " **** Error:" % e)
if count >= self.max_tries - 1:
syslog.syslog(syslog.LOG_ERR, "ftpupload: Unable to create remote directory %s" % remote_dir_path)
raise
else:
syslog.syslog(syslog.LOG_DEBUG, "ftpupload: Made directory %s" % remote_dir_path)
return
def _skipThisDir(self, local_dir):
return os.path.basename(local_dir) in ('.svn', 'CVS')
def _skipThisFile(self, timestamp, fileset, full_local_path):
filename = os.path.basename(full_local_path)
if filename[-1] == '~' or filename[0] == '#' :
return True
if full_local_path not in fileset:
return False
if os.stat(full_local_path).st_mtime > timestamp:
return False
# Filename is in the set, and is up to date.
return True
if __name__ == '__main__':
import weewx
import syslog
import socket
import configobj
weewx.debug = 1
syslog.openlog('reportengine', syslog.LOG_PID|syslog.LOG_CONS)
syslog.setlogmask(syslog.LOG_UPTO(syslog.LOG_DEBUG))
if len(sys.argv) < 2 :
print """Usage: ftpupload.py path-to-configuration-file [path-to-be-ftp'd]"""
exit()
try :
config_dict = configobj.ConfigObj(sys.argv[1], file_error=True)
except IOError:
print "Unable to open configuration file ", sys.argv[1]
raise
if len(sys.argv) == 2:
try:
ftp_dir = os.path.join(config_dict['Station']['WEEWX_ROOT'],
config_dict['Reports']['HTML_ROOT'])
except KeyError:
print "No HTML_ROOT in configuration dictionary."
exit()
else:
ftp_dir = sys.argv[2]
socket.setdefaulttimeout(10)
ftp_upload = FtpUpload(config_dict['Reports']['FTP']['server'],
config_dict['Reports']['FTP']['user'],
config_dict['Reports']['FTP']['password'],
ftp_dir,
config_dict['Reports']['FTP']['path'],
'FTP',
config_dict['Reports']['FTP'].as_bool('passive'),
config_dict['Reports']['FTP'].as_int('max_tries'))
ftp_upload.run()

View File

@@ -29,7 +29,7 @@ debug = 0
socket_timeout = 20
# Current version
version = 1.6.0a3
version = 1.6.0a4
############################################################################################
@@ -223,7 +223,7 @@ version = 1.6.0a3
passive = 1
# How many times to try to transfer a file before giving up:
max_retries = 3
max_tries = 3
# If you wish to upload files from something other than what HTML_ROOT is set to
# above, then reset it here:

View File

@@ -12,7 +12,7 @@
"""
import time
__version__="1.6.0a3"
__version__="1.6.0a4"
# Holds the program launch time in unix epoch seconds:
# Useful for calculating 'uptime.'

View File

@@ -1,77 +0,0 @@
#
# Copyright (c) 2009 Tom Keffer <tkeffer@gmail.com>
#
# See the file LICENSE.txt for your full rights.
#
# $Revision$
# $Author$
# $Date$
#
import os
import time
import syslog
import socket
import upload
class FtpData(object):
"""Synchronize local files with the web server"""
def __init__(self, source_dir, server, user, password, path, passive = 1, max_retries = 3):
self.source_dir = source_dir
self.server = server
self.user = user
self.password = password
self.path = path
self.passive = int(passive)
self.max_retries= int(max_retries)
def ftpData(self):
"""FTP the contents of the HTML directory to your web server. It uses
module 'upload' to do the heavy lifting.
"""
t1 = time.time()
# While 'upload' is a nice simple utility, it assumes that the
# root directory is the current directory. So, change directory to there:
os.chdir(self.source_dir)
# Now we can use the utility
upstr = upload.Upstreamer(self.server, self.user, self.password, self.path,
self.passive, self.max_retries)
finder = upload.Finder()
finder.find_all_files()
try:
n_uploaded = upstr.upstream(finder.get_new_files())
finder.reset_stamp(time.time())
finder.save()
t2=time.time()
syslog.syslog(syslog.LOG_INFO,
"ftp: uploaded %d files in %0.2f seconds" % (n_uploaded, (t2-t1)))
except socket.error, e:
syslog.syslog(syslog.LOG_ERR, "ftp: FTP failed. Reason: %s" % e)
if __name__ == '__main__':
import configobj
def test(config_path):
try :
config_dict = configobj.ConfigObj(config_path, file_error=True)
except IOError:
print "Unable to open configuration file ", config_path
exit()
ftp_dict = config_dict.get('FTP')
if ftp_dict:
html_dir = os.path.join(config_dict['Station']['WEEWX_ROOT'],
config_dict['HTML']['html_root'])
ftpData = FtpData(source_dir = html_dir, **ftp_dict)
ftpData.ftpData()
else:
print "No FTP section in configuration file. Nothing done."
test('/home/weewx/weewx.conf')

View File

@@ -14,13 +14,14 @@ import glob
import shutil
import syslog
import threading
import time
import configobj
import weewx.archive
import weewx.genfiles
import weewx.genimages
import weewx.ftpdata
import weeutil.ftpupload
import weeutil.weeutil
class StdReportEngine(threading.Thread):
@@ -97,6 +98,9 @@ class StdReportEngine(threading.Thread):
# Now inject any overrides for this specific report:
skin_dict.merge(self.config_dict['Reports'][report])
# Finally, add the report name:
skin_dict['REPORT_NAME'] = report
# If this is the first time the report engine has been run, then
# run the 'singleton list' of generators.
if self.first_run and skin_dict.has_key('singleton_list'):
@@ -190,9 +194,6 @@ class Ftp(ReportGenerator):
This will ftp everything in the public_html subdirectory to a webserver."""
def run(self):
f = open("/home/weewx/ftp.dict", "w")
self.skin_dict.write(f)
f.close()
# Check to see that all necessary options are present.
# If so, FTP the data up to a server.
@@ -200,13 +201,21 @@ class Ftp(ReportGenerator):
self.skin_dict.has_key('password') and
self.skin_dict.has_key('user') and
self.skin_dict.has_key('path')):
ftpData = weewx.ftpdata.FtpData(source_dir = os.path.join(self.config_dict['Station']['WEEWX_ROOT'],
self.config_dict['Reports']['HTML_ROOT']),
server = self.skin_dict['server'],
user = self.skin_dict['user'],
password = self.skin_dict['password'],
path = self.skin_dict['path'])
ftpData.ftpData()
t1 = time.time()
ftpData = weeutil.ftpupload.FtpUpload(server = self.skin_dict['server'],
user = self.skin_dict['user'],
password = self.skin_dict['password'],
local_root = os.path.join(self.config_dict['Station']['WEEWX_ROOT'],
self.config_dict['Reports']['HTML_ROOT']),
remote_root = self.skin_dict['path'],
name = self.skin_dict['REPORT_NAME'],
passive = bool(self.skin_dict.get('passive', True)),
max_tries = int(self.skin_dict.get('max_tries', 3)))
N = ftpData.run()
t2= time.time()
syslog.syslog(syslog.LOG_INFO, "reportengine: uploaded %d files in %0.1f seconds" % (N, (t2-t1)))
class Copy(ReportGenerator):
"""Class for managing the 'copy generator.'