Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3577a202b5 | ||
|
|
6a35c1c95d | ||
|
|
51b384cc07 | ||
|
|
059202695c | ||
|
|
e2b0fc9e43 | ||
|
|
4ecdc18703 | ||
|
|
9db9950efc | ||
|
|
f5cd751c67 | ||
|
|
49a52478e7 | ||
|
|
1bb70c597d | ||
|
|
8c603fa4cc | ||
|
|
44ea9545dd | ||
|
|
785e851f64 | ||
|
|
9ffa84a768 | ||
|
|
518fd94e5c | ||
|
|
065363e462 | ||
|
|
464f650bea | ||
|
|
a8a193e325 | ||
|
|
e0f638d227 | ||
|
|
97f90bc58a | ||
|
|
42d9269754 | ||
|
|
e3df46b37d | ||
|
|
01d063e280 | ||
|
|
41d16131be | ||
|
|
e10416a234 |
7
.gitignore
vendored
7
.gitignore
vendored
@ -11,10 +11,8 @@ htmlcov
|
||||
# Files generated by client / local config
|
||||
.resync-client-status.cfg
|
||||
resync-client.log
|
||||
resync.library.cornell.edu_sim100
|
||||
resync.library.cornell.edu_sim1000
|
||||
resync.library.cornell.edu_sim10000
|
||||
localhost_8888
|
||||
tmp
|
||||
# Related to packaging
|
||||
resync.egg-info
|
||||
# Other...
|
||||
@ -22,5 +20,4 @@ resync.egg-info
|
||||
*.pyc
|
||||
*~
|
||||
.cache
|
||||
rc
|
||||
re
|
||||
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
# resync change log
|
||||
|
||||
v2.0.1 2021-03-23
|
||||
* Route all URI and file requests through `resync/url_or_file_open.py` so that settings such as authentication headers can be consistently applied
|
||||
* Do not exclude any directories from sync by default, specify with --exclude
|
||||
* Fix comparison of using possibly multiple checksums
|
||||
* Improve test coverage
|
||||
* Remove Python 2 cruft
|
||||
|
||||
v2.0.0 2020-12-16
|
||||
* Supports ResourceSync v1.1 ANSI/NISO Z39.99-2017 <http://www.openarchives.org/rs/1.1/toc> as default
|
||||
* Support for the prior v1.0 ANSI/NISO Z39.99-2014 <http://www.openarchives.org/rs/1.0/toc> is retained with `spec-version='1.0'` option in scripts and `spec_version='1.0'` in various classes
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# resync
|
||||
|
||||
[](https://travis-ci.org/resync/resync)
|
||||
[](https://coveralls.io/github/resync/resync)
|
||||
[](https://travis-ci.org/resync/resync)
|
||||
[](https://coveralls.io/github/resync/resync)
|
||||
|
||||
**resync** is a ResourceSync library with supporting client scipts,
|
||||
written in python.
|
||||
@ -102,7 +102,7 @@ Thanks to: [Bernhard Haslhofer](https://github.com/behas),
|
||||
[Robert Sanderson](<https://github.com/azaroth42)
|
||||
and [other contributors](https://github.com/resync/resync/graphs/contributors).
|
||||
|
||||
See [CONTRIBUTING.md](https://github.com/resync/resync/blob/master/CONTRIBUTING.md)
|
||||
See [CONTRIBUTING.md](https://github.com/resync/resync/blob/main/CONTRIBUTING.md)
|
||||
for guidelines for contributing.
|
||||
|
||||
## Copyright and License
|
||||
|
||||
@ -9,11 +9,11 @@ resync is at <https://pypi.python.org/pypi/resync> on pypi
|
||||
0. In `develop` branch: bump version number in `resync/__init__.py` and check `CHANGES.md` is up to date
|
||||
1. Check all tests good with appropriate Python 3.x (`python setup.py test` and CI)
|
||||
2. Check code is up-to-date with github version
|
||||
3. Check out `master` and merge in `develop`
|
||||
3. Check out `main` and merge in `develop`
|
||||
4. Check all tests still good (`python setup.py test` and CI)
|
||||
5. Make sure master `README.md` has correct travis-ci icon link
|
||||
5. Make sure main `README.md` has correct travis-ci icon link
|
||||
6. Check branches as expected (`git branch -a`)
|
||||
7. Check local build and version reported OK (`python setup.py build; python setup.py install; resync-sync --version`)
|
||||
7. Check local build and version reported OK (`python setup.py build; python setup.py install; resync-sync -h`)
|
||||
8. Check client works with simulator:
|
||||
|
||||
```
|
||||
@ -58,14 +58,22 @@ resync is at <https://pypi.python.org/pypi/resync> on pypi
|
||||
|
||||
9. If all checks out OK, tag and push the new version to github:
|
||||
|
||||
```
|
||||
git tag -n1
|
||||
#...current tags
|
||||
git tag -a -m "ResourceSync v1.1 specification, add --delay" v2.0.0
|
||||
git push --tags
|
||||
```
|
||||
git tag -n1
|
||||
#...current tags
|
||||
git tag -a -m "ResourceSync library and client" v2.0.0
|
||||
git push --tags
|
||||
```
|
||||
|
||||
python setup.py sdist upload
|
||||
```
|
||||
10. Upload to PyPI
|
||||
|
||||
10. Then check on PyPI at <https://pypi.python.org/pypi/resync>
|
||||
|
||||
```
|
||||
rm -r dist
|
||||
python setup.py sdist bdist_wheel; ls dist
|
||||
# Should be source and wheel files for just this version
|
||||
twine upload dist/*
|
||||
```
|
||||
|
||||
10. Check on PyPI at <https://pypi.python.org/pypi/resync>
|
||||
11. Finally, back on `develop` branch start new version number by editing `resync/__init__.py` and `CHANGES.md`
|
||||
|
||||
@ -70,7 +70,7 @@ def main():
|
||||
verbose=args.verbose)
|
||||
|
||||
print("----- ResourceSync Explorer -----")
|
||||
process_shared_misc_options(args)
|
||||
process_shared_misc_options(args, include_remote=True)
|
||||
|
||||
c = Explorer(hashes=args.hash,
|
||||
verbose=args.verbose)
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
This is the one place the version number for resync is stored.
|
||||
"""
|
||||
__version__ = '2.0.0'
|
||||
__version__ = '2.0.1'
|
||||
|
||||
# Enable easy import for core classes, e.g.
|
||||
# from resync import Resource
|
||||
|
||||
@ -11,7 +11,8 @@ import distutils.dir_util
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
import shutil
|
||||
import socket
|
||||
|
||||
from .resource_list_builder import ResourceListBuilder
|
||||
from .resource_list import ResourceList
|
||||
@ -224,7 +225,7 @@ class Client(object):
|
||||
rlb = ResourceListBuilder(set_hashes=self.hashes, mapper=self.mapper)
|
||||
rlb.set_path = set_path
|
||||
try:
|
||||
rlb.add_exclude_files(self.exclude_patterns)
|
||||
rlb.add_exclude_patterns(self.exclude_patterns)
|
||||
rl = rlb.from_disk(paths=paths)
|
||||
except ValueError as e:
|
||||
raise ClientFatalError(str(e))
|
||||
@ -504,15 +505,12 @@ class Client(object):
|
||||
# 1. GET
|
||||
for try_i in range(1, self.tries + 1):
|
||||
try:
|
||||
r = requests.get(resource.uri, timeout=self.timeout, stream=True)
|
||||
# Fail on 4xx or 5xx
|
||||
r.raise_for_status()
|
||||
with open(filename, 'wb') as fd:
|
||||
for chunk in r.iter_content(chunk_size=1024):
|
||||
fd.write(chunk)
|
||||
with url_or_file_open(resource.uri, timeout=self.timeout) as fh_in:
|
||||
with open(filename, 'wb') as fh_out:
|
||||
shutil.copyfileobj(fh_in, fh_out)
|
||||
num_updated += 1
|
||||
break
|
||||
except requests.Timeout as e:
|
||||
except socket.timeout as e:
|
||||
if try_i < self.tries:
|
||||
msg = 'Download timed out, retrying...'
|
||||
self.logger.info(msg)
|
||||
@ -525,7 +523,7 @@ class Client(object):
|
||||
return(num_updated)
|
||||
else:
|
||||
raise ClientFatalError(msg)
|
||||
except (requests.RequestException, IOError) as e:
|
||||
except IOError as e:
|
||||
msg = "Failed to GET %s -- %s" % (resource.uri, str(e))
|
||||
if (self.ignore_failures):
|
||||
self.logger.warning(msg)
|
||||
|
||||
@ -7,8 +7,6 @@ import argparse
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import logging.config
|
||||
import re
|
||||
from urllib.request import urlopen
|
||||
|
||||
from .url_or_file_open import set_url_or_file_open_config
|
||||
|
||||
@ -181,7 +179,7 @@ def add_shared_misc_options(opt, default_logfile, include_remote=False):
|
||||
"--incremental sync")
|
||||
opt.add_argument('--exclude', type=str, action='append',
|
||||
help="exclude resources with URI or filename matching the python regex "
|
||||
"supplied (see: <https://docs.python.org/2/howto/regex.html> for regex "
|
||||
"supplied (see: <https://docs.python.org/3/howto/regex.html> for regex "
|
||||
"information, repeat option for multiple excludes)")
|
||||
opt.add_argument('--multifile', '-m', action='store_true',
|
||||
help="disable reading and output of sitemapindex for multifile sitemap")
|
||||
|
||||
@ -17,7 +17,6 @@ import distutils.dir_util
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
|
||||
from .mapper import Mapper
|
||||
from .sitemap import Sitemap
|
||||
@ -164,7 +163,7 @@ class Explorer(Client):
|
||||
caps = 'resource'
|
||||
else:
|
||||
caps = self.allowed_entries(capability)
|
||||
elif (r.capability is 'resource'):
|
||||
elif (r.capability == 'resource'):
|
||||
caps = r.capability
|
||||
else:
|
||||
caps = [r.capability]
|
||||
@ -278,38 +277,36 @@ class Explorer(Client):
|
||||
print("HEAD %s" % (uri))
|
||||
if (re.match(r'^\w+:', uri)):
|
||||
# Looks like a URI
|
||||
response = requests.head(uri)
|
||||
with url_or_file_open(uri, method='HEAD') as response:
|
||||
status_code = response.code()
|
||||
headers = response.headers()
|
||||
else:
|
||||
# Mock up response if we have a local file
|
||||
response = self.head_on_file(uri)
|
||||
print(" status: %s" % (response.status_code))
|
||||
if (response.status_code == '200'):
|
||||
(status_code, headers) = self.head_on_file(uri)
|
||||
print(" status: %s" % (status_code))
|
||||
if (status_code == '200'):
|
||||
# print some of the headers
|
||||
for header in ['content-length', 'last-modified',
|
||||
'lastmod', 'content-type', 'etag']:
|
||||
if header in response.headers:
|
||||
if header in headers:
|
||||
check_str = ''
|
||||
if (check_headers is not None and header in check_headers):
|
||||
if (response.headers[header] == check_headers[header]):
|
||||
if (headers[header] == check_headers[header]):
|
||||
check_str = ' MATCHES EXPECTED VALUE'
|
||||
else:
|
||||
check_str = ' EXPECTED %s' % (
|
||||
check_headers[header])
|
||||
print(
|
||||
" %s: %s%s" %
|
||||
(header, response.headers[header], check_str))
|
||||
print(" %s: %s%s" % (header, headers[header], check_str))
|
||||
|
||||
def head_on_file(self, file):
|
||||
"""Mock up requests.head(..) response on local file."""
|
||||
response = HeadResponse()
|
||||
if (not os.path.isfile(file)):
|
||||
response.status_code = '404'
|
||||
else:
|
||||
response.status_code = '200'
|
||||
response.headers[
|
||||
'last-modified'] = datetime_to_str(os.path.getmtime(file))
|
||||
response.headers['content-length'] = os.path.getsize(file)
|
||||
return(response)
|
||||
"""Get fake status code and headers from local file."""
|
||||
status_code = '404'
|
||||
headers = {}
|
||||
if os.path.isfile(file):
|
||||
status_code = '200'
|
||||
headers['last-modified'] = datetime_to_str(os.path.getmtime(file))
|
||||
headers['content-length'] = os.path.getsize(file)
|
||||
return(status_code, headers)
|
||||
|
||||
def allowed_entries(self, capability):
|
||||
"""Return list of allowed entries for given capability document.
|
||||
@ -367,15 +364,6 @@ class XResource(object):
|
||||
self.checks = checks
|
||||
|
||||
|
||||
class HeadResponse(object):
|
||||
"""Object to mock up requests.head(...) response."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize with no status_code and no headers."""
|
||||
self.status_code = None
|
||||
self.headers = {}
|
||||
|
||||
|
||||
class ExplorerQuit(Exception):
|
||||
"""Exception raised when user quits normally, no error."""
|
||||
|
||||
|
||||
@ -34,14 +34,16 @@ class Hashes(object):
|
||||
up [http://docs.python.org/library/base64.html]
|
||||
"""
|
||||
|
||||
NAME_TO_ATTRIBUTE = {'md5': 'md5', 'sha-1': 'sha1', 'sha-256': 'sha256'}
|
||||
|
||||
def __init__(self, hashes=None, file=None):
|
||||
"""Initialize Hasher object with types of hash to caluclate.
|
||||
"""Initialize Hashes object with types of hash to caluclate.
|
||||
|
||||
If file is supplied then compute for that file.
|
||||
"""
|
||||
self.hashes = set()
|
||||
for hash in hashes:
|
||||
if (hash not in ['md5', 'sha-1', 'sha-256']):
|
||||
if (hash not in self.NAME_TO_ATTRIBUTE.keys()):
|
||||
raise Exception("Hash type %s not supported" % (hash))
|
||||
self.hashes.add(hash)
|
||||
#
|
||||
@ -74,14 +76,24 @@ class Hashes(object):
|
||||
data = f.read(block_size)
|
||||
if not data:
|
||||
break
|
||||
if (self.md5_calc is not None):
|
||||
if self.md5_calc is not None:
|
||||
self.md5_calc.update(data)
|
||||
if (self.sha1_calc is not None):
|
||||
if self.sha1_calc is not None:
|
||||
self.sha1_calc.update(data)
|
||||
if (self.sha256_calc is not None):
|
||||
if self.sha256_calc is not None:
|
||||
self.sha256_calc.update(data)
|
||||
f.close()
|
||||
|
||||
def set(self, resource):
|
||||
"""Set hash values for resource from current file.
|
||||
|
||||
Assumes that resource has appropriate attributes or setters
|
||||
with names md5, sha1, etc. and that hashes have been calculated.
|
||||
"""
|
||||
for hash in self.hashes:
|
||||
att = self.NAME_TO_ATTRIBUTE[hash]
|
||||
setattr(resource, att, getattr(self, att))
|
||||
|
||||
@property
|
||||
def md5(self):
|
||||
"""Return MD5 hash calculated."""
|
||||
|
||||
@ -101,7 +101,7 @@ class ListBaseWithIndex(ListBase):
|
||||
self.logger.debug(
|
||||
"Read %d bytes from %s" %
|
||||
(self.content_length, uri))
|
||||
except KeyError:
|
||||
except (KeyError, TypeError):
|
||||
# If we don't get a length then c'est la vie
|
||||
self.logger.debug("Read ????? bytes from %s" % (uri))
|
||||
pass
|
||||
@ -170,7 +170,7 @@ class ListBaseWithIndex(ListBase):
|
||||
try:
|
||||
self.content_length = int(fh.info()['Content-Length'])
|
||||
self.bytes_read += self.content_length
|
||||
except KeyError:
|
||||
except (KeyError, TypeError):
|
||||
# If we don't get a length then c'est la vie
|
||||
pass
|
||||
self.logger.info(
|
||||
|
||||
@ -173,23 +173,35 @@ class Map:
|
||||
This does not rely on the destination filepath actually
|
||||
existing on the local filesystem, just on pattern matching.
|
||||
Return source URI on success, None on failure.
|
||||
|
||||
Relies upon self.dst_path and self.src_path not including trailing
|
||||
slashes. However, a match of just self.dst_path withouth a trailing
|
||||
slash will return self.src_path with a trailing slash.
|
||||
"""
|
||||
m = re.match(self.dst_path + "/(.*)$", dst_file)
|
||||
m = re.match(self.dst_path + "(/.*)?$", dst_file)
|
||||
if (m is None):
|
||||
return(None)
|
||||
rel_path = m.group(1)
|
||||
return(self.src_uri + '/' + rel_path)
|
||||
if rel_path is None:
|
||||
rel_path = '/'
|
||||
return self.src_uri + rel_path
|
||||
|
||||
def src_to_dst(self, src_uri):
|
||||
"""Return the dst filepath from the src URI.
|
||||
|
||||
Returns None on failure, destination path on success.
|
||||
Returns None on failure, local destination path on success.
|
||||
|
||||
Relies upon self.dst_path and self.src_path not including trailing
|
||||
slashes. However, a match of just self.src_path withouth a trailing
|
||||
slash will return self.dst_path with a trailing slash.
|
||||
"""
|
||||
m = re.match(self.src_uri + "/(.*)$", src_uri)
|
||||
m = re.match(self.src_uri + "(/.*)?$", src_uri)
|
||||
if (m is None):
|
||||
return(None)
|
||||
rel_path = m.group(1)
|
||||
return(self.dst_path + '/' + rel_path)
|
||||
if rel_path is None:
|
||||
rel_path = '/'
|
||||
return self.dst_path + rel_path
|
||||
|
||||
def unsafe(self):
|
||||
"""True if the mapping is unsafe for an update.
|
||||
|
||||
@ -1,11 +1,8 @@
|
||||
"""ResourceSync Resources - information about a web resource and changes."""
|
||||
|
||||
import re
|
||||
try: # python3
|
||||
from urllib.parse import urlparse
|
||||
except ImportError: # python2
|
||||
from urlparse import urlparse
|
||||
from posixpath import basename
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .w3c_datetime import str_to_datetime, datetime_to_str
|
||||
|
||||
@ -58,19 +55,21 @@ class Resource(object):
|
||||
|
||||
__slots__ = ('uri', 'timestamp', 'length', 'mime_type',
|
||||
'md5', 'sha1', 'sha256', 'change', 'ts_datetime',
|
||||
'path', '_extra', 'ln')
|
||||
'path', 'ln', '_extra')
|
||||
|
||||
CHANGE_TYPES = ['created', 'updated', 'deleted']
|
||||
|
||||
def __init__(self, uri=None, timestamp=None, length=None,
|
||||
md5=None, sha1=None, sha256=None, mime_type=None,
|
||||
change=None, ts_datetime=None, datetime=None, path=None,
|
||||
lastmod=None, capability=None,
|
||||
ts_at=None, md_at=None,
|
||||
ts_completed=None, md_completed=None,
|
||||
ts_from=None, md_from=None,
|
||||
ts_until=None, md_until=None,
|
||||
resource=None, ln=None):
|
||||
mime_type=None, md5=None, sha1=None, sha256=None,
|
||||
change=None, ts_datetime=None, path=None, ln=None,
|
||||
# the following in _extra
|
||||
capability=None,
|
||||
ts_at=None, ts_completed=None, ts_from=None, ts_until=None,
|
||||
# and the following via setters
|
||||
lastmod=None, datetime=None,
|
||||
md_at=None, md_completed=None, md_from=None, md_until=None,
|
||||
# and finally another Resource to copy from
|
||||
resource=None):
|
||||
"""Initialize Resource object.
|
||||
|
||||
Initialize either from parameters specified or from an existing
|
||||
@ -88,14 +87,15 @@ class Resource(object):
|
||||
self.change = None
|
||||
self.ts_datetime = None # Added in ResourceSync v1.1
|
||||
self.path = None
|
||||
self._extra = None
|
||||
self.ln = None
|
||||
# Create from a Resource-like object? Copy any relevant attributes
|
||||
self._extra = None
|
||||
# Create from a Resource-like object? Copy any attributes, both ones
|
||||
# that have slots and ones that live in _extra
|
||||
if (resource is not None):
|
||||
for att in ['uri', 'timestamp', 'length', 'md5', 'sha1', 'sha256',
|
||||
'change', 'ts_datetime', 'path', 'capability',
|
||||
'ts_at', 'md_at', 'ts_completed', 'md_completed',
|
||||
'ts_from', 'md_from', 'ts_until', 'md_until', 'ln']:
|
||||
for att in ['uri', 'timestamp', 'length', 'mime_type',
|
||||
'md5', 'sha1', 'sha256', 'change', 'ts_datetime', 'path', 'ln',
|
||||
# the following in _extra
|
||||
'capability', 'ts_at', 'ts_completed', 'ts_from', 'ts_until']:
|
||||
if hasattr(resource, att):
|
||||
setattr(self, att, getattr(resource, att))
|
||||
# Any arguments will then override
|
||||
@ -146,7 +146,7 @@ class Resource(object):
|
||||
self.md_until = md_until
|
||||
# Sanity check
|
||||
if (self.uri is None):
|
||||
raise ValueError("Cannot create resource without a URI")
|
||||
raise ValueError("Cannot create Resource without a URI")
|
||||
|
||||
def __setattr__(self, prop, value):
|
||||
"""Attribute setter with check and support for extra attributes.
|
||||
@ -199,6 +199,11 @@ class Resource(object):
|
||||
"""Set timestamp from a W3C Datetime Last-Modified value."""
|
||||
self.ts_datetime = str_to_datetime(datetime, context='ts_datetime')
|
||||
|
||||
@property
|
||||
def ts_at(self):
|
||||
"""ts_at is the timestamp for md_at."""
|
||||
return self._get_extra('ts_at')
|
||||
|
||||
@property
|
||||
def md_at(self):
|
||||
"""md_at values in W3C Datetime syntax, Z notation."""
|
||||
@ -211,6 +216,11 @@ class Resource(object):
|
||||
'ts_at',
|
||||
str_to_datetime(md_at, context='md_at datetime'))
|
||||
|
||||
@property
|
||||
def ts_completed(self):
|
||||
"""ts_completed is the timestamp for md_completed."""
|
||||
return self._get_extra('ts_completed')
|
||||
|
||||
@property
|
||||
def md_completed(self):
|
||||
"""md_completed value in W3C Datetime syntax, Z notation."""
|
||||
@ -223,6 +233,11 @@ class Resource(object):
|
||||
'ts_completed',
|
||||
str_to_datetime(md_completed, context='md_completed datetime'))
|
||||
|
||||
@property
|
||||
def ts_from(self):
|
||||
"""ts_from is the timestamp for md_from."""
|
||||
return self._get_extra('ts_from')
|
||||
|
||||
@property
|
||||
def md_from(self):
|
||||
"""md_from value in W3C Datetime syntax, Z notation."""
|
||||
@ -235,6 +250,11 @@ class Resource(object):
|
||||
'ts_from',
|
||||
str_to_datetime(md_from, context='md_from datetime'))
|
||||
|
||||
@property
|
||||
def ts_until(self):
|
||||
"""ts_until is the timestamp for md_until."""
|
||||
return self._get_extra('ts_until')
|
||||
|
||||
@property
|
||||
def md_until(self):
|
||||
"""md_until value in W3C Datetime syntax, Z notation."""
|
||||
@ -277,8 +297,10 @@ class Resource(object):
|
||||
return(None)
|
||||
|
||||
@hash.setter
|
||||
def hash(self, hash):
|
||||
"""Parse space separated set of values.
|
||||
def hash(self, hashes_str):
|
||||
"""Parse space separated set of values to set md5, sha1 and sha256.
|
||||
|
||||
Any existing values for md5, sha1 and sha256 will be removed first.
|
||||
|
||||
See specification at:
|
||||
http://tools.ietf.org/html/draft-snell-atompub-link-extensions-09
|
||||
@ -287,27 +309,24 @@ class Resource(object):
|
||||
self.md5 = None
|
||||
self.sha1 = None
|
||||
self.sha256 = None
|
||||
if (hash is None):
|
||||
return
|
||||
hash_seen = set()
|
||||
errors = []
|
||||
for entry in hash.split():
|
||||
for entry in hashes_str.split():
|
||||
(hash_type, value) = entry.split(':', 1)
|
||||
if (hash_type in hash_seen):
|
||||
errors.append("Ignored duplicate hash type %s" % (hash_type))
|
||||
if hash_type in hash_seen:
|
||||
errors.append("duplicate hash type %s" % (hash_type))
|
||||
else:
|
||||
hash_seen.add(hash_type)
|
||||
if (hash_type == 'md5'):
|
||||
if hash_type == 'md5':
|
||||
self.md5 = value
|
||||
elif (hash_type == 'sha-1'):
|
||||
elif hash_type == 'sha-1':
|
||||
self.sha1 = value
|
||||
elif (hash_type == 'sha-256'):
|
||||
elif hash_type == 'sha-256':
|
||||
self.sha256 = value
|
||||
else:
|
||||
errors.append("Ignored unsupported hash type (%s)" %
|
||||
(hash_type))
|
||||
if (len(errors) > 0):
|
||||
raise ValueError(". ".join(errors))
|
||||
errors.append("unsupported hash type %s" % (hash_type))
|
||||
if len(errors) > 0:
|
||||
raise ValueError("Ignored " + ", ".join(errors))
|
||||
|
||||
def link(self, rel):
|
||||
"""Look for link with specified rel, return else None.
|
||||
@ -316,19 +335,16 @@ class Resource(object):
|
||||
specified rel value. If there are multiple links with the
|
||||
same rel then just the first will be returned
|
||||
"""
|
||||
if (self.ln is None):
|
||||
return(None)
|
||||
for link in self.ln:
|
||||
if ('rel' in link and link['rel'] == rel):
|
||||
return(link)
|
||||
return(None)
|
||||
if self.ln is not None:
|
||||
for link in self.ln:
|
||||
if 'rel' in link and link['rel'] == rel:
|
||||
return link
|
||||
return None
|
||||
|
||||
def link_href(self, rel):
|
||||
"""Look for link with specified rel, return href from it or None."""
|
||||
link = self.link(rel)
|
||||
if (link is not None):
|
||||
link = link['href']
|
||||
return(link)
|
||||
return None if link is None else link['href']
|
||||
|
||||
def link_set(self, rel, href, allow_duplicates=False, **atts):
|
||||
"""Set/create link with specified rel, set href and any other attributes.
|
||||
@ -342,13 +358,13 @@ class Resource(object):
|
||||
Be aware that adding links to a Resource object will
|
||||
significantly increase the size of the object.
|
||||
"""
|
||||
if (self.ln is None):
|
||||
if self.ln is None:
|
||||
# automagically create a self.ln list
|
||||
self.ln = []
|
||||
link = None
|
||||
else:
|
||||
link = self.link(rel)
|
||||
if (link is not None and not allow_duplicates):
|
||||
if link is not None and not allow_duplicates:
|
||||
# overwrite current value
|
||||
link['href'] = href
|
||||
else:
|
||||
@ -398,7 +414,7 @@ class Resource(object):
|
||||
@property
|
||||
def contents(self):
|
||||
"""Get the URI of and ResourceSync rel="contents" link."""
|
||||
return(self.link_href('index'))
|
||||
return(self.link_href('contents'))
|
||||
|
||||
@contents.setter
|
||||
def contents(self, uri, type='application/xml'):
|
||||
@ -420,36 +436,43 @@ class Resource(object):
|
||||
def __eq__(self, other):
|
||||
"""Equality test for resources allowing <1s difference in timestamp.
|
||||
|
||||
See equal(...) for more details of equality test
|
||||
See equal(...) for more details of equality test.
|
||||
"""
|
||||
return(self.equal(other, delta=1.0))
|
||||
|
||||
def equal(self, other, delta=0.0):
|
||||
"""Equality or near equality test for resources.
|
||||
|
||||
Equality means:
|
||||
Equality means that any parameters that exist for both resources match,
|
||||
essentially "no proof of inequality":
|
||||
1. same uri, AND
|
||||
2. same timestamp WITHIN delta if specified for either, AND
|
||||
3. same md5 if specified for both, AND
|
||||
3. same md5|sha1|sha256 if specified for both, AND
|
||||
4. same length if specified for both
|
||||
"""
|
||||
if (other is None):
|
||||
if other is None:
|
||||
return False
|
||||
if self.uri != other.uri:
|
||||
return False
|
||||
if (self.uri != other.uri):
|
||||
return(False)
|
||||
if (self.timestamp is not None or other.timestamp is not None):
|
||||
# not equal if only one timestamp specified
|
||||
if (self.timestamp is None
|
||||
or other.timestamp is None
|
||||
or abs(self.timestamp - other.timestamp) >= delta):
|
||||
return(False)
|
||||
return False
|
||||
if ((self.md5 is not None and other.md5 is not None)
|
||||
and self.md5 != other.md5):
|
||||
return(False)
|
||||
return False
|
||||
if ((self.sha1 is not None and other.sha1 is not None)
|
||||
and self.sha1 != other.sha1):
|
||||
return False
|
||||
if ((self.sha256 is not None and other.sha256 is not None)
|
||||
and self.sha256 != other.sha256):
|
||||
return False
|
||||
if ((self.length is not None and other.length is not None)
|
||||
and self.length != other.length):
|
||||
return(False)
|
||||
return(True)
|
||||
return False
|
||||
return True
|
||||
|
||||
def __str__(self):
|
||||
"""Return a human readable string for this resource.
|
||||
@ -458,11 +481,12 @@ class Resource(object):
|
||||
designed to support logging.
|
||||
"""
|
||||
s = [str(self.uri), str(self.lastmod), str(self.length),
|
||||
str(self.md5 if self.md5 else self.sha1)]
|
||||
str(self.md5 if self.md5 else (self.sha1 if self.sha1 else self.sha256))]
|
||||
if (self.change is not None):
|
||||
s.append(str(self.change))
|
||||
ch = str(self.change)
|
||||
if self.datetime is not None:
|
||||
s.append(" @ " + str(self.datetime))
|
||||
ch += " @ " + str(self.datetime)
|
||||
s.append(ch)
|
||||
if (self.path is not None):
|
||||
s.append(str(self.path))
|
||||
return "[ " + " | ".join(s) + " ]"
|
||||
|
||||
@ -14,6 +14,22 @@ import collections.abc
|
||||
from .w3c_datetime import datetime_to_str
|
||||
|
||||
|
||||
def _str_datetime_now(x='now'):
|
||||
"""Return datetime string for use with time attributes.
|
||||
|
||||
Handling depends on input:
|
||||
'now' - returns datetime for now
|
||||
other string - no change, return same value
|
||||
otherwise - assume datetime value, generate string
|
||||
"""
|
||||
if (x == 'now' or not isinstance(x, str)):
|
||||
# Now, this is wht datetime_to_str() with no arg gives
|
||||
return datetime_to_str(x)
|
||||
else:
|
||||
# Assume valid datetime string
|
||||
return x
|
||||
|
||||
|
||||
class ResourceContainer(object):
|
||||
"""Class containing resource-like objects.
|
||||
|
||||
@ -26,6 +42,11 @@ class ResourceContainer(object):
|
||||
- uri is optional identifier of this container object
|
||||
- capability_name - name of this capability
|
||||
|
||||
The properties md_from, md_at, md_until and md_completed are datetime
|
||||
strings. The setters acceopt either a string value (not checked), the value
|
||||
'now' to generate the current datetime, or otherwise an integer timestamp
|
||||
value that is converted to a string.
|
||||
|
||||
Derived classes may add extra functionality such as len() etc..
|
||||
However, any code designed to work with any ResourceContainer
|
||||
should use only the core functionality.
|
||||
@ -45,19 +66,16 @@ class ResourceContainer(object):
|
||||
|
||||
Baseline implementation use iterator given by resources property.
|
||||
"""
|
||||
return(iter(self.resources))
|
||||
return iter(self.resources)
|
||||
|
||||
def __getitem__(self, index):
|
||||
"""Feed through for __getitem__ of resources property."""
|
||||
return(self.resources[index])
|
||||
return self.resources[index]
|
||||
|
||||
@property
|
||||
def capability(self):
|
||||
"""Get/set the <rs:md capability="" .../> attribute."""
|
||||
if ('capability' in self.md):
|
||||
return(self.md['capability'])
|
||||
else:
|
||||
return(None)
|
||||
"""Get/set the <rs:md capability="" .../> attribute, None if not set."""
|
||||
return self.md.get('capability')
|
||||
|
||||
@capability.setter
|
||||
def capability(self, capability):
|
||||
@ -65,65 +83,53 @@ class ResourceContainer(object):
|
||||
|
||||
@property
|
||||
def md_from(self):
|
||||
"""Get/set the <rs:md from="" .../> attribute."""
|
||||
if ('md_from' in self.md):
|
||||
return(self.md['md_from'])
|
||||
else:
|
||||
return(None)
|
||||
"""Get/set the <rs:md from="" .../> attribute, None if not set."""
|
||||
return self.md.get('md_from')
|
||||
|
||||
@md_from.setter
|
||||
def md_from(self, md_from):
|
||||
self.md['md_from'] = self._str_datetime_now(md_from)
|
||||
self.md['md_from'] = _str_datetime_now(md_from)
|
||||
|
||||
@property
|
||||
def md_until(self):
|
||||
"""Get/set the <rs:md until="" .../> attribute."""
|
||||
if ('md_until' in self.md):
|
||||
return(self.md['md_until'])
|
||||
else:
|
||||
return(None)
|
||||
"""Get/set the <rs:md until="" .../> attribute, None if not set."""
|
||||
return self.md.get('md_until')
|
||||
|
||||
@md_until.setter
|
||||
def md_until(self, md_until):
|
||||
self.md['md_until'] = self._str_datetime_now(md_until)
|
||||
self.md['md_until'] = _str_datetime_now(md_until)
|
||||
|
||||
@property
|
||||
def md_at(self):
|
||||
"""Get/set the <rs:md at="" attribute."""
|
||||
if ('md_at' in self.md):
|
||||
return(self.md['md_at'])
|
||||
else:
|
||||
return(None)
|
||||
return self.md.get('md_at')
|
||||
|
||||
@md_at.setter
|
||||
def md_at(self, md_at):
|
||||
self.md['md_at'] = self._str_datetime_now(md_at)
|
||||
self.md['md_at'] = _str_datetime_now(md_at)
|
||||
|
||||
@property
|
||||
def md_completed(self):
|
||||
"""Get/set the <rs:md completed="" .../> attribute."""
|
||||
if ('md_completed' in self.md):
|
||||
return(self.md['md_completed'])
|
||||
else:
|
||||
return(None)
|
||||
"""Get/set the <rs:md completed="" .../> attribute, None if not set."""
|
||||
return self.md.get('md_completed')
|
||||
|
||||
@md_completed.setter
|
||||
def md_completed(self, md_completed):
|
||||
self.md['md_completed'] = self._str_datetime_now(md_completed)
|
||||
self.md['md_completed'] = _str_datetime_now(md_completed)
|
||||
|
||||
def link(self, rel):
|
||||
"""Look for link with specified rel, return else None."""
|
||||
for link in self.ln:
|
||||
if ('rel' in link and link['rel'] == rel):
|
||||
return(link)
|
||||
return(None)
|
||||
return link
|
||||
return None
|
||||
|
||||
def link_href(self, rel):
|
||||
"""Look for link with specified rel, return href from it or None."""
|
||||
link = self.link(rel)
|
||||
if (link is not None):
|
||||
link = link['href']
|
||||
return(link)
|
||||
return link
|
||||
|
||||
def link_set(self, rel, href, **atts):
|
||||
"""Set/create link with specified rel, set href and any other attributes.
|
||||
@ -132,16 +138,16 @@ class ResourceContainer(object):
|
||||
also defines the type attributes and others are permitted also. See
|
||||
description of allowed formats in
|
||||
|
||||
http://www.openarchives.org/rs/resourcesync.html#DocumentFormats
|
||||
http://www.openarchives.org/rs/resourcesync#DocumentFormats
|
||||
"""
|
||||
link = self.link(rel)
|
||||
if (link is not None):
|
||||
# overwrite current value
|
||||
link['href'] = href
|
||||
else:
|
||||
if link is None:
|
||||
# create new link
|
||||
link = {'rel': rel, 'href': href}
|
||||
self.ln.append(link)
|
||||
else:
|
||||
# overwrite current value
|
||||
link['href'] = href
|
||||
for k in atts:
|
||||
link[k] = atts[k]
|
||||
|
||||
@ -232,29 +238,3 @@ class ResourceContainer(object):
|
||||
pruned2.append(r)
|
||||
self.resources = pruned2
|
||||
return(n)
|
||||
|
||||
def __str__(self):
|
||||
"""Return string of all resources in order given by interator."""
|
||||
s = ''
|
||||
for resource in self:
|
||||
s += str(resource) + "\n"
|
||||
return(s)
|
||||
|
||||
def _str_datetime_now(self, x=None):
|
||||
"""Return datetime string for use with time attributes.
|
||||
|
||||
Handling depends on input:
|
||||
'now' - returns datetime for now
|
||||
number - assume datetime values, generate string
|
||||
other - no change, return same value
|
||||
"""
|
||||
if (x == 'now'):
|
||||
# Now, this is wht datetime_to_str() with no arg gives
|
||||
return(datetime_to_str())
|
||||
try:
|
||||
# Test for number
|
||||
junk = x + 0.0
|
||||
return datetime_to_str(x)
|
||||
except TypeError:
|
||||
# Didn't look like a number, treat as string
|
||||
return x
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
"""ResourceSync Resource List object.
|
||||
|
||||
A Resource List is a set of resources with some metadata for
|
||||
each resource. Comparison of resource lists from a source and
|
||||
a destination allows understanding of whether the two are in
|
||||
sync or whether some resources need to be updated at the
|
||||
destination.
|
||||
each resource. Every resource must have a uri attribute.
|
||||
Comparison of resource lists from a source and a destination
|
||||
allows understanding of whether the two are in sync or whether
|
||||
some resources need to be updated at the destination.
|
||||
|
||||
There may also be metadata about the Resource List, and links
|
||||
to other ResourceSync documents. Metadata include the
|
||||
timestamp of the Resource List (md_at) and, optionally, the
|
||||
timestamp when creation of the Resource List was completed
|
||||
(md_completed).at the top level. These include a creation timestamp
|
||||
(md_completed) at the top level. These include a creation timestamp
|
||||
(from) and links to the Capability List.
|
||||
|
||||
Described in specification at:
|
||||
@ -18,6 +18,7 @@ http://www.openarchives.org/rs/resourcesync#DescResources
|
||||
"""
|
||||
|
||||
import collections.abc
|
||||
from collections import OrderedDict
|
||||
import os
|
||||
from datetime import datetime
|
||||
import re
|
||||
@ -29,29 +30,62 @@ from .mapper import Mapper, MapperError
|
||||
from .url_authority import UrlAuthority
|
||||
|
||||
|
||||
class ResourceListDict(dict):
|
||||
class SortedIterMixin(object):
|
||||
"""Mixin to provide sorted_iter() method."""
|
||||
|
||||
def sorted_iter(self):
|
||||
"""Iterator over all the resources in this dict by sorted key order."""
|
||||
self._sorted_iter_next_list = sorted(self.keys(), reverse=True)
|
||||
return iter(self._sorted_iter_next, None)
|
||||
|
||||
def _sorted_iter_next(self):
|
||||
if (len(self._sorted_iter_next_list) > 0):
|
||||
return self[self._sorted_iter_next_list.pop()]
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
class ResourceListDict(dict, SortedIterMixin):
|
||||
"""Default implementation of class to store resources in ResourceList.
|
||||
|
||||
Key properties of this class are:
|
||||
- has add(resource) method
|
||||
- is iterable and results given in alphanumeric order by resource.uri
|
||||
- is iterable and gives resources (not keys) in alphanumeric order by
|
||||
resource.uri
|
||||
"""
|
||||
|
||||
def __iter__(self):
|
||||
"""Iterator over all the resources in this ResourceListDict."""
|
||||
self._iter_next_list = sorted(self.keys())
|
||||
self._iter_next_list.reverse()
|
||||
return(iter(self._iter_next, None))
|
||||
|
||||
def _iter_next(self):
|
||||
if (len(self._iter_next_list) > 0):
|
||||
return(self[self._iter_next_list.pop()])
|
||||
else:
|
||||
return(None)
|
||||
return self.sorted_iter()
|
||||
|
||||
def uris(self):
|
||||
"""Extract sorted list of URIs for resources in this ResourceListDict."""
|
||||
return(sorted(self.keys()))
|
||||
return sorted(self.keys())
|
||||
|
||||
def add(self, resource, replace=False):
|
||||
"""Add just a single resource."""
|
||||
uri = resource.uri
|
||||
if (uri in self.keys() and not replace):
|
||||
raise ResourceListDupeError(
|
||||
"Attempt to add resource already in resource_list")
|
||||
self[uri] = resource
|
||||
|
||||
|
||||
class ResourceListOrdered(OrderedDict, SortedIterMixin):
|
||||
"""Alternative implementation of class to store resources in ResourceList.
|
||||
|
||||
Key properties of this class are:
|
||||
- has add(resource) method
|
||||
- is iterable and gives resources (not keys) in the order added
|
||||
"""
|
||||
|
||||
def __iter__(self):
|
||||
"""Iterator over the resources in the list in the order added."""
|
||||
return iter(self.values())
|
||||
|
||||
def uris(self):
|
||||
"""Extract list of all resource URIs in the order added."""
|
||||
return self.keys()
|
||||
|
||||
def add(self, resource, replace=False):
|
||||
"""Add just a single resource."""
|
||||
@ -62,42 +96,6 @@ class ResourceListDict(dict):
|
||||
self[uri] = resource
|
||||
|
||||
|
||||
class ResourceListOrdered(list):
|
||||
"""Alternative implementation of class to store resources in ResourceList.
|
||||
|
||||
FIXME - This is a rather inefficient implementation which involves
|
||||
scanning all resources to check for duplicates. Designed just to enable
|
||||
re-creation of examples in the spec. Something dictionary based would
|
||||
likely be better. Might be best to use OrderedDict but that is available
|
||||
natively only in python >= 2.7 and this library is designed for 2.6,2.7.
|
||||
|
||||
Key properties of this class are:
|
||||
- has add(resource) method
|
||||
- is iterable and results given in order added (not the usual one!)
|
||||
"""
|
||||
|
||||
def uris(self):
|
||||
"""Extract list of all resource URIs (in the order added)."""
|
||||
uris = []
|
||||
for r in self:
|
||||
uris.append(r.uri)
|
||||
return(uris)
|
||||
|
||||
def add(self, resource, replace=False):
|
||||
"""Add a single resource, check for dupes."""
|
||||
uri = resource.uri
|
||||
for r in self:
|
||||
if (uri == r.uri):
|
||||
if (replace):
|
||||
r = resource
|
||||
return
|
||||
else:
|
||||
raise ResourceListDupeError(
|
||||
"Attempt to add resource already in resource_list")
|
||||
# didn't find it in list, add to end
|
||||
self.append(resource)
|
||||
|
||||
|
||||
class ResourceListDupeError(Exception):
|
||||
"""Exception in case of duplicate resource."""
|
||||
|
||||
@ -123,11 +121,13 @@ class ResourceList(ListBaseWithIndex):
|
||||
|
||||
The default storage is unordered but the iterator imposes a canonical
|
||||
order which is alphabetical by URI. If it is desired to have
|
||||
resources listed in the order they are added then the ResourceDictOrdered
|
||||
resources listed in the order they are added then the ResourceListOrdered
|
||||
class may be specified on creation:
|
||||
|
||||
rl = ResourceList( resources_class=ResourceDictOrdered )
|
||||
|
||||
Use of ResourceListOrdered will be faster than the default.
|
||||
|
||||
In normal use it is expected that any Resource List Index will be
|
||||
created automatically when writing out a large Resource List in
|
||||
multiple sitemap files. However, should it be necessary to
|
||||
@ -176,11 +176,11 @@ class ResourceList(ListBaseWithIndex):
|
||||
written to work for any objects in self and sc, provided that the
|
||||
== operator can be used to compare them.
|
||||
|
||||
The functioning of this method depends on the iterators for self and
|
||||
src providing access to the resource objects in URI order.
|
||||
The functioning of this method depends on the sorted_iter() iterators
|
||||
for self and src providing access to the resource objects in URI order.
|
||||
"""
|
||||
dst_iter = iter(self.resources)
|
||||
src_iter = iter(src.resources)
|
||||
dst_iter = self.resources.sorted_iter()
|
||||
src_iter = src.resources.sorted_iter()
|
||||
same = ResourceList()
|
||||
updated = ResourceList()
|
||||
deleted = ResourceList()
|
||||
@ -189,21 +189,19 @@ class ResourceList(ListBaseWithIndex):
|
||||
src_cur = next(src_iter, None)
|
||||
while ((dst_cur is not None) and (src_cur is not None)):
|
||||
# print 'dst='+dst_cur+' src='+src_cur
|
||||
if (dst_cur.uri == src_cur.uri):
|
||||
if dst_cur.uri == src_cur.uri:
|
||||
if (dst_cur == src_cur):
|
||||
same.add(dst_cur)
|
||||
else:
|
||||
updated.add(src_cur)
|
||||
dst_cur = next(dst_iter, None)
|
||||
src_cur = next(src_iter, None)
|
||||
elif (not src_cur or dst_cur.uri < src_cur.uri):
|
||||
elif dst_cur.uri < src_cur.uri:
|
||||
deleted.add(dst_cur)
|
||||
dst_cur = next(dst_iter, None)
|
||||
elif (not dst_cur or dst_cur.uri > src_cur.uri):
|
||||
else: # dst_cur.uri > src_cur.uri:
|
||||
created.add(src_cur)
|
||||
src_cur = next(src_iter, None)
|
||||
else:
|
||||
raise Exception("this should not be possible")
|
||||
# what do we have leftover in src or dst lists?
|
||||
while (dst_cur is not None):
|
||||
deleted.add(dst_cur)
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
import os
|
||||
import os.path
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
from defusedxml.ElementTree import parse
|
||||
@ -24,8 +23,10 @@ class ResourceListBuilder():
|
||||
- set_hashes to indicate which hashes should be calculated for each resource
|
||||
- set_path set true to add path attribute for each resource
|
||||
- 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'))
|
||||
- exclude_patterns is a list of files and directory names patterns to exclude
|
||||
using re.match(..). These patterns are left anchored so thus need to be
|
||||
preceded with .* if there may be arbitrary leading characters (defaults to
|
||||
empty)
|
||||
"""
|
||||
|
||||
def __init__(self, mapper=None, set_hashes=None,
|
||||
@ -37,9 +38,10 @@ class ResourceListBuilder():
|
||||
|
||||
The following attributes may be set to determine information added to
|
||||
each Resource object based on the disk scan:
|
||||
- set_hashes - Set of digests to computer for each resource. This may add
|
||||
- set_hashes - Set of digests to compute for each resource. This may add
|
||||
significant time to the scan process as each file has to be read to
|
||||
compute the hash. Empty set or None means no hashes calculated
|
||||
compute the hash. Empty set or None means no hashes calculated, names
|
||||
defined in Hashes object
|
||||
- set_length - False to not add length for each resources
|
||||
- set_path - True to add local path information for each file/resource
|
||||
"""
|
||||
@ -47,34 +49,38 @@ class ResourceListBuilder():
|
||||
self.set_path = set_path
|
||||
self.set_hashes = set_hashes if (set_hashes and len(set_hashes) > 0) else None
|
||||
self.set_length = set_length
|
||||
self.exclude_files = [r'sitemap\d{0,5}.xml']
|
||||
self.exclude_dirs = ['CVS', '.git']
|
||||
self.exclude_patterns = []
|
||||
self.include_symlinks = False
|
||||
self.log_count_increment = 50000 # Write log message after 50000 files
|
||||
# Used internally only:
|
||||
self.logger = logging.getLogger('resync.resource_list_builder')
|
||||
self.compiled_exclude_files = []
|
||||
self.compiled_exclude_patterns = []
|
||||
|
||||
def add_exclude_files(self, exclude_patterns):
|
||||
"""Add more patterns of files to exclude while building resource_list."""
|
||||
def add_exclude_patterns(self, exclude_patterns):
|
||||
"""Add more patterns of files or directories to exclude while building resource_list.
|
||||
|
||||
After patterns are added the set of compiled patterns is updated.
|
||||
"""
|
||||
for pattern in exclude_patterns:
|
||||
self.exclude_files.append(pattern)
|
||||
self.exclude_patterns.append(pattern)
|
||||
self._compile_excludes()
|
||||
|
||||
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:
|
||||
def _compile_excludes(self):
|
||||
# Compile a set of regexps for files and directories to be exlcuded from scans
|
||||
self.compiled_exclude_patterns = []
|
||||
for pattern in self.exclude_patterns:
|
||||
try:
|
||||
self.compiled_exclude_files.append(re.compile(pattern))
|
||||
self.compiled_exclude_patterns.append(re.compile(pattern))
|
||||
except re.error as e:
|
||||
raise ValueError(
|
||||
"Bad python regex in exclude '%s': %s" % (pattern, str(e)))
|
||||
"Bad python regex in exclude pattern '%s': %s" % (pattern, str(e)))
|
||||
|
||||
def exclude_file(self, file):
|
||||
"""True if file should be exclude based on name pattern."""
|
||||
for pattern in self.compiled_exclude_files:
|
||||
if (pattern.match(file)):
|
||||
return(True)
|
||||
return(False)
|
||||
def _exclude(self, file):
|
||||
# True if file should be exclude based on name pattern.
|
||||
for pattern in self.compiled_exclude_patterns:
|
||||
if pattern.match(file):
|
||||
return True
|
||||
return False
|
||||
|
||||
def from_disk(self, resource_list=None, paths=None):
|
||||
"""Create or extend resource_list with resources from disk scan.
|
||||
@ -105,8 +111,6 @@ class ResourceListBuilder():
|
||||
# Either use resource_list passed in or make a new one
|
||||
if (resource_list is None):
|
||||
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 = []
|
||||
@ -131,23 +135,24 @@ class ResourceListBuilder():
|
||||
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 (sys.version_info < (3, 0)):
|
||||
path = path.decode('utf-8')
|
||||
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):
|
||||
if (num_files % self.log_count_increment == 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)
|
||||
# prune list of dirs based on self.exclude_dirs
|
||||
prune = []
|
||||
for dir in dirs:
|
||||
if self._exclude(dir):
|
||||
self.logger.debug("Excluding dir '%s'" % (dir))
|
||||
prune.append(dir)
|
||||
for dir in prune:
|
||||
dirs.remove(dir)
|
||||
else:
|
||||
# single file
|
||||
self.add_file(resource_list=resource_list, file=path)
|
||||
@ -157,37 +162,27 @@ class ResourceListBuilder():
|
||||
|
||||
Follows object settings of set_path, set_hashes and set_length.
|
||||
"""
|
||||
if self._exclude(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 os.path.islink(file) and not self.include_symlinks:
|
||||
self.logger.warning("Ignoring symlink '%s'" % (file))
|
||||
return
|
||||
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")
|
||||
uri = self.mapper.dst_to_src(file) # might throw MapperError
|
||||
file_stat = os.stat(file)
|
||||
except OSError as e:
|
||||
sys.stderr.write("Ignoring file %s (error: %s)" % (file, str(e)))
|
||||
self.logger.warning("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
|
||||
if self.set_path: # add full local path
|
||||
r.path = file
|
||||
if (self.set_hashes):
|
||||
hasher = Hashes(self.set_hashes, file)
|
||||
if ('md5' in self.set_hashes):
|
||||
r.md5 = hasher.md5
|
||||
if ('sha-1' in self.set_hashes):
|
||||
r.sha1 = hasher.sha1
|
||||
if ('sha-256' in self.set_hashes):
|
||||
r.sha256 = hasher.sha256
|
||||
if (self.set_length):
|
||||
# add length
|
||||
if self.set_hashes: # add any hashes requested
|
||||
Hashes(self.set_hashes, file).set(r)
|
||||
if self.set_length: # add length
|
||||
r.length = file_stat.st_size
|
||||
resource_list.add(r)
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from defusedxml.ElementTree import parse
|
||||
from xml.etree.ElementTree import ElementTree, Element, tostring
|
||||
@ -35,7 +34,7 @@ class SitemapIndexError(Exception):
|
||||
self.message = message
|
||||
self.etree = etree
|
||||
|
||||
def __repr__(self):
|
||||
def __str__(self):
|
||||
"""Return just the message attribute."""
|
||||
return(self.message)
|
||||
|
||||
@ -93,6 +92,13 @@ class Sitemap(object):
|
||||
self.md_att_keys = ['md_at', 'capability', 'change', 'datetime',
|
||||
'md_completed', 'md_from', 'hash', 'length',
|
||||
'path', 'mime_type', 'md_until']
|
||||
# capabilities
|
||||
self.capabilities = ['resourcelist', 'changelist', 'resourcedump',
|
||||
'changedump', 'resourcedump-manifest',
|
||||
'changedump-manifest', 'capabilitylist',
|
||||
'description',
|
||||
'resourcelist-archive', 'resourcedump-archive',
|
||||
'changelist-archive', 'changedump-archive']
|
||||
if self.spec_1_0:
|
||||
self.md_att_keys.remove('datetime')
|
||||
|
||||
@ -198,17 +204,13 @@ class Sitemap(object):
|
||||
self.resources_created = 0
|
||||
seen_top_level_md = False
|
||||
for e in list(etree.getroot()):
|
||||
# look for <rs:md> and <rs:ln>, first <url> ends
|
||||
# then look for resources in <url> blocks
|
||||
# look for <rs:md> and <rs:ln>, first <url>/<sitemap> ends
|
||||
# then look for resources in <url>/<sitemap> blocks.
|
||||
# ignore any elements we don't recognize
|
||||
if (e.tag == resource_tag):
|
||||
in_preamble = False # any later rs:md or rs:ln is error
|
||||
r = self.resource_from_etree(e, self.resource_class)
|
||||
try:
|
||||
resources.add(r)
|
||||
except SitemapDupeError:
|
||||
self.logger.warning(
|
||||
"dupe of: %s (lastmod=%s)" %
|
||||
(r.uri, r.lastmod))
|
||||
resources.add(r)
|
||||
self.resources_created += 1
|
||||
elif (e.tag == "{" + RS_NS + "}md"):
|
||||
if (in_preamble):
|
||||
@ -227,9 +229,6 @@ class Sitemap(object):
|
||||
else:
|
||||
raise SitemapParseError(
|
||||
"Found <rs:ln> after first <url> in sitemap")
|
||||
else:
|
||||
# element we don't recognize, ignore
|
||||
pass
|
||||
# check that we read to right capability document
|
||||
if (capability is not None):
|
||||
if ('capability' not in resources.md):
|
||||
@ -288,19 +287,11 @@ class Sitemap(object):
|
||||
"""Return string for the resource as part of an XML sitemap.
|
||||
|
||||
Returns a string with the XML snippet representing the resource,
|
||||
without any XML declaration.
|
||||
without any XML declaration. (So much simpler now only Python 3.x
|
||||
supported, see earlier versions for 2.6, 2.7 etc.)
|
||||
"""
|
||||
e = self.resource_etree_element(resource)
|
||||
if (sys.version_info >= (3, 0)):
|
||||
# python3.x
|
||||
return(tostring(e, encoding='unicode', method='xml'))
|
||||
elif (sys.version_info >= (2, 7)):
|
||||
s = tostring(e, encoding='UTF-8', method='xml')
|
||||
else:
|
||||
# must not specify method='xml' in python2.6
|
||||
s = tostring(e, encoding='UTF-8')
|
||||
# Chop off XML declaration that is added in 2.x... sigh
|
||||
return(s.replace("<?xml version='1.0' encoding='UTF-8'?>\n", ''))
|
||||
return(tostring(e, encoding='unicode', method='xml'))
|
||||
|
||||
def resource_from_etree(self, etree, resource_class):
|
||||
"""Construct a Resource from an etree.
|
||||
@ -382,27 +373,20 @@ class Sitemap(object):
|
||||
val = md_element.attrib.get(xml_att, None)
|
||||
if (val is not None):
|
||||
md[att] = val
|
||||
# capability. Allow this to be missing but do a very simple syntax
|
||||
# check on plausible values if present
|
||||
if ('capability' in md):
|
||||
if (re.match(r"^[\w\-]+$", md['capability']) is None):
|
||||
raise SitemapParseError(
|
||||
"Bad capability name '%s' in %s" %
|
||||
(capability, context))
|
||||
# change should be one of defined values
|
||||
if ('change' in md):
|
||||
if (md['change'] not in ['created', 'updated', 'deleted']):
|
||||
self.logger.warning(
|
||||
"Bad change attribute in <rs:md> for %s" %
|
||||
(context))
|
||||
# length should be an integer
|
||||
# capability. Allow this to be missing or a new value but warn if it
|
||||
# isn't recognized
|
||||
if ('capability' in md and md['capability'] not in self.capabilities):
|
||||
self.logger.warning("Unknown capability name '%s' in %s" % (md['capability'], context))
|
||||
# change must be one of defined values
|
||||
if ('change' in md and md['change'] not in ['created', 'updated', 'deleted']):
|
||||
raise SitemapParseError("Bad change attribute in <rs:md> for %s" % (context))
|
||||
# length must be an integer
|
||||
if ('length' in md):
|
||||
try:
|
||||
md['length'] = int(md['length'])
|
||||
except ValueError as e:
|
||||
raise SitemapParseError(
|
||||
"Invalid length element in <rs:md> for %s" %
|
||||
(context))
|
||||
raise SitemapParseError("Invalid length element in <rs:md> for %s" %
|
||||
(context))
|
||||
return(md)
|
||||
|
||||
def ln_from_etree(self, ln_element, context=''):
|
||||
@ -423,9 +407,8 @@ class Sitemap(object):
|
||||
# now do some checks and conversions...
|
||||
# href (MANDATORY)
|
||||
if ('href' not in ln):
|
||||
raise SitemapParseError(
|
||||
"Missing href in <rs:ln> in %s" %
|
||||
(context))
|
||||
raise SitemapParseError("Missing href in <rs:ln> in %s" %
|
||||
(context))
|
||||
# rel (MANDATORY)
|
||||
if ('rel' not in ln):
|
||||
raise SitemapParseError("Missing rel in <rs:ln> in %s" % (context))
|
||||
@ -434,9 +417,8 @@ class Sitemap(object):
|
||||
try:
|
||||
ln['length'] = int(ln['length'])
|
||||
except ValueError as e:
|
||||
raise SitemapParseError(
|
||||
"Invalid length attribute value in <rs:ln> for %s" %
|
||||
(context))
|
||||
raise SitemapParseError("Invalid length attribute value in <rs:ln> for %s" %
|
||||
(context))
|
||||
# pri - priority, must be a number between 1 and 999999
|
||||
if ('pri' in ln):
|
||||
try:
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
"""Determine URI authority based on DNS and paths."""
|
||||
|
||||
try: # python3
|
||||
from urllib.parse import urlparse
|
||||
except ImportError: # pragma: no cover python2
|
||||
from urlparse import urlparse # pragma: no cover
|
||||
from urllib.parse import urlparse
|
||||
import os.path
|
||||
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ from . import __version__
|
||||
|
||||
|
||||
# Global configuration settings
|
||||
FIRST_REQUEST = False
|
||||
NUM_REQUESTS = 0
|
||||
CONFIG = {
|
||||
'bearer_token': None,
|
||||
'delay': None
|
||||
@ -21,21 +21,29 @@ def set_url_or_file_open_config(key, value):
|
||||
CONFIG[key] = value
|
||||
|
||||
|
||||
def url_or_file_open(uri):
|
||||
"""Wrapper around urlopen() to prepend file: if no scheme provided."""
|
||||
def url_or_file_open(uri, method=None, timeout=None):
|
||||
"""Wrapper around urlopen() to prepend file: if no scheme provided.
|
||||
|
||||
Can be used as a context manager because the return value from urlopen(...)
|
||||
supports both that and straightforwrd use as simple file handle object.
|
||||
|
||||
If timeout is exceeded then urlopen(..) will raise a socket.timeout exception. If
|
||||
no timeout is specified then the global default will be used.
|
||||
"""
|
||||
if (not re.match(r'''\w+:''', uri)):
|
||||
uri = 'file:' + uri
|
||||
headers = {'User-Agent': 'resync/' + __version__}
|
||||
# Do we need to send an Authorization header?
|
||||
# FIXME - This token will be added blindy to all requests. This is insecure
|
||||
# if the --noauth setting is used allowing requests across different domains.
|
||||
# It would be better to have some scheme where a token is tied to a particular
|
||||
# domain, or domain pattern.
|
||||
headers = {'User-Agent': 'resync/' + __version__}
|
||||
if CONFIG['bearer_token'] is not None:
|
||||
headers['Authorization'] = 'Bearer ' + CONFIG['bearer_token']
|
||||
# Have we got a delay set? Apply only to web requests after first
|
||||
global FIRST_REQUEST
|
||||
if not FIRST_REQUEST and CONFIG['delay'] is not None and not uri.startswith('file:'):
|
||||
FIRST_REQUEST = False
|
||||
global NUM_REQUESTS
|
||||
if NUM_REQUESTS != 0 and CONFIG['delay'] is not None and not uri.startswith('file:'):
|
||||
time.sleep(CONFIG['delay'])
|
||||
return(urlopen(Request(url=uri, headers=headers)))
|
||||
NUM_REQUESTS += 1
|
||||
maybe_timeout = {} if timeout is None else {'timeout': timeout}
|
||||
return urlopen(Request(url=uri, headers=headers, method=method), **maybe_timeout)
|
||||
|
||||
1
setup.py
1
setup.py
@ -61,7 +61,6 @@ setup(
|
||||
long_description=open('README.md').read(),
|
||||
long_description_content_type='text/markdown',
|
||||
install_requires=[
|
||||
"requests",
|
||||
"python-dateutil>=1.5",
|
||||
"defusedxml>=0.4.1"
|
||||
],
|
||||
|
||||
@ -8,7 +8,7 @@ import sys
|
||||
from resync.client import Client
|
||||
from resync.client_utils import ClientFatalError
|
||||
from resync.capability_list import CapabilityList
|
||||
from resync.explorer import Explorer, XResource, HeadResponse, ExplorerQuit
|
||||
from resync.explorer import Explorer, XResource, ExplorerQuit
|
||||
from resync.resource import Resource
|
||||
|
||||
|
||||
@ -30,11 +30,6 @@ class TestExplorer(unittest.TestCase):
|
||||
self.assertEqual(x.acceptable_capabilities, [1, 2])
|
||||
self.assertEqual(x.checks, [3, 4])
|
||||
|
||||
def test02_head_response(self):
|
||||
hr = HeadResponse()
|
||||
self.assertEqual(hr.status_code, None)
|
||||
self.assertEqual(len(hr.headers), 0)
|
||||
|
||||
def test03_explorer_quit(self):
|
||||
eq = ExplorerQuit()
|
||||
self.assertTrue(isinstance(eq, Exception))
|
||||
@ -105,13 +100,14 @@ class TestExplorer(unittest.TestCase):
|
||||
|
||||
def test08_head_on_file(self):
|
||||
e = Explorer()
|
||||
r1 = e.head_on_file('tests/testdata/does_not_exist')
|
||||
self.assertEqual(r1.status_code, '404')
|
||||
r2 = e.head_on_file('tests/testdata/dir1/file_a')
|
||||
self.assertEqual(r2.status_code, '200')
|
||||
(status_code, headers) = e.head_on_file('tests/testdata/does_not_exist')
|
||||
self.assertEqual(status_code, '404')
|
||||
self.assertEqual(headers, {})
|
||||
(status_code, headers) = e.head_on_file('tests/testdata/dir1/file_a')
|
||||
self.assertEqual(status_code, '200')
|
||||
self.assertTrue(re.match(r'^\d\d\d\d\-\d\d\-\d\d',
|
||||
r2.headers['last-modified']))
|
||||
self.assertEqual(r2.headers['content-length'], 20)
|
||||
headers['last-modified']))
|
||||
self.assertEqual(headers['content-length'], 20)
|
||||
|
||||
def test09_allowed_entries(self):
|
||||
e = Explorer()
|
||||
|
||||
@ -33,9 +33,8 @@ class TestMapper(unittest.TestCase):
|
||||
self.assertEqual(m.src_to_dst('http://e.org/p/'), '/tmp/q/')
|
||||
self.assertEqual(m.src_to_dst('http://e.org/p/aa'), '/tmp/q/aa')
|
||||
self.assertEqual(m.src_to_dst('http://e.org/p/aa/bb'), '/tmp/q/aa/bb')
|
||||
self.assertEqual(m.src_to_dst(
|
||||
'http://e.org/p/aa/bb/'), '/tmp/q/aa/bb/')
|
||||
self.assertRaises(MapperError, m.src_to_dst, 'http://e.org/p')
|
||||
self.assertEqual(m.src_to_dst('http://e.org/p/aa/bb/'), '/tmp/q/aa/bb/')
|
||||
self.assertEqual(m.src_to_dst('http://e.org/p'), '/tmp/q/')
|
||||
self.assertRaises(MapperError, m.src_to_dst, 'http://e.org/pa')
|
||||
self.assertRaises(MapperError, m.src_to_dst, 'nomatch')
|
||||
|
||||
@ -44,7 +43,7 @@ class TestMapper(unittest.TestCase):
|
||||
self.assertEqual(m.dst_to_src('/tmp/q/'), 'http://e.org/p/')
|
||||
self.assertEqual(m.dst_to_src('/tmp/q/bb'), 'http://e.org/p/bb')
|
||||
self.assertEqual(m.dst_to_src('/tmp/q/bb/cc'), 'http://e.org/p/bb/cc')
|
||||
self.assertRaises(MapperError, m.dst_to_src, '/tmp/q')
|
||||
self.assertEqual(m.dst_to_src('/tmp/q'), 'http://e.org/p/')
|
||||
self.assertRaises(MapperError, m.dst_to_src, '/tmp/qa')
|
||||
self.assertRaises(MapperError, m.dst_to_src, 'nomatch')
|
||||
|
||||
@ -96,17 +95,44 @@ class TestMapper(unittest.TestCase):
|
||||
self.assertEqual(Mapper(['a=b', 'b=c']).default_src_uri(), 'a')
|
||||
self.assertRaises(MapperError, Mapper().default_src_uri)
|
||||
|
||||
# Tests for Map class
|
||||
|
||||
def test10_map_unsafe(self):
|
||||
class TestMap(unittest.TestCase):
|
||||
|
||||
def test01_map_unsafe(self):
|
||||
"""Test unsafe method."""
|
||||
self.assertFalse(Map('http://example.com/', 'path').unsafe())
|
||||
# Note the first is a URI and the second is a (silly) path in the
|
||||
# following
|
||||
self.assertFalse(
|
||||
Map('http://example.com/', 'http://example.com/').unsafe())
|
||||
self.assertFalse(Map('http://example.com/', 'http://example.com/').unsafe())
|
||||
self.assertFalse(Map('a', 'b').unsafe())
|
||||
self.assertFalse(Map('path/a', 'path/b').unsafe())
|
||||
# The following are unsafe
|
||||
self.assertTrue(Map('path', 'path').unsafe())
|
||||
self.assertTrue(Map('path/a', 'path').unsafe())
|
||||
self.assertTrue(Map('path', 'path/b').unsafe())
|
||||
|
||||
def test02_dst_to_src(self):
|
||||
"""Test dst_to_src method."""
|
||||
m = Map('uri:a/b', 'path/c')
|
||||
self.assertEqual(m.dst_to_src('path/c/file'), 'uri:a/b/file')
|
||||
self.assertEqual(m.dst_to_src('path/c/file/'), 'uri:a/b/file/')
|
||||
self.assertEqual(m.dst_to_src('path/c'), 'uri:a/b/')
|
||||
self.assertEqual(m.dst_to_src('path/c/'), 'uri:a/b/')
|
||||
self.assertEqual(m.dst_to_src('path/cliff'), None)
|
||||
# Note that trailing slashes on the map elements have no effect
|
||||
m = Map('uri:a/b/', 'path/c/')
|
||||
self.assertEqual(m.dst_to_src('path/c'), 'uri:a/b/')
|
||||
self.assertEqual(m.dst_to_src('path/c/'), 'uri:a/b/')
|
||||
self.assertEqual(m.dst_to_src('path/cliff'), None)
|
||||
# Failed maps return None
|
||||
self.assertEqual(m.dst_to_src('c/file'), None)
|
||||
self.assertEqual(m.dst_to_src(''), None)
|
||||
|
||||
def test03_src_to_dst(self):
|
||||
"""Test src_to_dst method."""
|
||||
m = Map('uri:x/', '/tmp/gg/')
|
||||
self.assertEqual(m.src_to_dst('uri:other'), None)
|
||||
self.assertEqual(m.src_to_dst('uri:x'), '/tmp/gg/')
|
||||
self.assertEqual(m.src_to_dst('uri:x/'), '/tmp/gg/')
|
||||
self.assertEqual(m.src_to_dst('uri:x/lizard'), '/tmp/gg/lizard')
|
||||
self.assertEqual(m.src_to_dst('uri:x/lizard/'), '/tmp/gg/lizard/')
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
"""Tests for resync.resource."""
|
||||
import unittest
|
||||
import re
|
||||
from resync.resource import Resource, ChangeTypeError
|
||||
@ -5,20 +6,60 @@ from resync.resource import Resource, ChangeTypeError
|
||||
|
||||
class TestResource(unittest.TestCase):
|
||||
|
||||
def test01a_same(self):
|
||||
def test01_init(self):
|
||||
"""Test __init__ method."""
|
||||
# No uri = error
|
||||
self.assertRaises(ValueError, Resource)
|
||||
self.assertRaises(ValueError, Resource, timestamp=12)
|
||||
# Create with many params
|
||||
r1 = Resource(uri='a', timestamp=0, length=123,
|
||||
md5='aaa', sha1='bbb', sha256='ccc',
|
||||
mime_type='text/plain', change='updated',
|
||||
ts_datetime=1000, path='/a/b/c', ln={'x': 'y'},
|
||||
ts_at=2000, ts_completed=3000, ts_from=4000,
|
||||
ts_until=5000)
|
||||
self.assertEqual(r1.uri, 'a')
|
||||
self.assertEqual(r1.timestamp, 0)
|
||||
self.assertEqual(r1.length, 123)
|
||||
self.assertEqual(r1.md5, 'aaa')
|
||||
self.assertEqual(r1.sha1, 'bbb')
|
||||
self.assertEqual(r1.sha256, 'ccc')
|
||||
self.assertEqual(r1.mime_type, 'text/plain')
|
||||
self.assertEqual(r1.change, 'updated')
|
||||
self.assertEqual(r1.ts_datetime, 1000)
|
||||
self.assertEqual(r1.path, '/a/b/c')
|
||||
self.assertEqual(r1.ln, {'x': 'y'})
|
||||
self.assertEqual(r1.ts_at, 2000)
|
||||
self.assertEqual(r1.ts_completed, 3000)
|
||||
self.assertEqual(r1.ts_from, 4000)
|
||||
self.assertEqual(r1.ts_until, 5000)
|
||||
# Create r2 like r1
|
||||
r2 = Resource(resource=r1)
|
||||
self.assertEqual(r2.uri, 'a')
|
||||
self.assertEqual(r2.timestamp, 0)
|
||||
self.assertEqual(r2.length, 123)
|
||||
self.assertEqual(r2.md5, 'aaa')
|
||||
self.assertEqual(r2.sha1, 'bbb')
|
||||
self.assertEqual(r2.sha256, 'ccc')
|
||||
self.assertEqual(r2.mime_type, 'text/plain')
|
||||
self.assertEqual(r2.change, 'updated')
|
||||
self.assertEqual(r2.ts_datetime, 1000)
|
||||
self.assertEqual(r2.path, '/a/b/c')
|
||||
self.assertEqual(r2.ln, {'x': 'y'})
|
||||
|
||||
def test02_equal(self):
|
||||
"""Test equal method via = operator."""
|
||||
# just uri
|
||||
r1 = Resource('a')
|
||||
r2 = Resource('a')
|
||||
self.assertEqual(r1, r1)
|
||||
self.assertEqual(r1, r2)
|
||||
|
||||
def test01b_same(self):
|
||||
# with timestamps
|
||||
r1 = Resource(uri='a', timestamp=1234.0)
|
||||
r2 = Resource(uri='a', timestamp=1234.0)
|
||||
self.assertEqual(r1, r1)
|
||||
self.assertEqual(r1, r2)
|
||||
|
||||
def test01c_same(self):
|
||||
"""Same with lastmod instead of direct timestamp"""
|
||||
# with lastmod instead of direct timestamp
|
||||
r1 = Resource('a')
|
||||
r1lm = '2012-01-01T00:00:00Z'
|
||||
r1.lastmod = r1lm
|
||||
@ -40,26 +81,45 @@ class TestResource(unittest.TestCase):
|
||||
self.assertEqual(r1.timestamp, r2.timestamp, ('%s (%f) == %s (%f)' % (
|
||||
r1lm, r1.timestamp, r2lm, r2.timestamp)))
|
||||
self.assertEqual(r1, r2)
|
||||
|
||||
def test01d_same(self):
|
||||
"""Same with slight timestamp diff"""
|
||||
# with slight timestamp diff
|
||||
r1 = Resource('a')
|
||||
r1.lastmod = '2012-01-02T01:02:03Z'
|
||||
r2 = Resource('a')
|
||||
r2.lastmod = '2012-01-02T01:02:03.99Z'
|
||||
self.assertNotEqual(r1.timestamp, r2.timestamp)
|
||||
self.assertEqual(r1, r2)
|
||||
|
||||
def test02a_diff(self):
|
||||
# now with too much time diff
|
||||
r1 = Resource('a', lastmod='2012-01-11')
|
||||
r2 = Resource('a', lastmod='2012-01-22')
|
||||
self.assertNotEqual(r1, r2)
|
||||
# different uris
|
||||
r1 = Resource('a')
|
||||
r2 = Resource('b')
|
||||
self.assertNotEqual(r1, r2)
|
||||
|
||||
def test02b_diff(self):
|
||||
r1 = Resource('a', lastmod='2012-01-11')
|
||||
r2 = Resource('a', lastmod='2012-01-22')
|
||||
# print 'r1 == r2 : '+str(r1==r2)
|
||||
# same and different lengths
|
||||
r1 = Resource('a', length=1234)
|
||||
r2 = Resource('a', length=4321)
|
||||
self.assertNotEqual(r1, r2)
|
||||
r2.length = r1.md5
|
||||
self.assertEqual(r1, r2)
|
||||
# same and different md5
|
||||
r1.md5 = "3006f84272f2653a6cf5ec3af8f0d773"
|
||||
r2.md5 = "3006f84272f2653a6cf5ec3af8f00000"
|
||||
self.assertNotEqual(r1, r2)
|
||||
r2.md5 = r1.md5
|
||||
self.assertEqual(r1, r2)
|
||||
# same and different sha1
|
||||
r1.sha1 = "3be0f3af2aa4656ce38e0cef305c6eb2af4385d4"
|
||||
r2.sha1 = "555"
|
||||
self.assertNotEqual(r1, r2)
|
||||
r2.sha1 = r1.sha1
|
||||
self.assertEqual(r1, r2)
|
||||
# same and different sha256
|
||||
r1.sha256 = "f41094ad47ef3e93ec1021bfa40f4bf0185f1bf897533638ae5358b61713f84a"
|
||||
r2.sha256 = "fab"
|
||||
self.assertNotEqual(r1, r2)
|
||||
r2.sha256 = r1.sha256
|
||||
self.assertEqual(r1, r2)
|
||||
|
||||
def test04_bad_lastmod(self):
|
||||
def setlastmod(r, v):
|
||||
@ -100,7 +160,8 @@ class TestResource(unittest.TestCase):
|
||||
r1 = Resource('def', lastmod='2012-01-01')
|
||||
self.assertEqual(repr(r1), "{'uri': 'def', 'timestamp': 1325376000}")
|
||||
|
||||
def test08_multiple_hashes(self):
|
||||
def test08_hash(self):
|
||||
"""Test hash getter and setters."""
|
||||
r1 = Resource('abcd')
|
||||
r1.md5 = "some_md5"
|
||||
r1.sha1 = "some_sha1"
|
||||
@ -108,8 +169,7 @@ class TestResource(unittest.TestCase):
|
||||
self.assertEqual(r1.md5, "some_md5")
|
||||
self.assertEqual(r1.sha1, "some_sha1")
|
||||
self.assertEqual(r1.sha256, "some_sha256")
|
||||
self.assertEqual(
|
||||
r1.hash, "md5:some_md5 sha-1:some_sha1 sha-256:some_sha256")
|
||||
self.assertEqual(r1.hash, "md5:some_md5 sha-1:some_sha1 sha-256:some_sha256")
|
||||
r2 = Resource('def')
|
||||
r2.hash = "md5:ddd"
|
||||
self.assertEqual(r2.md5, 'ddd')
|
||||
@ -121,6 +181,20 @@ class TestResource(unittest.TestCase):
|
||||
self.assertEqual(r2.md5, 'fff')
|
||||
self.assertEqual(r2.sha1, 'eee')
|
||||
self.assertEqual(r2.sha256, 'ggg')
|
||||
# bogus value will reset
|
||||
r2.hash = 11
|
||||
self.assertEqual(r2.md5, None)
|
||||
self.assertEqual(r2.sha1, None)
|
||||
self.assertEqual(r2.sha256, None)
|
||||
# string withough : will raise error
|
||||
with self.assertRaises(ValueError):
|
||||
r2.hash = "no-colon"
|
||||
# dupe
|
||||
with self.assertRaises(ValueError):
|
||||
r2.hash = "md5:aaa md5:bbb"
|
||||
# unknown
|
||||
with self.assertRaises(ValueError):
|
||||
r2.hash = "sha999:aaa"
|
||||
|
||||
def test09_changetypeerror(self):
|
||||
r1 = Resource('a')
|
||||
@ -129,9 +203,11 @@ class TestResource(unittest.TestCase):
|
||||
self.assertEqual(r1.change, 'deleted')
|
||||
self.assertRaises(ChangeTypeError, Resource, 'a', change="bad")
|
||||
# disable checking
|
||||
ct = Resource.CHANGE_TYPES
|
||||
Resource.CHANGE_TYPES = False
|
||||
r1 = Resource('a', change="bad")
|
||||
self.assertEqual(r1.change, 'bad')
|
||||
Resource.CHANGE_TYPES = ct
|
||||
|
||||
def test10_md_at_roundtrips(self):
|
||||
r = Resource('a')
|
||||
@ -182,3 +258,61 @@ class TestResource(unittest.TestCase):
|
||||
self.assertEqual(r.datetime, None)
|
||||
r = Resource(uri='dt2', datetime='2000-01-04T00:00:00Z')
|
||||
self.assertEqual(r.datetime, '2000-01-04T00:00:00Z')
|
||||
|
||||
def test15_link(self):
|
||||
"""Test link link_href and link_set methods."""
|
||||
r = Resource(uri='ln1')
|
||||
self.assertEqual(r.link('up'), None)
|
||||
self.assertEqual(r.link_href('up'), None)
|
||||
r.link_set('up', 'uri:up')
|
||||
self.assertEqual(r.link('up'), {'rel': 'up', 'href': 'uri:up'})
|
||||
self.assertEqual(r.link_href('up'), 'uri:up')
|
||||
r.link_set('down', 'uri:down')
|
||||
self.assertEqual(r.link('down'), {'rel': 'down', 'href': 'uri:down'})
|
||||
self.assertEqual(r.link_href('down'), 'uri:down')
|
||||
r.link_set('up', 'uri:up2')
|
||||
self.assertEqual(r.link('up'), {'rel': 'up', 'href': 'uri:up2'})
|
||||
r.link_add('up', 'uri:up3')
|
||||
self.assertEqual(r.link('up'), {'rel': 'up', 'href': 'uri:up2'}) # still get first
|
||||
self.assertEqual(r.ln, [{'rel': 'up', 'href': 'uri:up2'},
|
||||
{'href': 'uri:down', 'rel': 'down'},
|
||||
{'rel': 'up', 'href': 'uri:up3'}])
|
||||
|
||||
def test16_specific_links(self):
|
||||
"""Test setters/getters for specific link types."""
|
||||
r = Resource(uri='laughing')
|
||||
r.describedby = 'uri:db'
|
||||
self.assertEqual(r.describedby, 'uri:db')
|
||||
r.up = 'uri:up'
|
||||
self.assertEqual(r.up, 'uri:up')
|
||||
r.index = 'uri:index'
|
||||
self.assertEqual(r.index, 'uri:index')
|
||||
r.contents = 'uri:ct'
|
||||
self.assertEqual(r.contents, 'uri:ct')
|
||||
|
||||
def test17_basename(self):
|
||||
"""Test basename property derived from uri."""
|
||||
r = Resource(uri='http://example.org/any/complex/path/file')
|
||||
self.assertEqual(r.basename, 'file')
|
||||
r.uri = 'http://example.org/any/complex/path/'
|
||||
self.assertEqual(r.basename, '')
|
||||
r.uri = 'http://example.org'
|
||||
self.assertEqual(r.basename, '')
|
||||
|
||||
def test18_str(self):
|
||||
"""Test str method."""
|
||||
self.assertEqual(str(Resource('uri:a')),
|
||||
'[ uri:a | None | None | None ]')
|
||||
self.assertEqual(str(Resource('uri:a', timestamp=0, length=999)),
|
||||
'[ uri:a | 1970-01-01T00:00:00Z | 999 | None ]')
|
||||
self.assertEqual(str(Resource('uri:a', timestamp=0, length=999, sha256='abcdef123')),
|
||||
'[ uri:a | 1970-01-01T00:00:00Z | 999 | abcdef123 ]')
|
||||
self.assertEqual(str(Resource('uri:a', change='updated', ts_datetime=3661)),
|
||||
'[ uri:a | None | None | None | updated @ 1970-01-01T01:01:01Z ]')
|
||||
self.assertEqual(str(Resource('uri:a', path='/a/b/c')),
|
||||
'[ uri:a | None | None | None | /a/b/c ]')
|
||||
|
||||
def test19_change_type_error(self):
|
||||
"""Test error from bad change type."""
|
||||
cte = ChangeTypeError('unk')
|
||||
self.assertIn('ChangeTypeError: got unk, expected one of ', str(cte))
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
"""Tests for resync.resource_container."""
|
||||
|
||||
import unittest
|
||||
from resync.resource import Resource
|
||||
from resync.resource_container import ResourceContainer
|
||||
from resync.resource_container import ResourceContainer, _str_datetime_now
|
||||
|
||||
|
||||
class TestResourceContainer(unittest.TestCase):
|
||||
@ -8,9 +10,12 @@ class TestResourceContainer(unittest.TestCase):
|
||||
def test01_create_and_add(self):
|
||||
rc = ResourceContainer(resources=[])
|
||||
self.assertEqual(len(rc.resources), 0, "empty")
|
||||
rc.resources.append(Resource('a', timestamp=1))
|
||||
rc.resources.append(Resource('b', timestamp=2))
|
||||
rc.add(Resource('a', timestamp=1))
|
||||
rc.add(Resource('b', timestamp=2))
|
||||
self.assertEqual(len(rc.resources), 2, "two resources")
|
||||
# Add two more
|
||||
rc.add([Resource('c'), Resource('d')])
|
||||
self.assertEqual(len(rc.resources), 4, "four resources")
|
||||
|
||||
def test02_iter(self):
|
||||
rc = ResourceContainer(resources=[])
|
||||
@ -93,3 +98,37 @@ class TestResourceContainer(unittest.TestCase):
|
||||
self.assertEqual(rc.up, None)
|
||||
rc.up = "up_uri"
|
||||
self.assertEqual(rc.up, "up_uri")
|
||||
|
||||
def test09_capability(self):
|
||||
"""Test capability property."""
|
||||
rc = ResourceContainer()
|
||||
self.assertEqual(rc.capability, None)
|
||||
rc.capability = "fixit"
|
||||
self.assertEqual(rc.capability, 'fixit')
|
||||
|
||||
def test10_link_set(self):
|
||||
"""Test link_set method."""
|
||||
rc = ResourceContainer()
|
||||
self.assertEqual(rc.link('alink'), None) # non-spec rel should be supported
|
||||
rc.link_set('alink', 'uri:l', extra1='one', extra2='two')
|
||||
ln = rc.link('alink')
|
||||
self.assertEqual(rc.link('alink'), {'rel': 'alink', 'href': 'uri:l',
|
||||
'extra1': 'one', 'extra2': 'two'})
|
||||
# Can update existing link
|
||||
rc.link_set('alink', 'uri:new', extra1='1')
|
||||
self.assertEqual(rc.link('alink'), {'rel': 'alink', 'href': 'uri:new',
|
||||
'extra1': '1', 'extra2': 'two'})
|
||||
|
||||
def test11_index(self):
|
||||
"""Test index property."""
|
||||
rc = ResourceContainer()
|
||||
self.assertEqual(rc.index, None)
|
||||
rc.index = 'uri:i'
|
||||
self.assertEqual(rc.index, 'uri:i')
|
||||
|
||||
def test12_str_datetime_now(self):
|
||||
"""Test _str_datetime_now function."""
|
||||
self.assertTrue(isinstance(_str_datetime_now(), str))
|
||||
self.assertTrue(isinstance(_str_datetime_now('now'), str))
|
||||
self.assertEqual(_str_datetime_now(0), '1970-01-01T00:00:00Z')
|
||||
self.assertEqual(_str_datetime_now('2020-12-24T16:01:00Z'), '2020-12-24T16:01:00Z')
|
||||
|
||||
@ -1,15 +1,13 @@
|
||||
"""Tests for resync.resource_list."""
|
||||
|
||||
from .testlib import TestCase
|
||||
|
||||
import os.path
|
||||
try: # python2
|
||||
# Must try this first as io also exists in python2
|
||||
# but is the wrong one!
|
||||
import StringIO as io
|
||||
except ImportError: # python3
|
||||
import io
|
||||
import io
|
||||
import re
|
||||
|
||||
from resync.resource import Resource
|
||||
from resync.resource_list import ResourceList, ResourceListDupeError
|
||||
from resync.resource_list import ResourceList, ResourceListOrdered, ResourceListDupeError
|
||||
from resync.sitemap import Sitemap, SitemapParseError
|
||||
|
||||
|
||||
@ -121,7 +119,8 @@ class TestResourceList(TestCase):
|
||||
r1.md5 = "aabbcc"
|
||||
self.assertEqual(i.hashes(), set(['md5']))
|
||||
r2.sha1 = "ddeeff"
|
||||
self.assertEqual(i.hashes(), set(['md5', 'sha-1']))
|
||||
r2.sha256 = "hhiijj"
|
||||
self.assertEqual(i.hashes(), set(['md5', 'sha-1', 'sha-256']))
|
||||
|
||||
def test08_iter(self):
|
||||
i = ResourceList()
|
||||
@ -129,13 +128,26 @@ class TestResourceList(TestCase):
|
||||
i.add(Resource('b', timestamp=2))
|
||||
i.add(Resource('c', timestamp=3))
|
||||
i.add(Resource('d', timestamp=4))
|
||||
resources = []
|
||||
for r in i:
|
||||
resources.append(r)
|
||||
resources = list(i.resources)
|
||||
self.assertEqual(len(resources), 4)
|
||||
self.assertEqual(resources[0].uri, 'a')
|
||||
self.assertEqual(resources[3].uri, 'd')
|
||||
|
||||
def test09_resource_list_ordered(self):
|
||||
"""Tests for ResourceList with ResourceListOrdered."""
|
||||
i = ResourceList(resources_class=ResourceListOrdered)
|
||||
i.add(Resource('a', timestamp=1))
|
||||
i.add(Resource('d', timestamp=4))
|
||||
i.add(Resource('c', timestamp=3))
|
||||
self.assertEqual(list(i.resources.uris()), ['a', 'd', 'c'])
|
||||
self.assertRaises(ResourceListDupeError, i.add, Resource('a', timestamp=11))
|
||||
self.assertEqual(i.resources['a'].uri, 'a')
|
||||
self.assertEqual(i.resources['a'].timestamp, 1)
|
||||
# With replacement
|
||||
i.add(Resource('a', timestamp=11), replace=True)
|
||||
self.assertEqual(i.resources['a'].uri, 'a')
|
||||
self.assertEqual(i.resources['a'].timestamp, 11)
|
||||
|
||||
def test20_as_xml(self):
|
||||
rl = ResourceList()
|
||||
rl.add(Resource('a', timestamp=1))
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
import unittest
|
||||
import re
|
||||
import os
|
||||
from testfixtures import LogCapture
|
||||
import time
|
||||
|
||||
from resync.resource_list_builder import ResourceListBuilder
|
||||
from resync.resource_list import ResourceList
|
||||
from resync.resource import Resource
|
||||
from resync.mapper import Mapper
|
||||
from resync.mapper import Mapper, MapperError
|
||||
|
||||
|
||||
class TestResourceListBuilder(unittest.TestCase):
|
||||
@ -137,3 +140,84 @@ class TestResourceListBuilder(unittest.TestCase):
|
||||
self.assertFalse(u'x:/A_\u00c3_tilde.txt' in uris)
|
||||
# Snowman is single char
|
||||
self.assertFalse(u'x:snowman_\u2603.txt' in uris)
|
||||
|
||||
def test10_add_exclude_patterns(self):
|
||||
"""Test add_exclude_patterns method."""
|
||||
rlb = ResourceListBuilder()
|
||||
self.assertEqual(len(rlb.exclude_patterns), 0)
|
||||
rlb.add_exclude_patterns(['aaa', 'bbb'])
|
||||
self.assertIn('aaa', rlb.exclude_patterns)
|
||||
self.assertIn('bbb', rlb.exclude_patterns)
|
||||
|
||||
def test11_compile_excludes(self):
|
||||
"""Test _compile_excludes method."""
|
||||
rlb = ResourceListBuilder()
|
||||
self.assertEqual(len(rlb.compiled_exclude_patterns), 0)
|
||||
rlb.exclude_patterns = [r'aaa\d+', r'bbb']
|
||||
rlb._compile_excludes()
|
||||
self.assertEqual(len(rlb.compiled_exclude_patterns), 2)
|
||||
# Error case
|
||||
rlb.exclude_patterns.append('bad regex \\')
|
||||
self.assertRaises(ValueError, rlb._compile_excludes)
|
||||
|
||||
def test12_exclude(self):
|
||||
"""Test _exclude method."""
|
||||
rlb = ResourceListBuilder()
|
||||
rlb.add_exclude_patterns(['.*frog.*'])
|
||||
rlb._compile_excludes()
|
||||
self.assertTrue(rlb._exclude('a frog'))
|
||||
self.assertFalse(rlb._exclude('toad'))
|
||||
|
||||
def test13_from_disk_add_path(self):
|
||||
"""Test from_disk_add_path method."""
|
||||
# Check sanity check - must have path, resource_list and mapper
|
||||
rlb = ResourceListBuilder(mapper=Mapper())
|
||||
self.assertRaises(ValueError, rlb.from_disk_add_path, path='aaa')
|
||||
self.assertRaises(ValueError, rlb.from_disk_add_path, resource_list=ResourceList())
|
||||
rlb = ResourceListBuilder()
|
||||
self.assertRaises(ValueError, rlb.from_disk_add_path, path='aaa', resource_list=ResourceList())
|
||||
# Check log message
|
||||
rlb = ResourceListBuilder(mapper=Mapper(['http://example.org/', 'tests']))
|
||||
rlb.log_count_increment = 2
|
||||
rl = ResourceList()
|
||||
with LogCapture() as lc:
|
||||
rlb.from_disk_add_path(path='tests/testdata/dir1', resource_list=rl)
|
||||
self.assertIn('from_disk_add_path: 2 files...', lc.records[-1].msg)
|
||||
# text excluding dirs -- just one file under find2 not excluced
|
||||
rlb = ResourceListBuilder(mapper=Mapper(['http://example.org/', 'tests']))
|
||||
rl = ResourceList()
|
||||
rlb.add_exclude_patterns(['find1', 'find3'])
|
||||
rlb._compile_excludes()
|
||||
rlb.from_disk_add_path(path='tests/testdata/find', resource_list=rl)
|
||||
self.assertEqual(len(rl), 1)
|
||||
|
||||
def test14_add_file(self):
|
||||
"""Test add_file method."""
|
||||
rlb = ResourceListBuilder(mapper=Mapper(['http://example.org/', 'tests']))
|
||||
rl = ResourceList()
|
||||
rlb.add_exclude_patterns(['.*ro'])
|
||||
with LogCapture() as lc:
|
||||
# escluded
|
||||
rlb.add_file(resource_list=rl, file='frog')
|
||||
self.assertIn("Excluding file 'frog'", lc.records[-1].msg)
|
||||
# mapper error
|
||||
self.assertRaises(MapperError, rlb.add_file, resource_list=rl, file='i-dont-exist')
|
||||
# map OK but doesn't exist
|
||||
rlb.add_file(resource_list=rl, file='tests/i-dont-exist')
|
||||
self.assertIn("Ignoring file 'tests/i-dont-exist'", lc.records[-1].msg)
|
||||
# ignore symlink by default
|
||||
rlb.add_file(resource_list=rl, file='tests/testdata/symlink/dir2/a_file.txt')
|
||||
self.assertIn("Ignoring symlink 'tests/testdata/symlink/dir2/a_file.txt'", lc.records[-1].msg)
|
||||
# ...or not
|
||||
rl = ResourceList()
|
||||
rlb.include_symlinks = True
|
||||
rlb.add_file(resource_list=rl, file='tests/testdata/symlink/dir2/a_file.txt')
|
||||
self.assertEqual(len(rl), 1)
|
||||
# Check hashing
|
||||
rlb = ResourceListBuilder(mapper=Mapper(['http://example.org/', 'tests/testdata/dir1/']),
|
||||
set_hashes=['md5', 'sha-1', 'sha-256'])
|
||||
rl = ResourceList()
|
||||
rlb.add_file(resource_list=rl, file='tests/testdata/dir1/file_a')
|
||||
self.assertEqual(rl['http://example.org/file_a'].md5, '6bf26fd66601b528d2e0b47eaa87edfd')
|
||||
self.assertEqual(rl['http://example.org/file_a'].sha1, 'c60a598a5d9e489cf50533eeead6d70f15eafcf8')
|
||||
self.assertEqual(rl['http://example.org/file_a'].sha256, '1c6291bfac0322752c4632ebd69bf6d81d53985fbf5ee54de5cc1fefba6566b6')
|
||||
|
||||
@ -1,27 +1,24 @@
|
||||
"""Test for resync.sitemap."""
|
||||
|
||||
import io
|
||||
from testfixtures import LogCapture
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
try: # python2
|
||||
# Must try this first as io also exists in python2
|
||||
# but in the wrong one!
|
||||
import StringIO as io
|
||||
except ImportError: # python3
|
||||
import io
|
||||
import xml.etree.ElementTree # for xml.etree.ElementTree.ParseError
|
||||
from defusedxml.ElementTree import parse
|
||||
|
||||
from resync.resource import Resource
|
||||
from resync.resource_list import ResourceList
|
||||
from resync.sitemap import Sitemap, SitemapIndexError, SitemapParseError
|
||||
|
||||
# etree gives ParseError in 2.7,3.x; ExpatError in 2.6
|
||||
etree_error_class = None
|
||||
if (sys.version_info < (2, 7)):
|
||||
from xml.parsers.expat import ExpatError
|
||||
etree_error_class = ExpatError
|
||||
else:
|
||||
# In python3 this seems only to work with the full class name??
|
||||
# from xml.etree.ElementTree import ParseError
|
||||
import xml.etree.ElementTree
|
||||
etree_error_class = xml.etree.ElementTree.ParseError
|
||||
|
||||
class TestSitemapIndexError(unittest.TestCase):
|
||||
|
||||
def test_str(self):
|
||||
"""Test str(...) gives just message part."""
|
||||
err = SitemapIndexError("howdy", "this should be the etree")
|
||||
self.assertEqual(str(err), "howdy")
|
||||
|
||||
|
||||
class TestSitemap(unittest.TestCase):
|
||||
@ -50,7 +47,7 @@ class TestSitemap(unittest.TestCase):
|
||||
'<url><loc>aardvark</loc><lastmod>2012-01-11T04:05:06Z</lastmod><rs:md datetime="2012-01-11T04:05:06Z" /></url>')
|
||||
|
||||
def test_02_resource_str(self):
|
||||
r1 = Resource('3b', 1234.1, 9999, 'ab54de')
|
||||
r1 = Resource('3b', 1234.1, length=9999, md5='ab54de')
|
||||
self.assertEqual(Sitemap().resource_as_xml(r1),
|
||||
"<url><loc>3b</loc><lastmod>1970-01-01T00:20:34.100000Z</lastmod><rs:md hash=\"md5:ab54de\" length=\"9999\" /></url>")
|
||||
r1 = Resource('3c', datetime='2013-01-02T13:00:00Z')
|
||||
@ -115,7 +112,8 @@ class TestSitemap(unittest.TestCase):
|
||||
i = iter(m)
|
||||
self.assertEqual(Sitemap().resources_as_xml(i), "<?xml version='1.0' encoding='UTF-8'?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" xmlns:rs=\"http://www.openarchives.org/rs/terms/\"><url><loc>a</loc><lastmod>2001-01-01T00:00:00Z</lastmod><rs:md length=\"1234\" /></url><url><loc>b</loc><lastmod>2002-02-02T00:00:00Z</lastmod><rs:md length=\"56789\" /></url></urlset>")
|
||||
|
||||
def test_10_sitemap(self):
|
||||
def test_10_parse_xml(self):
|
||||
"""Test parse_xml method with string XML."""
|
||||
xml = '<?xml version=\'1.0\' encoding=\'UTF-8\'?>\n\
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:rs="http://www.openarchives.org/rs/terms/">\
|
||||
<url><loc>http://e.com/a</loc><lastmod>2012-03-14T18:37:36Z</lastmod><rs:md hash="md5:Q2hlY2sgSW50ZWdyaXR5IQ==" length=\"12\" /></url>\
|
||||
@ -130,8 +128,7 @@ class TestSitemap(unittest.TestCase):
|
||||
self.assertEqual(r.lastmod, '2012-03-14T18:37:36Z')
|
||||
self.assertEqual(r.length, 12)
|
||||
self.assertEqual(r.md5, 'Q2hlY2sgSW50ZWdyaXR5IQ==')
|
||||
|
||||
def test_11_parse_2(self):
|
||||
# ..another
|
||||
xml = '<?xml version=\'1.0\' encoding=\'UTF-8\'?>\n\
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:rs="http://www.openarchives.org/rs/terms/">\
|
||||
<url><loc>/tmp/rs_test/src/file_a</loc><lastmod>2012-03-14T18:37:36Z</lastmod><rs:md length=\"12\" /></url>\
|
||||
@ -142,6 +139,54 @@ class TestSitemap(unittest.TestCase):
|
||||
self.assertFalse(s.parsed_index, 'was a sitemap')
|
||||
self.assertEqual(s.resources_created, 2, 'got 2 resources')
|
||||
|
||||
def test_11_parse_xml_error(self):
|
||||
"""Test exceptiona from parse_xml method."""
|
||||
# bad params
|
||||
s = Sitemap()
|
||||
self.assertRaises(ValueError, s.parse_xml)
|
||||
# got a sitemap when told to expect and indexp
|
||||
xml = '<?xml version=\'1.0\' encoding=\'UTF-8\'?>\n\
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:rs="http://www.openarchives.org/rs/terms/">\
|
||||
</urlset>'
|
||||
self.assertRaises(SitemapIndexError, s.parse_xml, fh=io.StringIO(xml), sitemapindex=True)
|
||||
# dupe entries DO NOT create an error
|
||||
xml = '<?xml version=\'1.0\' encoding=\'UTF-8\'?>\n\
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:rs="http://www.openarchives.org/rs/terms/">\
|
||||
<url><loc>/mouse</loc><lastmod>2020-12-21T00:00:00Z</lastmod><rs:md length=\"12\" /></url>\
|
||||
<url><loc>/mouse</loc><lastmod>2020-12-21T00:00:00Z</lastmod><rs:md length=\"12\" /></url>\
|
||||
</urlset>'
|
||||
s = Sitemap()
|
||||
i = s.parse_xml(fh=io.StringIO(xml))
|
||||
self.assertEqual(len(i.resources), 2)
|
||||
# preamble rs:md after <url> is error
|
||||
xml = '<?xml version=\'1.0\' encoding=\'UTF-8\'?>\n\
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:rs="http://www.openarchives.org/rs/terms/">\
|
||||
<url><loc>/frog</loc><lastmod>2020-12-21T00:01:00Z</lastmod><rs:md length=\"5\" /></url>\
|
||||
<rs:md capability=\"resourcelist\"/>\
|
||||
<url><loc>/toad</loc><lastmod>2020-12-21T00:02:00Z</lastmod><rs:md length=\"8\" /></url>\
|
||||
</urlset>'
|
||||
self.assertRaises(SitemapParseError, s.parse_xml, fh=io.StringIO(xml))
|
||||
# preamble rs:ln after <url> is also error
|
||||
xml = '<?xml version=\'1.0\' encoding=\'UTF-8\'?>\n\
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:rs="http://www.openarchives.org/rs/terms/">\
|
||||
<url><loc>/wills</loc><lastmod>2020-12-21T00:01:00Z</lastmod><rs:md length=\"5\" /></url>\
|
||||
<rs:ln rel="up" href="http://example.com/resourcesync_description.xml"/>\
|
||||
</urlset>'
|
||||
s = Sitemap()
|
||||
self.assertRaises(SitemapParseError, s.parse_xml, fh=io.StringIO(xml))
|
||||
# but random unknown junk should be ignored...
|
||||
xml = '<?xml version=\'1.0\' encoding=\'UTF-8\'?>\n\
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:rs="http://www.openarchives.org/rs/terms/">\
|
||||
<junk1>beetle</junk1>\
|
||||
<rs:md capability=\"resourcelist\"/>\
|
||||
<junk2>fly</junk2>\
|
||||
<url><loc>/whale</loc><lastmod>2020-12-21T00:01:00Z</lastmod><rs:md length=\"5\" /></url>\
|
||||
<junk3>ant</junk3>\
|
||||
</urlset>'
|
||||
s = Sitemap()
|
||||
i = s.parse_xml(fh=io.StringIO(xml))
|
||||
self.assertEqual(len(i.resources), 1)
|
||||
|
||||
def test_12_parse_multi_loc(self):
|
||||
xml_start = '<?xml version=\'1.0\' encoding=\'UTF-8\'?>\n\
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:rs="http://www.openarchives.org/rs/terms/">\
|
||||
@ -192,9 +237,9 @@ class TestSitemap(unittest.TestCase):
|
||||
def test_15_parse_illformed(self):
|
||||
s = Sitemap()
|
||||
# ExpatError in python2.6, ParserError in 2.7,3.x
|
||||
self.assertRaises(etree_error_class, s.parse_xml,
|
||||
self.assertRaises(xml.etree.ElementTree.ParseError, s.parse_xml,
|
||||
io.StringIO('not xml'))
|
||||
self.assertRaises(etree_error_class, s.parse_xml,
|
||||
self.assertRaises(xml.etree.ElementTree.ParseError, s.parse_xml,
|
||||
io.StringIO('<urlset><url>something</urlset>'))
|
||||
|
||||
def test_16_parse_valid_xml_but_other(self):
|
||||
@ -326,3 +371,65 @@ class TestSitemap(unittest.TestCase):
|
||||
r2 = next(i)
|
||||
self.assertEqual(r2.uri, '/tmp/rs_test/src/file_b')
|
||||
self.assertEqual(r2.change, None)
|
||||
|
||||
def test_31_resource_from_etree(self):
|
||||
"""Test resource_from_etree method."""
|
||||
# multiple <loc>
|
||||
xml = '''<?xml version=\'1.0\' encoding=\'UTF-8\'?>
|
||||
<url xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:rs="http://www.openarchives.org/rs/terms/">\
|
||||
<loc>a_name</loc>
|
||||
<loc>another_name_oops</loc>
|
||||
</url>'''
|
||||
et = parse(io.StringIO(xml))
|
||||
self.assertRaises(SitemapParseError, Sitemap().resource_from_etree, et, Resource)
|
||||
# no <loc>
|
||||
xml = '''<?xml version=\'1.0\' encoding=\'UTF-8\'?>
|
||||
<url xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:rs="http://www.openarchives.org/rs/terms/">\
|
||||
<no_loc_element />
|
||||
</url>'''
|
||||
et = parse(io.StringIO(xml))
|
||||
self.assertRaises(SitemapParseError, Sitemap().resource_from_etree, et, Resource)
|
||||
# muktiple <rs:md> not allowed
|
||||
xml = '''<?xml version=\'1.0\' encoding=\'UTF-8\'?>
|
||||
<url xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:rs="http://www.openarchives.org/rs/terms/">\
|
||||
<loc>a_name</loc>
|
||||
<rs:md type="text/plain" />
|
||||
<rs:md something="123" />
|
||||
</url>'''
|
||||
et = parse(io.StringIO(xml))
|
||||
self.assertRaises(SitemapParseError, Sitemap().resource_from_etree, et, Resource)
|
||||
# warn is hash invalid
|
||||
with LogCapture() as lc:
|
||||
xml = '''<?xml version=\'1.0\' encoding=\'UTF-8\'?>
|
||||
<url xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:rs="http://www.openarchives.org/rs/terms/">\
|
||||
<loc>a_name</loc>
|
||||
<rs:md hash="UNKNOWN-TYPE:blah" />
|
||||
</url>'''
|
||||
et = parse(io.StringIO(xml))
|
||||
Sitemap().resource_from_etree(et, Resource)
|
||||
self.assertIn('Ignored unsupported hash type UNKNOWN-TYPE', lc.records[-1].msg)
|
||||
|
||||
def test_32_md_from_etree(self):
|
||||
"""Test md_from_etree method."""
|
||||
# Warning for unknwon capability
|
||||
with LogCapture() as lc:
|
||||
xml = '''<?xml version=\'1.0\' encoding=\'UTF-8\'?>
|
||||
<rs:md xmlns:rs="http://www.openarchives.org/rs/terms/" capability="WHY" />
|
||||
'''
|
||||
et = parse(io.StringIO(xml)).getroot()
|
||||
Sitemap().md_from_etree(et)
|
||||
self.assertIn("Unknown capability name 'WHY'", lc.records[-1].msg)
|
||||
# Bad value for change is an error
|
||||
with LogCapture() as lc:
|
||||
xml = '''<?xml version=\'1.0\' encoding=\'UTF-8\'?>
|
||||
<rs:md xmlns:rs="http://www.openarchives.org/rs/terms/" change="BAD" />
|
||||
'''
|
||||
et = parse(io.StringIO(xml)).getroot()
|
||||
self.assertRaises(SitemapParseError, Sitemap().md_from_etree, et)
|
||||
# length must be an integer
|
||||
with LogCapture() as lc:
|
||||
xml = '''<?xml version=\'1.0\' encoding=\'UTF-8\'?>
|
||||
<rs:md xmlns:rs="http://www.openarchives.org/rs/terms/" length="short" />
|
||||
'''
|
||||
et = parse(io.StringIO(xml)).getroot()
|
||||
self.assertRaises(SitemapParseError, Sitemap().md_from_etree, et)
|
||||
|
||||
37
tests/test_url_or_file_open.py
Normal file
37
tests/test_url_or_file_open.py
Normal file
@ -0,0 +1,37 @@
|
||||
"""Tests for resync.url_or_file_open."""
|
||||
from .testlib import TestCase, webserver
|
||||
import time
|
||||
|
||||
from resync.url_or_file_open import NUM_REQUESTS, CONFIG, set_url_or_file_open_config, url_or_file_open
|
||||
|
||||
|
||||
class TestUrlOrFileOpen(TestCase):
|
||||
|
||||
def test_set_url_or_file_open_config(self):
|
||||
"""Test set_url_or_file_open_config function."""
|
||||
self.assertEqual(CONFIG['bearer_token'], None)
|
||||
self.assertEqual(CONFIG['delay'], None)
|
||||
self.assertNotIn('my_thing', CONFIG)
|
||||
set_url_or_file_open_config('bearer_token', 'open seasame')
|
||||
self.assertEqual(CONFIG['bearer_token'], 'open seasame')
|
||||
set_url_or_file_open_config('my_thing', 'special')
|
||||
self.assertEqual(CONFIG['my_thing'], 'special')
|
||||
|
||||
def test_url_or_file_open(self):
|
||||
"""Test basic operation of url_or_file_open function."""
|
||||
# Open file
|
||||
fh = url_or_file_open('tests/testdata/dir1/file_a')
|
||||
self.assertIn(b'I am file a', fh.read())
|
||||
fh.close()
|
||||
with url_or_file_open('file:tests/testdata/dir1/file_b') as fh:
|
||||
self.assertIn(b'I am file b', fh.read())
|
||||
# Open URL
|
||||
with webserver('tests/testdata', 'localhost', 9999):
|
||||
with url_or_file_open('http://localhost:9999/dir2/file_x') as fh:
|
||||
self.assertIn(b'I am the mysterious file_x', fh.read())
|
||||
# test delay of 0.1s
|
||||
set_url_or_file_open_config('delay', 0.1)
|
||||
before = time.time()
|
||||
with url_or_file_open('http://localhost:9999/dir1/file_a') as fh:
|
||||
self.assertIn(b'I am file a', fh.read())
|
||||
self.assertGreater(time.time() - before, 0.099)
|
||||
1
tests/testdata/symlink/dir1/a_file.txt
vendored
Normal file
1
tests/testdata/symlink/dir1/a_file.txt
vendored
Normal file
@ -0,0 +1 @@
|
||||
I am a real file!
|
||||
1
tests/testdata/symlink/dir2/a_file.txt
vendored
Symbolic link
1
tests/testdata/symlink/dir2/a_file.txt
vendored
Symbolic link
@ -0,0 +1 @@
|
||||
../dir1/a_file.txt
|
||||
@ -36,10 +36,4 @@ class TestCase(unittest.TestCase):
|
||||
@property
|
||||
def tmpdir(self):
|
||||
"""Read-only access to _tmpdir, just in case... The rmtree scares me."""
|
||||
#
|
||||
# FIXME - Hack to work on python2.6 where setUpClass is not called, will
|
||||
# FIXME - not have proper tidy as tearDownClass will not be called.
|
||||
# FIXME - Remove when 2.6 no longer supported
|
||||
if (not self._tmpdir and sys.version_info < (2, 7)):
|
||||
self.setUpClass()
|
||||
return(self._tmpdir)
|
||||
|
||||
@ -78,6 +78,7 @@ def webserver(dir='/tmp/htdocs', host='localhost', port=9999):
|
||||
finally:
|
||||
# Close the server
|
||||
p.terminate()
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Loading…
Reference in New Issue
Block a user