Merge pull request #174 from developmentseed/develop

version 0.13.0
This commit is contained in:
Scisco
2016-03-25 18:21:13 -04:00
29 changed files with 217 additions and 161 deletions

View File

@@ -1,27 +1,47 @@
sudo: required
language: python
sudo: false
services:
- docker
python:
- '2.7'
- '3.5'
cache:
directories:
- ~/.cache/pip
addons:
apt:
packages:
- libgdal1h
- gdal-bin
- libproj-dev
- libhdf5-serial-dev
- libpng-dev
- libgdal-dev
- libatlas-dev
- libatlas-base-dev
- gfortran
env:
global:
- secure: QsF7ignSAbH/WCyO6v9bw1exmCWDQR0DqmHkwJ5swc9N44OOOzbWGsaMSYB5y9h+d70fz4arbxQDhsk2KvX4Zd1/2YIMOrIsbgDYeegpkhVPgyQNPKmVqiX+Tb47t1C/TgkC7A07tiPpuefYcLNMZ8gzz7oKhh1UKapYftqzZ+g=
- secure: HxjeKWSROBQYy9NuNkgQeaK1ubTF8vH5FcR8nUTSAYxxw/qOzKpqkiq4BcJSRcIwTbkvaBf4MshLGVOxPjMeyJFe06UD/6LvTUGS3bwdya+m0RFjHe5/3wzS8/MxLbTlvgzmuGLLKOsJjXCi9eQQchKfHv+QuhGxhYVLQpnbU9E=
- secure: Zq0Z2UA2A7/ieXX8XoMweClJTp8hiVBxoQ1ylJYNd7qsRSk0QvZhn62db5/x48L9S1kELk0sG64q5Pf96/RPLpdjkBUAdEkS7qF+QOvRvAv2woNEHutjlMUvP6jwYGbug+AORg76btZ57OwMOi3aTkagQMMKnokfo7KGbffy0Jo=
- PIP_WHEEL_DIR=$HOME/.cache/pip/wheels
- PIP_FIND_LINKS=file://$HOME/.cache/pip/wheels
before_install:
- pip install -U pip
- pip install wheel
install:
- pip install --user twine
- pip install -r requirements-dev.txt
- pip install -e .
before_script:
- docker build --file="travis-dockerfile" -t "developmentseed/landsat-util:travis" .
script:
- docker run --rm -it -v "$(pwd)":/test -w /test developmentseed/landsat-util:travis nosetests
after_success:
- docker login -e ${DOCKER_EMAIL} -u ${DOCKER_USER} -p ${DOCKER_PASSWORD}
- docker push developmentseed/landsat-util:travis
- python setup.py test
deploy:
provider: pypi
@@ -32,11 +52,3 @@ deploy:
repo: developmentseed/landsat-util
branch:
- master
after_deploy:
if [ "$TRAVIS_BRANCH" == "master" ]; then
echo "Start Docker Hub Push"
VER=$(docker run --rm -it -v "$(pwd)":/test -w /test developmentseed/landsat-util:travis python landsat/landsat.py --version | sed s/[^0-9\.]//g)
docker build . -t developmentseed/landsat-util:$VER
docker push developmentseed/landsat-util:$VER
fi

View File

@@ -4,6 +4,7 @@ Authors
Scisco https://github.com/scisco
Marc Farra https://github.com/kamicut
Drew Bollinger https://github.com/drewbo
Sean Gillies https://github.com/sgillies
Alex Kappel https://twitter.com/alex_kappel
See also https://github.com/developmentseed/landsat-util/graphs/contributors.

View File

@@ -1,7 +1,11 @@
Changes
=======
0.12.2 (2016-02-18)
0.13.0 (2016-03-25)
------------------
- Python 3.5 support
0.12.2 (2016-03-24)
------------------
- Fix for #167
- Fix for #145

View File

@@ -1,7 +1,15 @@
FROM ubuntu:14.04
RUN apt-get -y update
RUN apt-get install --yes git-core python-pip python-skimage python-numpy python-scipy libgdal-dev libatlas-base-dev gfortran libfreetype6-dev libglib2.0-dev zlib1g-dev python-pycurl
ADD landsat /usr/local/lib/python2.7/dist-packages/landsat
ADD bin/landsat /usr/local/bin/
RUN apt-get install --yes git-core python-pip python-scipy libgdal-dev libatlas-base-dev gfortran libfreetype6-dev libglib2.0-dev zlib1g-dev python-pycurl
ADD . /landsat
RUN cd /landsat && pip install -r requirements/docker.txt
RUN pip install setuptools
RUN pip install -U pip
RUN pip install wheel
RUN pip install https://s3-us-west-2.amazonaws.com/ds-satellite-projects/landsat-util/numpy-1.10.4-cp27-cp27mu-linux_x86_64.whl
RUN pip install https://s3-us-west-2.amazonaws.com/ds-satellite-projects/landsat-util/Pillow-3.1.1-cp27-cp27mu-linux_x86_64.whl
RUN pip install https://s3-us-west-2.amazonaws.com/ds-satellite-projects/landsat-util/scikit_image-0.12.3-cp27-cp27mu-manylinux1_x86_64.whl
RUN cd /landsat && pip install -r requirements-dev.txt
RUN sed -i 's/numpy.*//g' /landsat/requirements.txt
RUN sed -i 's/scipy.*//g' /landsat/requirements.txt
RUN sed -i 's/scikit-image.*//g' /landsat/requirements.txt
RUN cd /landsat && pip install -e .

View File

@@ -1,6 +1,8 @@
include AUTHORS.txt
include LICENSE
include README.md
include requirements.txt
include requirements-dev.txt
recursive-include tests *
recursive-exclude * __pycache__

View File

@@ -1,7 +1,7 @@
Landsat-util
===============
.. image:: https://travis-ci.org/developmentseed/landsat-util.svg?branch=v0.5
.. image:: https://travis-ci.org/developmentseed/landsat-util.svg?branch=master
:target: https://travis-ci.org/developmentseed/landsat-util
.. image:: https://badge.fury.io/py/landsat-util.svg
@@ -29,16 +29,6 @@ To run the documentation locally::
$ cd docs
$ make html
Travis Tests
++++++++++++
To speed up testing on travis, we use a docker image.
To test with docker image locally run:
.. code::
$ docker run --rm -it -v "$(pwd)":/test developmentseed/landsat-util:travis nosetests
Recently Added Features
+++++++++++++++++++++++

View File

@@ -63,9 +63,6 @@ Running Tests
::
$: pip install -U requirements/dev.txt
$: nosetests
Or::
$: pip install -r requirements-dev.txt
$: python setup.py test

View File

@@ -1 +1 @@
__version__ = '0.12.2'
__version__ = '0.13.0'

View File

@@ -1,4 +1,5 @@
import warnings
import rasterio

View File

@@ -1,5 +1,8 @@
# Landsat Util
# License: CC0 1.0 Universal
from __future__ import print_function, division, absolute_import
from xml.etree import ElementTree
from os.path import join, exists, getsize
@@ -7,9 +10,9 @@ import requests
from usgs import api, USGSError
from homura import download as fetch
from utils import check_create_folder, url_builder
from mixins import VerbosityMixin
import settings
from .utils import check_create_folder, url_builder
from .mixins import VerbosityMixin
from . import settings
class RemoteFileDoesntExist(Exception):

View File

@@ -2,6 +2,8 @@
# Landsat Util
# License: CC0 1.0 Universal
from __future__ import print_function, division, absolute_import
import os
import tarfile
import glob
@@ -20,9 +22,9 @@ from skimage.util import img_as_ubyte
from skimage.exposure import rescale_intensity
from polyline.codec import PolylineCodec
from mixins import VerbosityMixin
from utils import get_file, check_create_folder, exit, adjust_bounding_box
from decorators import rasterio_decorator
from .mixins import VerbosityMixin
from .utils import get_file, check_create_folder, exit, adjust_bounding_box
from .decorators import rasterio_decorator
class FileDoesNotExist(Exception):

View File

@@ -3,11 +3,17 @@
# Landsat Util
# License: CC0 1.0 Universal
from __future__ import print_function, division, absolute_import
import argparse
import textwrap
import json
from os.path import join
from urllib2 import URLError
try:
from urllib.request import URLError
except ImportError:
from urllib2 import URLError
from datetime import datetime
from dateutil.relativedelta import relativedelta
@@ -15,15 +21,15 @@ from dateutil.parser import parse
import pycurl
from boto.exception import NoAuthHandlerFound
from downloader import Downloader, IncorrectSceneId, RemoteFileDoesntExist, USGSInventoryAccessMissing
from search import Search
from uploader import Uploader
from utils import reformat_date, convert_to_integer_list, timer, exit, get_file, convert_to_float_list
from mixins import VerbosityMixin
from image import Simple, PanSharpen, FileDoesNotExist
from ndvi import NDVIWithManualColorMap, NDVI
from __init__ import __version__
import settings
from .downloader import Downloader, IncorrectSceneId, RemoteFileDoesntExist, USGSInventoryAccessMissing
from .search import Search
from .uploader import Uploader
from .utils import reformat_date, convert_to_integer_list, timer, exit, get_file, convert_to_float_list
from .mixins import VerbosityMixin
from .image import Simple, PanSharpen, FileDoesNotExist
from .ndvi import NDVIWithManualColorMap, NDVI
from .__init__ import __version__
from . import settings
DESCRIPTION = """Landsat-util is a command line utility that makes it easy to
@@ -465,10 +471,10 @@ def process_image(path, bands=None, verbose=False, pansharpen=False, ndvi=False,
p = Simple(path, bands=bands, dst_path=settings.PROCESSED_IMAGE, verbose=verbose, force_unzip=force_unzip,
bounds=bounds)
except IOError as e:
exit(e.message, 1)
except FileDoesNotExist as e:
exit(e.message, 1)
except IOError as err:
exit(str(err), 1)
except FileDoesNotExist as err:
exit(str(err), 1)
return p.run()

View File

@@ -2,6 +2,8 @@
# Landsat Util
# License: CC0 1.0 Universal
from __future__ import print_function, division, absolute_import
import sys
import subprocess
from termcolor import colored
@@ -108,6 +110,6 @@ class VerbosityMixin(object):
if indent:
msg = (' ' * indent) + msg
sys.stdout.write(msg + '\n')
print(msg)
return msg

View File

@@ -1,11 +1,13 @@
from __future__ import print_function, division, absolute_import
from os.path import join
import rasterio
import numpy
import settings
from decorators import rasterio_decorator
from image import BaseProcess
from . import settings
from .decorators import rasterio_decorator
from .image import BaseProcess
class NDVI(BaseProcess):
@@ -45,7 +47,7 @@ class NDVI(BaseProcess):
except IOError:
pass
self.cmap = {k: v[:4] for k, v in colormap.iteritems()}
self.cmap = {k: v[:4] for k, v in colormap.items()}
@rasterio_decorator
def run(self):

View File

@@ -1,12 +1,14 @@
# Landsat Util
# License: CC0 1.0 Universal
from __future__ import print_function, division, absolute_import
import json
import time
import requests
import settings
from utils import three_digit, create_paired_list, geocode
from . import settings
from .utils import three_digit, create_paired_list, geocode
class Search(object):

View File

@@ -4,23 +4,32 @@
# The S3 uploader is a fork of pys3upload (https://github.com/leetreveil/pys3upload)
from __future__ import print_function, division
from __future__ import print_function, division, absolute_import
import os
import sys
import time
import threading
import contextlib
import Queue
from multiprocessing import pool
try:
import cStringIO
StringIO = cStringIO
import queue
except:
import Queue as queue
from multiprocessing import pool
try:
from io import BytesIO as StringIO
except ImportError:
import StringIO
try:
from cStringIO import StringIO
except:
from StringIO import StringIO
from boto.s3.connection import S3Connection
from mixins import VerbosityMixin
from .mixins import VerbosityMixin
STREAM = sys.stderr
@@ -96,7 +105,7 @@ class Uploader(VerbosityMixin):
self.output('Uploading to S3', normal=True, arrow=True)
upload(bucket_name, self.key, self.secret,
data_collector(f.readlines()), filename, cb,
data_collector(iter(f)), filename, cb,
threads=10, replace=True, secure=True, connection=self.conn)
print('\n')
@@ -114,7 +123,7 @@ def data_collector(iterable, def_buf_size=5242880):
:returns:
A generator object
"""
buf = ''
buf = b''
for data in iterable:
buf += data
if len(buf) >= def_buf_size:
@@ -130,11 +139,11 @@ def upload_part(upload_func, progress_cb, part_no, part_data):
def _upload_part(retries_left=num_retries):
try:
with contextlib.closing(StringIO.StringIO(part_data)) as f:
with contextlib.closing(StringIO(part_data)) as f:
f.seek(0)
cb = lambda c, t: progress_cb(part_no, c, t) if progress_cb else None
upload_func(f, part_no, cb=cb, num_cb=100)
except Exception, exc:
except Exception as exc:
retries_left -= 1
if retries_left > 0:
return _upload_part(retries_left=retries_left)
@@ -208,7 +217,7 @@ def upload(bucket, aws_access_key, aws_secret_key,
raise Exception('s3 key ' + key + ' already exists')
multipart_obj = b.initiate_multipart_upload(key)
err_queue = Queue.Queue()
err_queue = queue.Queue()
lock = threading.Lock()
upload.counter = 0
@@ -218,7 +227,7 @@ def upload(bucket, aws_access_key, aws_secret_key,
def check_errors():
try:
exc = err_queue.get(block=False)
except Queue.Empty:
except queue.Empty:
pass
else:
raise exc

View File

@@ -1,15 +1,23 @@
# Landsat Util
# License: CC0 1.0 Universal
from __future__ import print_function, division, absolute_import
import os
import sys
import time
import re
from cStringIO import StringIO
try:
from io import StringIO
except ImportError:
from cStringIO import StringIO
from datetime import datetime
import geocoder
from mixins import VerbosityMixin
from .mixins import VerbosityMixin
class Capturing(list):
@@ -43,7 +51,7 @@ class timer(object):
def __exit__(self, type, value, traceback):
self.end = time.time()
print 'Time spent : {0:.2f} seconds'.format((self.end - self.start))
print('Time spent : {0:.2f} seconds'.format((self.end - self.start)))
def exit(message, code=0):

View File

@@ -1,7 +1,7 @@
-r base.txt
pdoc>=0.3.1
nose>=1.3.7
coverage>=4.0
Sphinx>=1.3.1
wheel>=0.26.0
mock>=1.3.0
jsonschema==2.5.1

View File

@@ -1,2 +1,14 @@
-r requirements/base.txt
usgs==0.1.9
requests==2.7.0
python-dateutil==2.5.1
numpy==1.10.4
termcolor==1.1.0
rasterio==0.32.0
six==1.8.0
scipy==0.17.0
scikit-image==0.12.3
homura==0.1.3
boto==2.39.0
polyline==1.1
geocoder==1.9.0
matplotlib==1.5.1

View File

@@ -1,5 +0,0 @@
-r docker.txt
numpy>=1.9.3
scipy>=0.16.0
scikit-image>=0.11.3

View File

@@ -1,11 +0,0 @@
requests==2.7.0
python-dateutil>=2.4.2
termcolor>=1.1.0
rasterio>=0.27.0
six==1.8.0
homura>=0.1.2
boto>=2.38.0
polyline==1.1
geocoder>=1.5.1
jsonschema==2.5.1
usgs==0.1.9

View File

@@ -15,10 +15,11 @@ def readme():
with open('README.rst') as f:
return f.read()
test_requirements = [
'nose>=1.3.7',
'mock>=1.3.0'
]
with open('requirements.txt') as fid:
INSTALL_REQUIRES = [l.strip() for l in fid.readlines() if l]
with open('requirements-dev.txt') as fid:
TEST_REQUIRES = [l.strip() for l in fid.readlines() if l]
setup(
name='landsat-util',
@@ -34,22 +35,7 @@ setup(
include_package_data=True,
license='CCO',
platforms='Posix; MacOS X; Windows',
install_requires=[
'usgs==0.1.9',
'requests==2.7.0',
'python-dateutil>=2.4.2',
'numpy>=1.9.3',
'termcolor>=1.1.0',
'rasterio>=0.26.0',
'six==1.8.0',
'scipy>=0.16.0',
'scikit-image>=0.11.3',
'homura>=0.1.2',
'boto>=2.38.0',
'polyline==1.1',
'geocoder>=1.5.1',
'matplotlib==1.5.1'
],
install_requires=INSTALL_REQUIRES,
test_suite='nose.collector',
tests_require=test_requirements
tests_require=TEST_REQUIRES
)

View File

@@ -8,6 +8,7 @@ import errno
import shutil
import unittest
from tempfile import mkdtemp
import rasterio
from rasterio.warp import transform_bounds
@@ -62,7 +63,8 @@ class TestProcess(unittest.TestCase):
bounds=bounds)
path = p.run()
self.assertTrue(exists(path))
self.assertEqual(map('{0:.2f}'.format, get_bounds(path)), map('{0:.2f}'.format, bounds))
for val, exp in zip(get_bounds(path), bounds):
self.assertAlmostEqual(val, exp, 2)
def test_simple_with_intersecting_bounds_clip(self):
@@ -72,7 +74,8 @@ class TestProcess(unittest.TestCase):
bounds=bounds)
path = p.run()
self.assertTrue(exists(path))
self.assertEqual(map('{0:.2f}'.format, get_bounds(path)), map('{0:.2f}'.format, expected_bounds))
for val, exp in zip(get_bounds(path), expected_bounds):
self.assertAlmostEqual(val, exp, 2)
def test_simple_with_out_of_bounds_clip(self):
@@ -82,7 +85,8 @@ class TestProcess(unittest.TestCase):
bounds=bounds)
path = p.run()
self.assertTrue(exists(path))
self.assertEqual(map('{0:.2f}'.format, get_bounds(path)), map('{0:.2f}'.format, expected_bounds))
for val, exp in zip(get_bounds(path), expected_bounds):
self.assertAlmostEqual(val, exp, 2)
def test_simple_with_zip_file(self):
@@ -104,7 +108,8 @@ class TestProcess(unittest.TestCase):
bounds=bounds)
path = p.run()
self.assertTrue(exists(path))
self.assertEqual(map('{0:.2f}'.format, get_bounds(path)), map('{0:.2f}'.format, bounds))
for val, exp in zip(get_bounds(path), bounds):
self.assertAlmostEqual(val, exp, 2)
def test_ndvi(self):
@@ -118,7 +123,8 @@ class TestProcess(unittest.TestCase):
bounds=bounds)
path = p.run()
self.assertTrue(exists(path))
self.assertEqual(map('{0:.2f}'.format, get_bounds(path)), map('{0:.2f}'.format, bounds))
for val, exp in zip(get_bounds(path), bounds):
self.assertAlmostEqual(val, exp, 2)
def test_ndvi_with_manual_colormap(self):

View File

@@ -8,10 +8,10 @@ import unittest
import subprocess
import errno
import shutil
import mock
from os.path import join
from jsonschema import validate
from jsonschema import validate
import mock
import landsat.landsat as landsat
from tests import geojson_schema

View File

@@ -3,9 +3,16 @@
"""Tests for mixins"""
from __future__ import absolute_import
import sys
import unittest
from cStringIO import StringIO
try:
from io import StringIO
except:
from cStringIO import StringIO
from contextlib import contextmanager
from landsat.mixins import VerbosityMixin
@@ -30,45 +37,45 @@ class TestMixins(unittest.TestCase):
def test_output(self):
# just a value
with capture(self.v.output, 'this is a test') as output:
with capture(self.v.output, u'this is a test') as output:
self.assertEquals("", output)
# value as normal
with capture(self.v.output, 'this is a test', normal=True) as output:
with capture(self.v.output, u'this is a test', normal=True) as output:
self.assertEquals("this is a test\n", output)
# value as normal with color
with capture(self.v.output, 'this is a test', normal=True, color='blue') as output:
with capture(self.v.output, u'this is a test', normal=True, color='blue') as output:
self.assertEquals("\x1b[34mthis is a test\x1b[0m\n", output)
# value as error
with capture(self.v.output, 'this is a test', normal=True, error=True) as output:
with capture(self.v.output, u'this is a test', normal=True, error=True) as output:
self.assertEquals("\x1b[31mthis is a test\x1b[0m\n", output)
# value with arrow
with capture(self.v.output, 'this is a test', normal=True, arrow=True) as output:
with capture(self.v.output, u'this is a test', normal=True, arrow=True) as output:
self.assertEquals("\x1b[34m===> \x1b[0mthis is a test\n", output)
# value with indent
with capture(self.v.output, 'this is a test', normal=True, indent=1) as output:
with capture(self.v.output, u'this is a test', normal=True, indent=1) as output:
self.assertEquals(" this is a test\n", output)
def test_exit(self):
with self.assertRaises(SystemExit):
with capture(self.v.exit, 'exit test') as output:
with capture(self.v.exit, u'exit test') as output:
self.assertEquals('exit test', output)
def test_print(self):
# message in blue with arrow
with capture(self.v._print, msg='this is a test', color='blue', arrow=True) as output:
with capture(self.v._print, msg=u'this is a test', color='blue', arrow=True) as output:
self.assertEquals("\x1b[34m===> \x1b[0m\x1b[34mthis is a test\x1b[0m\n", output)
# just a message
with capture(self.v._print, msg='this is a test') as output:
with capture(self.v._print, msg=u'this is a test') as output:
self.assertEquals("this is a test\n", output)
# message with color and indent
with capture(self.v._print, msg='this is a test', color='blue', indent=1) as output:
with capture(self.v._print, msg=u'this is a test', color='blue', indent=1) as output:
self.assertEquals(" \x1b[34mthis is a test\x1b[0m\n", output)

View File

@@ -4,6 +4,7 @@
"""Tests for search"""
import unittest
from jsonschema import validate
from landsat.search import Search

View File

@@ -34,11 +34,11 @@ class TestUploader(unittest.TestCase):
class upload_tests(unittest.TestCase):
def test_should_be_able_to_upload_data(self):
input = ['12', '345']
input = [b'12', b'345']
state['mock_boto_s3_multipart_upload_data'] = []
conn = S3Connection('some_key', 'some_secret', True)
upload('test_bucket', 'some_key', 'some_secret', input, 'some_key', connection=conn)
self.assertEqual(state['mock_boto_s3_multipart_upload_data'], ['12', '345'])
self.assertEqual(state['mock_boto_s3_multipart_upload_data'], [b'12', b'345'])
class upload_part_tests(unittest.TestCase):
@@ -57,36 +57,36 @@ class upload_part_tests(unittest.TestCase):
counter[0] += 1
raise Exception()
upload_part(upload_func, '_', '_', '_')
upload_part(upload_func, b'_', b'_', b'_')
self.assertEqual(counter[0], 5)
class doc_collector_tests(unittest.TestCase):
def test_should_be_able_to_read_every_byte_of_data(self):
input = ['12345']
input = [b'12345']
result = list(data_collector(input, def_buf_size=3))
self.assertEqual(result, ['123', '45'])
self.assertEqual(result, [b'123', b'45'])
def test_should_be_able_to_read_single_yield(self):
input = ['123']
input = [b'123']
result = list(data_collector(input, def_buf_size=3))
self.assertEqual(result, ['123'])
self.assertEqual(result, [b'123'])
def test_should_be_able_to_yield_data_less_than_buffer_size(self):
input = ['123']
input = [b'123']
result = list(data_collector(input, def_buf_size=6))
self.assertEqual(result, ['123'])
self.assertEqual(result, [b'123'])
def test_a_single_item_should_still_be_buffered_even_if_it_is_above_the_buffer_size(self):
input = ['123456']
input = [b'123456']
result = list(data_collector(input, def_buf_size=3))
self.assertEqual(result, ['123', '456'])
self.assertEqual(result, [b'123', b'456'])
def test_should_return_rest_of_data_on_last_iteration(self):
input = ['1234', '56']
input = [b'1234', b'56']
result = list(data_collector(input, def_buf_size=3))
self.assertEqual(result, ['123', '456'])
self.assertEqual(result, [b'123', b'456'])
if __name__ == '__main__':
unittest.main()

17
tox.ini Normal file
View File

@@ -0,0 +1,17 @@
[tox]
envlist = py27,py34,py35
[testenv]
deps =
wheel
cython>=0.21
pip>=8.1.1
jsonschema
mock>=1.3.0
nose>=1.3.7
pytest
commands =
pip install -r requirements.txt
pip install -e .
python setup.py test
install_command=pip install --process-dependency-links --allow-external --allow-unverified {opts} {packages}

View File

@@ -1,6 +0,0 @@
FROM developmentseed/landsat-util:dev
ADD . /test
RUN apt-get -y update
RUN apt-get install --yes git-core
RUN cd /test && pip install -r requirements/docker.txt
RUN pip install pdoc>=0.3.1 nose>=1.3.7 coverage>=4.0 Sphinx>=1.3.1 wheel>=0.26.0 mock>=1.3.0