diff --git a/.gitignore b/.gitignore index 71b79d3..33baecb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ +*.egg-info *.pyc -.coverage -.venv -build -dist -extras -iptools.egg-info +/.coverage +/.python-version +/.tox/ +/.venv/ +/build/ +/dist/ +/extras/ setuptools-*.egg diff --git a/.travis.yml b/.travis.yml index 79a101a..6a3e3ea 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,16 +1,21 @@ language: python python: - - "2.6" - "2.7" - - "3.2" - - "3.3" + - "3.5" + - "3.6" + - "3.7" + - "3.8" - "pypy" + - "pypy3" + install: - - pip install . --use-mirrors - - pip install -r tests/requirements.txt --use-mirrors + - pip install wheel tox-travis + - python setup.py install bdist_wheel + - pip install ./dist/iptools-*.whl script: - - flake8 - - nosetests + - tox + - tox --installpkg ./dist/iptools-*.whl + notifications: email: - travis-ci+python-iptools@bd808.com diff --git a/README.md b/README.md index 932796a..3b3b4c4 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,8 @@ Local documentation can be built using [Sphinx][]: Python Version Compatibility ---------------------------- -[Travis CI][ci-home] automatically runs tests against python 2.6, 2.7, 3.2, 3.3 and pypy. +[Travis CI][ci-home] automatically runs tests against Python 2.7, 3.5, 3.6, +3.7, 3.8, pypy, and pypy3. Installation ------------ diff --git a/iptools/__init__.py b/iptools/__init__.py index 42cc0c3..3a966f7 100644 --- a/iptools/__init__.py +++ b/iptools/__init__.py @@ -22,13 +22,6 @@ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -__version__ = '0.7.0-dev' - -__all__ = ( - 'IpRange', - 'IpRangeList', -) - # sniff for python2.x / python3k compatibility "fixes' try: @@ -51,10 +44,16 @@ def next(iterable): Sequence = object # end compatibility "fixes' - from . import ipv4 from . import ipv6 +__version__ = '0.7.0' + +__all__ = ( + 'IpRange', + 'IpRangeList', +) + def _address2long(address): """ @@ -64,7 +63,7 @@ def _address2long(address): if parsed is None: parsed = ipv6.ip2long(address) return parsed -#end _addess2long +# end _addess2long class IpRange (Sequence): @@ -164,7 +163,7 @@ def __init__(self, start, end=None): self._ipver = ipv4 if self.endIp > ipv4.MAX_IP: self._ipver = ipv6 - #end __init__ + # end __init__ def __repr__(self): """ @@ -178,7 +177,7 @@ def __repr__(self): return "IpRange(%r, %r)" % ( self._ipver.long2ip(self.startIp), self._ipver.long2ip(self.endIp)) - #end __repr__ + # end __repr__ def __str__(self): """ @@ -192,7 +191,7 @@ def __str__(self): return ( self._ipver.long2ip(self.startIp), self._ipver.long2ip(self.endIp)).__repr__() - #end __str__ + # end __str__ def __eq__(self, other): """ @@ -206,7 +205,7 @@ def __eq__(self, other): return isinstance(other, IpRange) and \ self.startIp == other.startIp and \ self.endIp == other.endIp - #end __eq__ + # end __eq__ def __len__(self): """ @@ -223,7 +222,7 @@ def __len__(self): True """ return self._len - #end __len__ + # end __len__ def __hash__(self): """ @@ -238,7 +237,7 @@ def __hash__(self): False """ return hash((self.startIp, self.endIp)) - #end __hash__ + # end __hash__ def _cast(self, item): if isinstance(item, basestring): @@ -250,12 +249,12 @@ def _cast(self, item): if ipv4 == self._ipver and item > ipv4.MAX_IP: # casting an ipv6 in an ipv4 range # downcast to ipv4 iff address is in the IPv4 mapped block - if item in IpRange(ipv6.IPV4_MAPPED): + if item in _IPV6_MAPPED_IPV4: item = item & ipv4.MAX_IP - #end if + # end if return item - #end _cast + # end _cast def index(self, item): """ @@ -282,11 +281,11 @@ def index(self, item): if offset >= 0 and offset < self._len: return offset raise ValueError('%s is not in range' % self._ipver.long2ip(item)) - #end index + # end index def count(self, item): return int(item in self) - #end count + # end count def __contains__(self, item): """ @@ -313,7 +312,7 @@ def __contains__(self, item): """ item = self._cast(item) return self.startIp <= item <= self.endIp - #end __contains__ + # end __contains__ def __getitem__(self, index): """ @@ -348,7 +347,7 @@ def __getitem__(self, index): """ if isinstance(index, slice): if index.step not in (None, 1): - #TODO: return an IpRangeList + # TODO: return an IpRangeList raise ValueError('slice step not supported') start = index.start or 0 if start < 0: @@ -371,7 +370,7 @@ def __getitem__(self, index): if index < 0 or index >= self._len: raise IndexError('index out of range') return self._ipver.long2ip(self.startIp + index) - #end __getitem__ + # end __getitem__ def __iter__(self): """ @@ -392,12 +391,15 @@ def __iter__(self): while i <= self.endIp: yield self._ipver.long2ip(i) i += 1 - #end __iter__ -#end class IpRange + # end __iter__ +# end class IpRange + + +_IPV6_MAPPED_IPV4 = IpRange(ipv6.IPV4_MAPPED) class IpRangeList (object): - """ + r""" List of IpRange objects. Converts a list of ip address and/or CIDR addresses into a list of IpRange @@ -410,7 +412,7 @@ class IpRangeList (object): """ def __init__(self, *args): self.ips = tuple(map(IpRange, args)) - #end __init__ + # end __init__ def __repr__(self): """ @@ -429,7 +431,7 @@ def __repr__(self): IpRange('192.168.0.0', '192.168.255.255'))" """ return "IpRangeList%r" % (self.ips,) - #end __repr__ + # end __repr__ def __str__(self): """ @@ -440,7 +442,7 @@ def __str__(self): ('192.168.0.0', '192.168.255.255'))" """ return "(%s)" % ", ".join(str(i) for i in self.ips) - #end __str__ + # end __str__ def __contains__(self, item): """ @@ -465,11 +467,16 @@ def __contains__(self, item): :type item: str :returns: ``True`` if address is in list, ``False`` otherwise. """ + if isinstance(item, basestring): + item = _address2long(item) + if type(item) not in (type(1), type(ipv4.MAX_IP), type(ipv6.MAX_IP)): + raise TypeError( + "expected ip address, 32-bit integer or 128-bit integer") for r in self.ips: if item in r: return True return False - #end __contains__ + # end __contains__ def __iter__(self): """ @@ -497,7 +504,7 @@ def __iter__(self): for r in self.ips: for ip in r: yield ip - #end __iter__ + # end __iter__ def __len__(self): """ @@ -516,7 +523,39 @@ def __len__(self): True """ return sum(r.__len__() for r in self.ips) - #end __len__ -#end class IpRangeList + # end __len__ + + def __hash__(self): + """ + Return correct hash for IpRangeList object + + >>> a = IpRange('127.0.0.0/8') + >>> b = IpRange('127.0.0.0', '127.255.255.255') + >>> IpRangeList(a, b).__hash__() == IpRangeList(a, b).__hash__() + True + >>> IpRangeList(a, b).__hash__() == IpRangeList(b, a).__hash__() + True + >>> c = IpRange('10.0.0.0/8') + >>> IpRangeList(a, c).__hash__() == IpRangeList(c, a).__hash__() + False + """ + return hash(self.ips) + # end __hash__ + + def __eq__(self, other): + """ + >>> a = IpRange('127.0.0.0/8') + >>> b = IpRange('127.0.0.0', '127.255.255.255') + >>> IpRangeList(a, b) == IpRangeList(a, b) + True + >>> IpRangeList(a, b) == IpRangeList(b, a) + True + >>> c = IpRange('10.0.0.0/8') + >>> IpRangeList(a, c) == IpRangeList(c, a) + False + """ + return hash(self) == hash(other) + # end __eq__ +# end class IpRangeList # vim: set sw=4 ts=4 sts=4 et : diff --git a/iptools/ipv4.py b/iptools/ipv4.py index f1160a0..3243482 100644 --- a/iptools/ipv4.py +++ b/iptools/ipv4.py @@ -23,6 +23,40 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. +import re + +# sniff for python2.x / python3k compatibility "fixes' +try: + basestring = basestring +except NameError: + # 'basestring' is undefined, must be python3k + basestring = str + +try: + bin = bin +except NameError: + # builtin bin function doesn't exist + def bin(x): + """ + From http://code.activestate.com/recipes/219300/#c7 + """ + if x < 0: + return '-' + bin(-x) + out = [] + if x == 0: + out.append('0') + while x > 0: + out.append('01'[x & 1]) + x >>= 1 + pass + try: + return '0b' + ''.join(reversed(out)) + except NameError: + out.reverse() + return '0b' + ''.join(out) + # end bin +# end compatibility "fixes' + __all__ = ( 'cidr2block', 'hex2ip', @@ -60,43 +94,6 @@ 'TEST_NET_3', ) - -import re - - -# sniff for python2.x / python3k compatibility "fixes' -try: - basestring = basestring -except NameError: - # 'basestring' is undefined, must be python3k - basestring = str - -try: - bin = bin -except NameError: - # builtin bin function doesn't exist - def bin(x): - """ - From http://code.activestate.com/recipes/219300/#c7 - """ - if x < 0: - return '-' + bin(-x) - out = [] - if x == 0: - out.append('0') - while x > 0: - out.append('01'[x & 1]) - x >>= 1 - pass - try: - return '0b' + ''.join(reversed(out)) - except NameError: - out.reverse() - return '0b' + ''.join(out) - #end bin -# end compatibility "fixes' - - #: Regex for validating an IPv4 address _DOTTED_QUAD_RE = re.compile(r'^(\d{1,3}\.){0,3}\d{1,3}$') @@ -223,7 +220,7 @@ def validate_ip(s): return False return True return False -#end validate_ip +# end validate_ip def validate_cidr(s): @@ -266,7 +263,7 @@ def validate_cidr(s): return False return True return False -#end validate_cidr +# end validate_cidr def validate_netmask(s): @@ -285,6 +282,10 @@ def validate_netmask(s): True >>> validate_netmask('128.0.0.1') False + >>> validate_netmask('1.255.255.0') + False + >>> validate_netmask('0.255.255.0') + False :param s: String to validate as a dotted-quad notation netmask. @@ -293,7 +294,8 @@ def validate_netmask(s): :raises: TypeError """ if validate_ip(s): - mask = bin(ip2network(s))[2:] + # Convert to binary string, strip '0b' prefix, 0 pad to 32 bits + mask = bin(ip2network(s))[2:].zfill(32) # all left most bits must be 1, all right most must be 0 seen0 = False for c in mask: @@ -305,7 +307,7 @@ def validate_netmask(s): return True else: return False -#end validate_netmask +# end validate_netmask def validate_subnet(s): @@ -348,7 +350,7 @@ def validate_subnet(s): else: return False raise TypeError("expected string or unicode") -#end validate_subnet +# end validate_subnet def ip2long(ip): @@ -385,7 +387,7 @@ def ip2long(ip): for q in quads: lngip = (lngip << 8) | int(q) return lngip -#end ip2long +# end ip2long def ip2network(ip): @@ -406,7 +408,7 @@ def ip2network(ip): for i in range(4): netw = (netw << 8) | int(len(quads) > i and quads[i] or 0) return netw -#end ip2network +# end ip2network def long2ip(l): @@ -448,7 +450,7 @@ def long2ip(l): "expected int between %d and %d inclusive" % (MIN_IP, MAX_IP)) return '%d.%d.%d.%d' % ( l >> 24 & 255, l >> 16 & 255, l >> 8 & 255, l & 255) -#end long2ip +# end long2ip def ip2hex(addr): @@ -478,7 +480,7 @@ def ip2hex(addr): if netip is None: return None return "%08x" % netip -#end ip2hex +# end ip2hex def hex2ip(hex_str): @@ -506,7 +508,7 @@ def hex2ip(hex_str): except ValueError: return None return long2ip(netip) -#end hex2ip +# end hex2ip def cidr2block(cidr): @@ -543,7 +545,7 @@ def cidr2block(cidr): network = ip2network(ip) return _block_from_ip_and_prefix(network, prefix) -#end cidr2block +# end cidr2block def netmask2prefix(mask): @@ -571,7 +573,7 @@ def netmask2prefix(mask): if validate_netmask(mask): return bin(ip2network(mask)).count('1') return 0 -#end netmask2prefix +# end netmask2prefix def subnet2block(subnet): @@ -609,7 +611,7 @@ def subnet2block(subnet): network = ip2network(ip) return _block_from_ip_and_prefix(network, prefix) -#end subnet2block +# end subnet2block def _block_from_ip_and_prefix(ip, prefix): @@ -630,6 +632,6 @@ def _block_from_ip_and_prefix(ip, prefix): mask = (1 << shift) - 1 block_end = block_start | mask return (long2ip(block_start), long2ip(block_end)) -#end _block_from_ip_and_prefix +# end _block_from_ip_and_prefix # vim: set sw=4 ts=4 sts=4 et : diff --git a/iptools/ipv6.py b/iptools/ipv6.py index 319c4b5..073a427 100644 --- a/iptools/ipv6.py +++ b/iptools/ipv6.py @@ -23,10 +23,15 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. +import re +from . import ipv4 + __all__ = ( 'cidr2block', 'ip2long', 'long2ip', + 'long2rfc1924', + 'rfc19242long', 'validate_cidr', 'validate_ip', 'DOCUMENTATION_NETWORK', @@ -52,13 +57,8 @@ 'UNSPECIFIED_ADDRESS', ) - -import re -from . import ipv4 - - #: Regex for validating an IPv6 in hex notation -_HEX_RE = re.compile(r'^([0-9a-f]{0,4}:){2,7}[0-9a-f]{0,4}$') +_HEX_RE = re.compile(r'^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$') #: Regex for validating an IPv6 in dotted-quad notation _DOTTED_QUAD_RE = re.compile(r'^([0-9a-f]{0,4}:){2,6}(\d{1,3}\.){0,3}\d{1,3}$') @@ -138,6 +138,21 @@ #: All DHCP servers and relay agents on the local site MULTICAST_SITE_DHCP = "ff05::1:3" +#: RFC 1924 alphabet +_RFC1924_ALPHABET = [ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', + 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', + 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', + '!', '#', '$', '%', '&', '(', ')', '*', '+', '-', ';', '<', '=', + '>', '?', '@', '^', '_', '`', '{', '|', '}', '~', +] +#: RFC 1924 reverse lookup +_RFC1924_REV = None +#: Regex for validating an IPv6 in hex notation +_RFC1924_RE = re.compile(r'^[0-9A-Za-z!#$%&()*+-;<=>?@^_`{|}~]{20}$') + def validate_ip(s): """Validate a hexidecimal IPv6 ip address. @@ -169,6 +184,8 @@ def validate_ip(s): Traceback (most recent call last): ... TypeError: expected string or buffer + >>> validate_ip('1080:0:0:0:8:800:200c:417a') + True :param s: String to validate as a hexidecimal IPv6 ip address. @@ -190,7 +207,7 @@ def validate_ip(s): return False return True return False -#end validate_ip +# end validate_ip def ip2long(ip): @@ -220,6 +237,9 @@ def ip2long(ip): True >>> ip2long('ff::ff::ff') == None True + >>> expect = 21932261930451111902915077091070067066 + >>> ip2long('1080:0:0:0:8:800:200C:417A') == expect + True :param ip: Hexidecimal IPv6 address @@ -247,7 +267,7 @@ def ip2long(ip): hextets.append('0') for h in h2: hextets.append(h) - #end if + # end if lngip = 0 for h in hextets: @@ -255,10 +275,10 @@ def ip2long(ip): h = '0' lngip = (lngip << 16) | int(h, 16) return lngip -#end ip2long +# end ip2long -def long2ip(l): +def long2ip(l, rfc1924=False): """Convert a network byte order 128-bit integer to a canonical IPv6 address. @@ -283,10 +303,16 @@ def long2ip(l): Traceback (most recent call last): ... TypeError: expected int between 0 and inclusive + >>> long2ip(ip2long('1080::8:800:200C:417A'), rfc1924=True) + '4)+k&C#VzJ4br>0wv%Yp' + >>> long2ip(ip2long('::'), rfc1924=True) + '00000000000000000000' :param l: Network byte order 128-bit integer. :type l: int + :param rfc1924: Encode in RFC 1924 notation (base 85) + :type rfc1924: bool :returns: Canonical IPv6 address (eg. '::1'). :raises: TypeError """ @@ -294,6 +320,9 @@ def long2ip(l): raise TypeError( "expected int between %d and %d inclusive" % (MIN_IP, MAX_IP)) + if rfc1924: + return long2rfc1924(l) + # format as one big hex value hex_str = '%032x' % l # split into double octet chunks without padding zeros @@ -311,7 +340,7 @@ def long2ip(l): dc_len, dc_start = (run_len, run_start) else: run_len, run_start = (0, -1) - #end for + # end for if dc_len > 1: dc_end = dc_start + dc_len if dc_end == len(hextets): @@ -319,10 +348,76 @@ def long2ip(l): hextets[dc_start:dc_end] = [''] if dc_start == 0: hextets = [''] + hextets - #end if + # end if return ':'.join(hextets) -#end long2ip +# end long2ip + + +def long2rfc1924(l): + """Convert a network byte order 128-bit integer to an rfc1924 IPv6 + address. + + + >>> long2rfc1924(ip2long('1080::8:800:200C:417A')) + '4)+k&C#VzJ4br>0wv%Yp' + >>> long2rfc1924(ip2long('::')) + '00000000000000000000' + >>> long2rfc1924(MAX_IP) + '=r54lj&NUUO~Hi%c2ym0' + + + :param l: Network byte order 128-bit integer. + :type l: int + :returns: RFC 1924 IPv6 address + :raises: TypeError + """ + if MAX_IP < l or l < MIN_IP: + raise TypeError( + "expected int between %d and %d inclusive" % (MIN_IP, MAX_IP)) + o = [] + r = l + while r > 85: + o.append(_RFC1924_ALPHABET[r % 85]) + r = r // 85 + o.append(_RFC1924_ALPHABET[r]) + return ''.join(reversed(o)).zfill(20) + + +def rfc19242long(s): + """Convert an RFC 1924 IPv6 address to a network byte order 128-bit + integer. + + + >>> expect = 0 + >>> rfc19242long('00000000000000000000') == expect + True + >>> expect = 21932261930451111902915077091070067066 + >>> rfc19242long('4)+k&C#VzJ4br>0wv%Yp') == expect + True + >>> rfc19242long('pizza') == None + True + >>> rfc19242long('~~~~~~~~~~~~~~~~~~~~') == None + True + >>> rfc19242long('=r54lj&NUUO~Hi%c2ym0') == MAX_IP + True + + + :param ip: RFC 1924 IPv6 address + :type ip: str + :returns: Network byte order 128-bit integer or ``None`` if ip is invalid. + """ + global _RFC1924_REV + if not _RFC1924_RE.match(s): + return None + if _RFC1924_REV is None: + _RFC1924_REV = {v: k for k, v in enumerate(_RFC1924_ALPHABET)} + x = 0 + for c in s: + x = x * 85 + _RFC1924_REV[c] + if x > MAX_IP: + return None + return x def validate_cidr(s): @@ -365,7 +460,7 @@ def validate_cidr(s): return False return True return False -#end validate_cidr +# end validate_cidr def cidr2block(cidr): @@ -399,6 +494,6 @@ def cidr2block(cidr): mask = (1 << shift) - 1 block_end = block_start | mask return (long2ip(block_start), long2ip(block_end)) -#end cidr2block +# end cidr2block # vim: set sw=4 ts=4 sts=4 et : diff --git a/setup.cfg b/setup.cfg index dc76948..85d3e9c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,15 +1,19 @@ [nosetests] +verbosity=2 detailed-errors=1 with-coverage=1 with-doctest=1 cover-package=iptools cover-html=1 cover-html-dir=docs/_build/cover +cover-branches=1 [flake8] -ignore=F821 count=1 show-pep8=1 show-source=1 statistics=1 -exclude=build,dist,doc,*.egg,*.egg-info +exclude=.tox,.venv,build,dist,docs,*.egg,*.egg-info + +[wheel] +universal = 1 diff --git a/setup.py b/setup.py index 996ba0c..551a951 100644 --- a/setup.py +++ b/setup.py @@ -32,11 +32,11 @@ 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', 'Programming Language :: Python', - 'Programming Language :: Python :: 2.5', - 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.2', - 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', 'Topic :: Utilities', 'Topic :: Internet', ], diff --git a/tests/iptools/iptools_test.py b/tests/iptools/iptools_test.py index ff23586..a1c8c88 100644 --- a/tests/iptools/iptools_test.py +++ b/tests/iptools/iptools_test.py @@ -35,7 +35,7 @@ def testMixedRange(self): self.assertFalse('209.19.170.129' in INTERNAL_IPS) # end testMixedRange -#end class IpRangeListTests +# end class IpRangeListTests class IpRangeTests(unittest.TestCase): @@ -44,7 +44,7 @@ def testIPv6Range(self): fixture = iptools.IpRange('::ffff:0:0/96') self.assertTrue('::ffff:172.16.11.12' in fixture) self.assertFalse('209.19.170.129' in fixture) - #end testIPv6Range + # end testIPv6Range def testV4MappedAddressInIPv6Range(self): """ @@ -60,7 +60,7 @@ def testV4MappedAddressInIPv6Range(self): self.assertTrue('::ffff:192.168.0.12' in fixture) self.assertFalse('::ffff:192.168.1.12' in fixture) - #end test6to4AddressInIPv6Range -#end class IpRangeTests + # end test6to4AddressInIPv6Range +# end class IpRangeTests # vim:se sw=4 ts=4 sts=4 et: diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..446681b --- /dev/null +++ b/tox.ini @@ -0,0 +1,8 @@ +[tox] +envlist = py27, py35, py36, py37, py38, pypy, pypy3 + +[testenv] +deps = -r{toxinidir}/tests/requirements.txt +commands = + flake8 + nosetests