diff --git a/CHANGES.md b/CHANGES.md index f9fb03c..bea0b7f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,9 +6,10 @@ The first two components of the version tags are tied to the ResourceSync specif version. Versions 0.6.x are intended to implement the v0.6 ResourceSync specification (http://www.openarchives.org/rs/0.6/), -v0.6.2 2013-05.. -- Fix example code in README! +v0.6.2 2013-05-14 +- Fixed example code in README! - Added --version flag +- Added --paths option to specify local paths to search rather than mappings v0.6.1 2013-05-09 - First release working toward v0.6 specification @@ -17,11 +18,11 @@ v0.6.1 2013-05-09 v0.5.3 2013-05-08 - Final release working with v0.5 specification -- Improve handling of assumed mappings for testing client on a local filesystem -- Fix sitemapindex support for large resource lists, add rel="up" for component sitemaps +- Improved handling of assumed mappings for testing client on a local filesystem +- Fixed sitemapindex support for large resource lists, add rel="up" for component sitemaps v0.5.2 2013-03-26 -- Fix setup.py +- Fixed setup.py v0.5.1 2013-03-22 - Code reworked for 0.5 specification (http://www.openarchives.org/rs/0.5/) diff --git a/bin/resync b/bin/resync index 2d57818..b98578f 100755 --- a/bin/resync +++ b/bin/resync @@ -2,7 +2,19 @@ """ resync: The ResourceSync command line client -Created by Simeon Warner on 2012-04... +Copyright 2012,2013 Simeon Warner + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License """ import logging @@ -122,8 +134,7 @@ def main(): # Options and arguments p = optparse.OptionParser(description='ResourceSync command line client', usage='usage: %prog [options] uri_path local_path (-h for help)', - version='%prog '+__version__, - add_help_option=False) + version='%prog '+__version__ ) # Modes # a. modes using remote sitemap/resources @@ -146,8 +157,10 @@ def main(): 'These modes act on files on the local disk') loc.add_option('--resourcelist', '--resource-list', '-r', action='store_true', help="write a resource list based on files on disk using uri=path mappings " - "in reverse to calculate URIs from the local paths. To STDOUT by " - "default, override with --outfile") + "in reverse to calculate URIs from the local paths. Scans local disk " + "based either on explicit --paths setting, else starting from all local " + "paths specified in the mappings. Writes to STDOUT by default, override " + "with --outfile") loc.add_option('--changelist', '--change-list', '-c', action='store_true', help="write a change list based on comparison of a reference sitemap " "(specify file with --reference) and either files on disk (using " @@ -167,19 +180,22 @@ def main(): # Specification of map between remote URI and local file paths, and remote sitemap nam = p.add_option_group('FILE/URI NAMING OPTIONS') nam.add_option('--outfile', type=str, action='store', - help='write sitemap to specified file rather than STDOUT') + help="write sitemap to specified file rather than STDOUT") + nam.add_option('--paths', type=str, action='store', + help="explicit set of paths for disk scan --resourceslist or --changelist " + "generation") nam.add_option('--sitemap', type=str, action='store', - help='explicitly set sitemap name, overriding default sitemap.xml ' - 'appended to first source URI specified in the mappings') + help="explicitly set sitemap name, overriding default sitemap.xml " + "appended to first source URI specified in the mappings") nam.add_option('--reference', type=str, action='store', - help='reference sitemap name for --changelist calculation') + help="reference sitemap name for --changelist calculation") nam.add_option('--newreference', type=str, action='store', - help='updated reference sitemap name for --changelist calculation') - nam.add_option('--dump', metavar="DUMPFILE", type=str, action='store', - help='write dump to specified file for --resourcelist or --changelist') + help="updated reference sitemap name for --changelist calculation") + nam.add_option('--dump', metavar='DUMPFILE', type=str, action='store', + help="write dump to specified file for --resourcelist or --changelist") nam.add_option('--changelist-uri','--change-list-uri', type=str, action='store', - help='explicitly set the changelist URI that will be use in --inc mode, ' - 'overrides process of getting this from the sitemap') + help="explicitly set the changelist URI that will be use in --inc mode, " + "overrides process of getting this from the sitemap") # Options that apply to multiple modes opt = p.add_option_group('MISCELANEOUS OPTIONS') @@ -228,8 +244,6 @@ def main(): opt.add_option('--eval', '-e', action='store_true', help="output evaluation of source/client synchronization performance... " "be warned, this is very verbose") - opt.add_option('--help', '-h', action='help', - help="this help") (args, map) = p.parse_args() @@ -296,7 +310,8 @@ def main(): elif (args.explore): c.explore() elif (args.resourcelist): - c.write_resource_list(outfile=args.outfile, + c.write_resource_list(paths.args.paths, + outfile=args.outfile, links=links, dump=args.dump) elif (args.changelist): @@ -305,6 +320,7 @@ def main(): c.write_change_list(ref_sitemap=args.reference, newref_sitemap=( args.newreference if (args.newreference) else None ), empty=args.empty, + paths=args.paths, outfile=args.outfile, links=links, dump=args.dump) diff --git a/resync/client.py b/resync/client.py index be94a38..acf5d35 100644 --- a/resync/client.py +++ b/resync/client.py @@ -81,19 +81,24 @@ class Client(object): return(self.sitemap_name) return(self.sitemap_uri(self.resource_list_name)) - @property - def resource_list(self): - """Return a resource list for files on disk based on current mappings + def build_resource_list(self, paths=None): + """Return a resource list for files on local disk + + The set of files is taken by disk scan from the paths specified or + else defaults to the paths specified in the current mappings Return resource_list. Uses existing self.mapper settings. """ - # 0. Sanity checks + # 0. Sanity checks, parse paths is specified if (len(self.mapper)<1): raise ClientFatalError("No source to destination mapping specified") + if (paths is not None): + # Expect comma separated list of paths + paths=paths.split(',') # 1. Build from disk - rlb = ResourceListBuilder(do_md5=self.checksum,mapper=self.mapper) + rlb = ResourceListBuilder(set_md5=self.checksum,mapper=self.mapper) rlb.add_exclude_files(self.exclude_patterns) - rl = rlb.from_disk() + rl = rlb.from_disk(paths=paths) # 2. Set defaults and overrides rl.allow_multifile = self.allow_multifile rl.pretty_xml = self.pretty_xml @@ -136,8 +141,7 @@ class Client(object): self.checksum=False self.logger.info("Not calculating checksums on destination as not present in source resource_list") # 1.b destination resource_list mapped back to source URIs - rlb = ResourceListBuilder(mapper=self.mapper) - rlb.do_md5=self.checksum + rlb = ResourceListBuilder(set_md5=self.checksum, mapper=self.mapper) dst_resource_list = rlb.from_disk() ### 2. Compare these resource_lists respecting any comparison options (same,updated,deleted,created)=dst_resource_list.compare(src_resource_list) @@ -490,23 +494,31 @@ class Client(object): caps = ['capabilitylist'] return( options[inp].uri, caps, inp ) - def write_resource_list(self,outfile=None,links=None,dump=None): + def write_resource_list(self,paths=None,outfile=None,links=None,dump=None): """Write a resource list sitemap for files on local disk - Set of resources included is based on the mappings. Optionally - links can be added. Output will be to stdout unless outfile + Set of resources included is based on paths setting or else the mappings. + Optionally links can be added. Output will be to stdout unless outfile is specified. """ - rl = self.resource_list - rl.ln = links + rl = self.build_resource_list(paths=paths) + if (links is not None): + rl.ln = links if (outfile is None): print rl.as_xml() else: rl.write(basename=outfile) self.write_dump_if_requested(rl,dump) - def write_change_list(self,outfile=None,ref_sitemap=None,newref_sitemap=None, + def write_change_list(self,paths=None,outfile=None,ref_sitemap=None,newref_sitemap=None, empty=None,links=None,dump=None): + """Write a change list + + Unless the both ref_sitemap and newref_sitemap are specified then the Change + List is calculated between the reference an the current state of files on + disk. The files on disk are scanned based either on the paths setting or + else on the mappings. + """ cl = ChangeList(ln=links) if (not empty): # 1. Get and parse reference sitemap @@ -515,7 +527,7 @@ class Client(object): # or build resource_list from files on disk if (newref_sitemap is None): # Get resource list from disk - new_rl = self.resource_list + new_rl = self.build_resource_list(paths=paths) else: new_rl = self.read_reference_resource_list(newref_sitemap,name='new reference') # 3. Calculate change list diff --git a/resync/resource_list_builder.py b/resync/resource_list_builder.py index f95e45e..466613c 100644 --- a/resync/resource_list_builder.py +++ b/resync/resource_list_builder.py @@ -1,12 +1,16 @@ """ResourceListBuilder to create ResourceList objects -Currently implements build from files on disk only. +Currently implements build from files on disk only. Attributes: -- do_md5 set true to calculate MD5 sums for all files -- do_length set true to include file length in resource_list +- set_path set true to add path attribute for each resource +- set_md5 set true to calculate MD5 sums for all files +- set_length set true to include file length in resource_list (defaults true) - exclude_dirs is a list of directory names to exclude (defaults to ['CVS','.git')) + +FIXME - should add options to set sha1 and sha256 in addition or as +alternatives to md5. """ import os @@ -24,16 +28,24 @@ from utils import compute_md5_for_file class ResourceListBuilder(): - def __init__(self, do_md5=False, do_length=True, mapper=None): + def __init__(self, mapper=None, set_md5=False, set_length=True, set_path=False): """Create ResourceListBuilder object, optionally set options - Optionaly sets the following attributes: - - do_md5 - True to add md5 digests for each resource - - do_length - False to not add length for each resources + The mapper attribute must be set before a call to from_disk() in order to + map between local filenames and URIs. + + The following attributes may be set to determine information added to + each Resource object based on the disk scan: + - set_md5 - True to add md5 digests for each resource. This may add + significant time to the scan process as each file has to be read to + compute the sum + - set_length - False to not add length for each resources + - set_path - True to add local path information for each file/resource """ - self.do_md5 = do_md5 - self.do_length = do_length self.mapper = mapper + self.set_path = set_path + self.set_md5 = set_md5 + self.set_length = set_length self.exclude_files = ['sitemap\d{0,5}.xml'] self.exclude_dirs = ['CVS','.git'] self.include_symlinks = False @@ -47,6 +59,7 @@ class ResourceListBuilder(): self.exclude_files.append(pattern) def compile_excludes(self): + """Compile a set of regexps for files to be exlcuded from scans""" self.compiled_exclude_files = [] for pattern in self.exclude_files: self.compiled_exclude_files.append(re.compile(pattern)) @@ -58,22 +71,30 @@ class ResourceListBuilder(): return(True) return(False) - def from_disk(self, resource_list=None, set_path=False): + def from_disk(self, resource_list=None, paths=None): """Create or extend resource_list with resources from disk scan - Assumes very simple disk path to URL mapping: chop path and - replace with url_path. Returns the new or extended ResourceList + Assumes very simple disk path to URL mapping (in self.mapping): chop + path and replace with url_path. Returns the new or extended ResourceList object. If a resource_list is specified then items are added to that rather than creating a new one. - If set_path is True then the path attribue will be set with the - local path for each Resource. + If paths is specified then these are used instead of the set + of local paths in self.mapping. + + Example usage with mapping start paths: + + mapper=Mapper('http://example.org/path','/path/to/files') + rlb = ResourceListBuilder(mapper=mapper) + m = rlb.from_disk() + + Example usage with explicit paths: mapper=Mapper('http://example.org/path','/path/to/files') - mb = ResourceListBuilder(mapper=mapper) - m = resource_list_from_disk() + rlb = ResourceListBuilder(mapper=mapper) + m = rlb.from_disk(paths=['/path/to/files/a','/path/to/files/b']) """ num=0 # Either use resource_list passed in or make a new one @@ -81,58 +102,72 @@ class ResourceListBuilder(): resource_list = ResourceList() # Compile exclude pattern matches self.compile_excludes() + # Work out start paths from map if not explicitly specified + if (paths is None): + paths=[] + for map in self.mapper.mappings: + paths.append(map.dst_path) # Run for each map in the mappings - for map in self.mapper.mappings: - self.logger.info("Scanning disk for %s" % (str(map))) - self.from_disk_add_map(resource_list=resource_list, map=map, set_path=set_path) + for path in paths: + self.logger.info("Scanning disk from %s" % (path)) + self.from_disk_add_path(path=path, resource_list=resource_list) return(resource_list) - def from_disk_add_map(self, resource_list=None, map=None, set_path=False): - """Add to resource_list with resources from disk scan based one map - - If set_path is True then the path attribue will be set with the - local path for each Resource. + def from_disk_add_path(self, path=None, resource_list=None): + """Add to resource_list with resources from disk scan starting at path """ # sanity - if (resource_list is None or map is None): - raise ValueError("Must specify resource_list and map") - path=map.dst_path - #print "walking: %s" % (path) - # for each file: create Resource object, add, increment counter - num_files=0 - for dirpath, dirs, files in os.walk(path,topdown=True): - for file_in_dirpath in files: - num_files+=1 - if (num_files%50000 == 0): - self.logger.info("ResourceListBuilder.from_disk_add_map: %d files..." % (num_files)) - try: - if self.exclude_file(file_in_dirpath): - self.logger.debug("Excluding file %s" % (file_in_dirpath)) - continue - # get abs filename and also URL - file = os.path.join(dirpath,file_in_dirpath) - if (not os.path.isfile(file) or not (self.include_symlinks or not os.path.islink(file))): - continue - uri = map.dst_to_src(file) - if (uri is None): - raise Exception("Internal error, mapping failed") - file_stat=os.stat(file) - except OSError as e: - sys.stderr.write("Ignoring file %s (error: %s)" % (file,str(e))) - continue - timestamp = file_stat.st_mtime #UTC - r = Resource(uri=uri,timestamp=timestamp) - if (set_path): - r.path=file - if (self.do_md5): - # add md5 - r.md5=compute_md5_for_file(file) - if (self.do_length): - # add length - r.length=file_stat.st_size - resource_list.add(r) - # prune list of dirs based on self.exclude_dirs - for exclude in self.exclude_dirs: - if exclude in dirs: - self.logger.debug("Excluding dir %s" % (exclude)) - dirs.remove(exclude) + if (path is None or resource_list is None or self.mapper is None): + raise ValueError("Must specify path, resource_list and mapper") + # is path a directory or a file? for each file: create Resource object, + # add, increment counter + if os.path.isdir(path): + num_files=0 + for dirpath, dirs, files in os.walk(path,topdown=True): + for file_in_dirpath in files: + num_files+=1 + if (num_files%50000 == 0): + self.logger.info("ResourceListBuilder.from_disk_add_path: %d files..." % (num_files)) + self.add_file(resource_list=resource_list,dir=dirpath,file=file_in_dirpath) + # prune list of dirs based on self.exclude_dirs + for exclude in self.exclude_dirs: + if exclude in dirs: + self.logger.debug("Excluding dir %s" % (exclude)) + dirs.remove(exclude) + else: + # single file + self.add_file(resource_list=resource_list,file=path) + + def add_file(self, resource_list=None, dir=None, file=None): + """Add a single file to resource_list + + Follows object settings of set_path, set_md5 and set_length. + """ + try: + if self.exclude_file(file): + self.logger.debug("Excluding file %s" % (file)) + return + # get abs filename and also URL + if (dir is not None): + file = os.path.join(dir,file) + if (not os.path.isfile(file) or not (self.include_symlinks or not os.path.islink(file))): + return + uri = self.mapper.dst_to_src(file) + if (uri is None): + raise Exception("Internal error, mapping failed") + file_stat=os.stat(file) + except OSError as e: + sys.stderr.write("Ignoring file %s (error: %s)" % (file,str(e))) + return + timestamp = file_stat.st_mtime #UTC + r = Resource(uri=uri,timestamp=timestamp) + if (self.set_path): + # add full local path + r.path=file + if (self.set_md5): + # add md5 + r.md5=compute_md5_for_file(file) + if (self.set_length): + # add length + r.length=file_stat.st_size + resource_list.add(r) diff --git a/resync/test/test_client.py b/resync/test/test_client.py index 46efc73..04e6d62 100644 --- a/resync/test/test_client.py +++ b/resync/test/test_client.py @@ -28,12 +28,8 @@ class TestClient(unittest.TestCase): def test01_make_resource_list_empty(self): c = Client() - # No mapping is error - # - def wrap_resource_list_property_call(c): - # do this because assertRaises( ClientFatalError, c.resource_list ) doesn't work - return(c.resource_list) - self.assertRaises( ClientFatalError, wrap_resource_list_property_call, c ) + # No mapping is an error + self.assertRaises( ClientFatalError, c.build_resource_list ) def test02_bad_source_uri(self): c = Client() @@ -81,6 +77,25 @@ class TestClient(unittest.TestCase): c.parse_document() self.assertTrue( re.search(r'Parsed changedump document with 3 entries',capturer.result) ) + def test40_write_resource_list(self): + c = Client() + c.set_mappings( ['http://example.org/','resync/test/testdata'] ) + # with no explicit paths seting the mapping will be used + + with capture_stdout() as capturer: + c.write_resource_list() + self.assertTrue( re.search(r'', capturer.result ) ) + self.assertTrue( re.search(r'http://example.org/dir1/file_b2001-09-09T01:46:40Z', capturer.result ) ) + self.assertFalse( re.search(r'dir2', capturer.result ) ) + if __name__ == '__main__': suite = unittest.TestLoader().loadTestsFromTestCase(TestClient) unittest.TextTestRunner(verbosity=2).run(suite) diff --git a/resync/test/test_resource_list_builder.py b/resync/test/test_resource_list_builder.py index 58a3c34..eef569e 100644 --- a/resync/test/test_resource_list_builder.py +++ b/resync/test/test_resource_list_builder.py @@ -15,40 +15,104 @@ class TestResourceListBuilder(unittest.TestCase): os.utime( "resync/test/testdata/dir1/file_a", (0, 1343236426 ) ) os.utime( "resync/test/testdata/dir1/file_b", (0, 1000000000 ) ) - def test1_simple_output(self): + def test01_simple_scan(self): rlb = ResourceListBuilder() rlb.mapper = Mapper(['http://example.org/t','resync/test/testdata/dir1']) rl = rlb.from_disk() - rl.md['modified']=None #don't write so we can test output easily - self.assertEqual(rl.as_xml(),'\nhttp://example.org/t/file_a2012-07-25T17:13:46Zhttp://example.org/t/file_b2001-09-09T01:46:40Z' ) + self.assertEqual( len(rl), 2 ) + rli = iter(rl) + r = rli.next() + self.assertEqual( r.uri, 'http://example.org/t/file_a' ) + self.assertEqual( r.lastmod, '2012-07-25T17:13:46Z' ) + self.assertEqual( r.md5, None ) + self.assertEqual( r.length, 20 ) + self.assertEqual( r.path, None ) + r = rli.next() + self.assertEqual( r.uri, 'http://example.org/t/file_b' ) + self.assertEqual( r.lastmod, '2001-09-09T01:46:40Z' ) + self.assertEqual( r.md5, None ) + self.assertEqual( r.length, 45 ) + self.assertEqual( r.path, None ) - def test2_pretty_output(self): - rlb = ResourceListBuilder() + def test02_no_length(self): + rlb = ResourceListBuilder(set_length=False) rlb.mapper = Mapper(['http://example.org/t','resync/test/testdata/dir1']) rl = rlb.from_disk() - rl.md['modified']=None #don't write so we can test output easily - rl.pretty_xml=True - self.assertEqual(rl.as_xml(),'\n\n\nhttp://example.org/t/file_a2012-07-25T17:13:46Z\nhttp://example.org/t/file_b2001-09-09T01:46:40Z\n' ) + self.assertEqual( len(rl), 2 ) + rli = iter(rl) + r = rli.next() + self.assertEqual( r.uri, 'http://example.org/t/file_a' ) + self.assertEqual( r.lastmod, '2012-07-25T17:13:46Z' ) + self.assertEqual( r.md5, None ) + self.assertEqual( r.length, None ) + self.assertEqual( r.path, None ) + r = rli.next() + self.assertEqual( r.uri, 'http://example.org/t/file_b' ) + self.assertEqual( r.lastmod, '2001-09-09T01:46:40Z' ) + self.assertEqual( r.md5, None ) + self.assertEqual( r.length, None ) + self.assertEqual( r.path, None ) - def test3_with_md5(self): - rlb = ResourceListBuilder(do_md5=True) + def test03_set_md5(self): + rlb = ResourceListBuilder(set_md5=True) rlb.mapper = Mapper(['http://example.org/t','resync/test/testdata/dir1']) rl = rlb.from_disk() - xml = rl.as_xml() - self.assertNotEqual( None, re.search('http://example.org/t/file_a[\w\:\-]+Z',xml) ) #must escape + in md5 - self.assertNotEqual( None, re.search('http://example.org/t/file_b[\w\:\-]+Z',xml) ) + self.assertEqual( len(rl), 2 ) + rli = iter(rl) + r = rli.next() + self.assertEqual( r.uri, 'http://example.org/t/file_a' ) + self.assertEqual( r.lastmod, '2012-07-25T17:13:46Z' ) + self.assertEqual( r.md5, 'a/Jv1mYBtSjS4LR+qoft/Q==' ) + self.assertEqual( r.length, 20 ) + self.assertEqual( r.path, None ) + r = rli.next() + self.assertEqual( r.uri, 'http://example.org/t/file_b' ) + self.assertEqual( r.lastmod, '2001-09-09T01:46:40Z' ) + self.assertEqual( r.md5, 'RS5Uva4WJqxdbnvoGzneIQ==' ) + self.assertEqual( r.length, 45 ) + self.assertEqual( r.path, None ) - def test4_data(self): - rlb = ResourceListBuilder(do_md5=True) + def test04_data(self): + rlb = ResourceListBuilder(set_path=True,set_md5=True) rlb.mapper = Mapper(['http://example.org/t','resync/test/testdata/dir1']) - rl = rlb.from_disk(set_path=True) + rl = rlb.from_disk() self.assertEqual( len(rl), 2) - r1 = rl.resources.get('http://example.org/t/file_a') - self.assertTrue( r1 is not None ) - self.assertEqual( r1.uri, 'http://example.org/t/file_a' ) - self.assertEqual( r1.lastmod, '2012-07-25T17:13:46Z' ) - self.assertEqual( r1.md5, 'a/Jv1mYBtSjS4LR+qoft/Q==' ) - self.assertEqual( r1.path, 'resync/test/testdata/dir1/file_a' ) + r = rl.resources.get('http://example.org/t/file_a') + self.assertTrue( r is not None ) + self.assertEqual( r.uri, 'http://example.org/t/file_a' ) + self.assertEqual( r.lastmod, '2012-07-25T17:13:46Z' ) + self.assertEqual( r.md5, 'a/Jv1mYBtSjS4LR+qoft/Q==' ) + self.assertEqual( r.path, 'resync/test/testdata/dir1/file_a' ) + + def test05_from_disk_paths(self): + rlb = ResourceListBuilder() + rlb.mapper = Mapper(['http://example.org/t','resync/test/testdata/dir1']) + # no path, should get no resources + rl = rlb.from_disk(paths=[]) + self.assertEqual( len(rl), 0) + # full path, 2 resources + rl = rlb.from_disk(paths=['resync/test/testdata/dir1']) + self.assertEqual( len(rl), 2) + # new object with mapper covering larger space of disk + rlb = ResourceListBuilder(set_path=True) + rlb.mapper = Mapper(['http://example.org/t','resync/test/testdata']) + # same path with 2 resources + rl = rlb.from_disk(paths=['resync/test/testdata/dir1']) + self.assertEqual( len(rl), 2) + # same path with 2 resources + rl = rlb.from_disk(paths=['resync/test/testdata/dir1','resync/test/testdata/dir2']) + self.assertEqual( len(rl), 3) + # path that is just a single file + rl = rlb.from_disk(paths=['resync/test/testdata/dir1/file_a']) + self.assertEqual( len(rl), 1) + rli = iter(rl) + r = rli.next() + self.assertTrue( r is not None ) + self.assertEqual( r.uri, 'http://example.org/t/dir1/file_a' ) + self.assertEqual( r.lastmod, '2012-07-25T17:13:46Z' ) + self.assertEqual( r.md5, None ) + self.assertEqual( r.length, 20 ) + self.assertEqual( r.path, 'resync/test/testdata/dir1/file_a' ) if __name__ == '__main__': suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestResourceListBuilder)