Repository: openaddresses/machine
Pull request #530 (2017-01-22 06:49:48)

Add secret MBTiles rendering to rendered output
Generates MBTiles with Tippecanoe alongside preview renders, but does nothing with them except upload to S3. Second step of #466.


Patch:
diff --git a/openaddr/ci/objects.py b/openaddr/ci/objects.py
index bb11b8c..15ce8e2 100644
--- a/openaddr/ci/objects.py
+++ b/openaddr/ci/objects.py
@@ -89,8 +89,8 @@ class RunState:
         'address count', 'version', 'fingerprint', 'cache time', 'processed',
         'output', 'process time', 'website', 'skipped', 'license',
         'share-alike', 'attribution required', 'attribution name',
-        'attribution flag', 'process hash', 'preview', 'source problem',
-        'code version')}
+        'attribution flag', 'process hash', 'preview', 'slippymap',
+        'source problem', 'code version')}
 
     def __init__(self, json_blob):
         blob_dict = dict(json_blob or {})
@@ -107,6 +107,7 @@ class RunState:
         self.processed = blob_dict.get('processed')
         self.output = blob_dict.get('output')
         self.preview = blob_dict.get('preview')
+        self.slippymap = blob_dict.get('slippymap')
         self.process_time = blob_dict.get('process time')
         self.process_hash = blob_dict.get('process hash')
         self.website = blob_dict.get('website')
diff --git a/openaddr/ci/work.py b/openaddr/ci/work.py
index f17f15f..b1ddc2f 100644
--- a/openaddr/ci/work.py
+++ b/openaddr/ci/work.py
@@ -72,6 +72,13 @@ def assemble_output(s3, input, source_name, run_id, index_dirname):
         url, _ = upload_file(s3, key_name, preview_path)
         output['preview'] = url
     
+    if input['slippymap']:
+        # e.g. /runs/0/slippymap.mbtiles
+        slippymap_path = os.path.join(index_dirname, input['slippymap'])
+        key_name = '/runs/{run}/{slippymap}'.format(run=run_id, **input)
+        url, _ = upload_file(s3, key_name, slippymap_path)
+        output['slippymap'] = url
+    
     return output
 
 def do_work(s3, run_id, source_name, job_contents_b64, render_preview, output_dir, mapzen_key=None):
diff --git a/openaddr/process_one.py b/openaddr/process_one.py
index 5220dcd..24a6649 100644
--- a/openaddr/process_one.py
+++ b/openaddr/process_one.py
@@ -11,7 +11,7 @@ from os import mkdir, rmdir, close, chmod
 from _thread import get_ident
 import tempfile, json, csv, sys
 
-from . import cache, conform, preview, CacheResult, ConformResult, __version__
+from . import cache, conform, preview, slippymap, CacheResult, ConformResult, __version__
 from .compat import csvopen, csvwriter, PY2
 from .cache import DownloadError
 
@@ -46,7 +46,7 @@ def process(source, destination, do_preview, mapzen_key=None, extras=dict()):
     logging.getLogger('openaddr').addHandler(log_handler)
     
     cache_result, conform_result = CacheResult.empty(), ConformResult.empty()
-    preview_path, skipped_source = None, False
+    preview_path, slippymap_path, skipped_source = None, None, False
 
     try:
         with open(temp_src) as file:
@@ -78,6 +78,9 @@ def process(source, destination, do_preview, mapzen_key=None, extras=dict()):
                 
                 if do_preview and mapzen_key:
                     preview_path = render_preview(conform_result.path, temp_dir, mapzen_key)
+                
+                if do_preview:
+                    slippymap_path = render_slippymap(conform_result.path, temp_dir)
 
                 if not preview_path:
                     _L.warning('Nothing previewed')
@@ -97,7 +100,7 @@ def process(source, destination, do_preview, mapzen_key=None, extras=dict()):
 
     # Write output
     state_path = write_state(source, skipped_source, destination, log_handler,
-                             cache_result, conform_result, preview_path, temp_dir)
+        cache_result, conform_result, preview_path, slippymap_path, temp_dir)
 
     log_handler.close()
     rmtree(temp_dir)
@@ -111,6 +114,18 @@ def render_preview(csv_filename, temp_dir, mapzen_key):
 
     return png_filename
 
+def render_slippymap(csv_filename, temp_dir):
+    '''
+    '''
+    try:
+        mbtiles_filename = join(temp_dir, 'slippymap.mbtiles')
+        slippymap.generate(csv_filename, mbtiles_filename)
+    except Exception as e:
+        _L.error('%s in render_slippymap: %s', type(e), e)
+        return None
+    else:
+        return mbtiles_filename
+
 class LogFilter:
     ''' Logging filter object to match only record in the current thread.
     '''
@@ -165,7 +180,7 @@ def find_source_problem(log_contents, source):
     return None
 
 def write_state(source, skipped, destination, log_handler, cache_result,
-                conform_result, preview_path, temp_dir):
+                conform_result, preview_path, slippymap_path, temp_dir):
     '''
     '''
     source_id, _ = splitext(basename(source))
@@ -200,6 +215,10 @@ def write_state(source, skipped, destination, log_handler, cache_result,
         preview_path2 = join(statedir, 'preview.png')
         copy(preview_path, preview_path2)
     
+    if slippymap_path:
+        slippymap_path2 = join(statedir, 'slippymap.mbtiles')
+        copy(slippymap_path, slippymap_path2)
+    
     log_handler.flush()
     output_path = join(statedir, 'output.txt')
     copy(log_handler.stream.name, output_path)
@@ -232,6 +251,7 @@ def write_state(source, skipped, destination, log_handler, cache_result,
         ('process time', conform_result.elapsed and str(conform_result.elapsed)),
         ('output', relpath(output_path, statedir)),
         ('preview', preview_path and relpath(preview_path2, statedir)),
+        ('slippymap', slippymap_path and relpath(slippymap_path2, statedir)),
         ('attribution required', boolstr(conform_result.attribution_flag)),
         ('attribution name', conform_result.attribution_name),
         ('share-alike', boolstr(conform_result.sharealike_flag)),
diff --git a/openaddr/slippymap.py b/openaddr/slippymap.py
new file mode 100644
index 0000000..8fcdfb6
--- /dev/null
+++ b/openaddr/slippymap.py
@@ -0,0 +1,103 @@
+from __future__ import division
+import logging; _L = logging.getLogger('openaddr.slippymap')
+
+from zipfile import ZipFile
+from io import TextIOWrapper
+from csv import DictReader
+from tempfile import gettempdir
+from argparse import ArgumentParser
+from urllib.parse import urlparse
+import os, subprocess, json
+
+def generate(filename_or_url, mbtiles_filename):
+    '''
+    '''
+    src_filename = get_local_filename(filename_or_url)
+    
+    cmd = 'tippecanoe', '-l', 'dots', '-r', '3', \
+          '-n', 'OpenAddresses Dots', '-f', \
+          '-t', gettempdir(), '-o', mbtiles_filename
+    
+    tippecanoe = subprocess.Popen(cmd, stdin=subprocess.PIPE, bufsize=1)
+    
+    for feature in iterate_file_features(src_filename):
+        tippecanoe.stdin.write(json.dumps(feature).encode('utf8'))
+        tippecanoe.stdin.write(b'\n')
+
+    tippecanoe.stdin.close()
+    tippecanoe.wait()
+
+def get_local_filename(filename_or_url):
+    '''
+    '''
+    parsed = urlparse(filename_or_url)
+    suffix = os.path.splitext(parsed.path)[1]
+    
+    if parsed.scheme in ('', 'file'):
+        return filename_or_url
+    
+    if parsed.scheme not in ('http', 'https'):
+        raise ValueError('Unknown URL type: {}'.format(filename_or_url))
+    
+    _L.info('Downloading {}...'.format(filename_or_url))
+
+    got = requests.get(filename_or_url)
+    _, filename = mkstemp(prefix='SlippyMap-', suffix=suffix)
+
+    with open(filename, 'wb') as file:
+        file.write(got.content)
+        _L.debug('Saved to {}'.format(filename))
+    
+    return filename
+
+def iterate_file_features(filename):
+    ''' Stream GeoJSON features from an input .csv or .zip file.
+    '''
+    suffix = os.path.splitext(filename)[1].lower()
+    
+    if suffix == '.csv':
+        open_file = open(filename, 'r')
+    elif suffix == '.zip':
+        open_file = open(filename, 'rb')
+    
+    with open_file as file:
+        if suffix == '.csv':
+            csv_file = file
+        elif suffix == '.zip':
+            zip = ZipFile(file)
+            csv_names = [name for name in zip.namelist() if name.endswith('.csv')]
+            csv_file = TextIOWrapper(zip.open(csv_names[0]))
+        
+        for row in DictReader(csv_file):
+            try:
+                lon, lat = float(row['LON']), float(row['LAT'])
+            except:
+                continue
+            
+            if -180 <= lon <= 180 and -90 <= lat <= 90:
+                geometry = dict(type='Point', coordinates=[lon, lat])
+                properties = {k: v for (k, v) in row.items() if k not in ('LON', 'LAT')}
+                feature = dict(type='Feature', geometry=geometry, properties=properties)
+                yield(feature)
+
+parser = ArgumentParser(description='Generate a single source slippy map MBTiles file with Tippecanoe.')
+
+parser.add_argument('src_filename', help='Input Zip or CSV filename or URL.')
+parser.add_argument('mbtiles_filename', help='Output MBTiles filename.')
+
+parser.add_argument('-v', '--verbose', help='Turn on verbose logging',
+                    action='store_const', dest='loglevel',
+                    const=logging.DEBUG, default=logging.INFO)
+
+parser.add_argument('-q', '--quiet', help='Turn off most logging',
+                    action='store_const', dest='loglevel',
+                    const=logging.WARNING, default=logging.INFO)
+
+def main():
+    args = parser.parse_args()
+    from .ci import setup_logger
+    setup_logger(None, None, None, log_level=args.loglevel)
+    generate(args.src_filename, args.mbtiles_filename)
+
+if __name__ == '__main__':
+    exit(main())


Test patch:
diff --git a/openaddr/tests/__init__.py b/openaddr/tests/__init__.py
index f0705f8..1fa612c 100644
--- a/openaddr/tests/__init__.py
+++ b/openaddr/tests/__init__.py
@@ -57,6 +57,12 @@ from ..cache import CacheResult
 from ..conform import ConformResult
 from ..process_one import find_source_problem
 
+def touch_second_arg_file(_, path, *args, **kwargs):
+    ''' Write a short dummy file for the second argument.
+    '''
+    with open(path, 'w') as file:
+        file.write('yo')
+
 class TestOA (unittest.TestCase):
     
     def setUp(self):
@@ -302,7 +308,11 @@ class TestOA (unittest.TestCase):
         '''
         source = join(self.src_dir, 'us-ca-alameda_county.json')
         
-        with HTTMock(self.response_content):
+        with HTTMock(self.response_content), \
+             mock.patch('openaddr.preview.render') as preview_ren, \
+             mock.patch('openaddr.slippymap.generate') as slippymap_gen:
+            preview_ren.side_effect = touch_second_arg_file
+            slippymap_gen.side_effect = touch_second_arg_file
             state_path = process_one.process(source, self.testdir, True, mapzen_key='mapzen-XXXX')
         
         with open(state_path) as file:
@@ -312,6 +322,7 @@ class TestOA (unittest.TestCase):
         self.assertIsNotNone(state['processed'])
         self.assertIsNotNone(state['sample'])
         self.assertIsNotNone(state['preview'])
+        self.assertIsNotNone(state['slippymap'])
         self.assertEqual(state['geometry type'], 'Point')
         self.assertIsNone(state['website'])
         self.assertEqual(state['license'], 'http://www.acgov.org/acdata/terms.htm')
@@ -350,7 +361,11 @@ class TestOA (unittest.TestCase):
         '''
         source = join(self.src_dir, 'us-ca-alameda_county-mixedcase.json')
         
-        with HTTMock(self.response_content):
+        with HTTMock(self.response_content), \
+             mock.patch('openaddr.preview.render') as preview_ren, \
+             mock.patch('openaddr.slippymap.generate') as slippymap_gen:
+            preview_ren.side_effect = touch_second_arg_file
+            slippymap_gen.side_effect = touch_second_arg_file
             state_path = process_one.process(source, self.testdir, True, mapzen_key='mapzen-XXXX')
         
         with open(state_path) as file:
@@ -360,6 +375,7 @@ class TestOA (unittest.TestCase):
         self.assertIsNotNone(state['processed'])
         self.assertIsNotNone(state['sample'])
         self.assertIsNotNone(state['preview'])
+        self.assertIsNotNone(state['slippymap'])
         self.assertEqual(state['geometry type'], 'Point')
         self.assertIsNone(state['website'])
         self.assertEqual(state['license'], 'http://www.acgov.org/acdata/terms.htm')
@@ -394,7 +410,11 @@ class TestOA (unittest.TestCase):
         '''
         source = join(self.src_dir, 'us-ca-san_francisco.json')
         
-        with HTTMock(self.response_content):
+        with HTTMock(self.response_content), \
+             mock.patch('openaddr.preview.render') as preview_ren, \
+             mock.patch('openaddr.slippymap.generate') as slippymap_gen:
+            preview_ren.side_effect = touch_second_arg_file
+            slippymap_gen.side_effect = touch_second_arg_file
             state_path = process_one.process(source, self.testdir, True, mapzen_key='mapzen-XXXX')
         
         with open(state_path) as file:
@@ -404,6 +424,7 @@ class TestOA (unittest.TestCase):
         self.assertIsNotNone(state['processed'])
         self.assertIsNotNone(state['sample'])
         self.assertIsNotNone(state['preview'])
+        self.assertIsNotNone(state['slippymap'])
         self.assertEqual(state['geometry type'], 'Point')
         self.assertIsNone(state['website'])
         self.assertEqual(state['license'], '')
@@ -441,7 +462,11 @@ class TestOA (unittest.TestCase):
         '''
         source = join(self.src_dir, 'us-ca-carson.json')
         
-        with HTTMock(self.response_content):
+        with HTTMock(self.response_content), \
+             mock.patch('openaddr.preview.render') as preview_ren, \
+             mock.patch('openaddr.slippymap.generate') as slippymap_gen:
+            preview_ren.side_effect = touch_second_arg_file
+            slippymap_gen.side_effect = touch_second_arg_file
             state_path = process_one.process(source, self.testdir, True, mapzen_key='mapzen-XXXX')
         
         with open(state_path) as file:
@@ -452,6 +477,7 @@ class TestOA (unittest.TestCase):
         self.assertIsNotNone(state['processed'])
         self.assertIsNotNone(state['sample'])
         self.assertIsNotNone(state['preview'])
+        self.assertIsNotNone(state['slippymap'])
         self.assertEqual(state['geometry type'], 'Point')
         self.assertEqual(state['website'], 'http://ci.carson.ca.us/')
         self.assertIsNone(state['license'])
@@ -490,6 +516,7 @@ class TestOA (unittest.TestCase):
         self.assertIsNotNone(state['processed'])
         self.assertIsNotNone(state['sample'])
         self.assertIsNone(state['preview'])
+        self.assertIsNone(state['slippymap'])
         self.assertEqual(state['geometry type'], 'Point')
 
         with open(join(dirname(state_path), state['sample'])) as file:
@@ -517,6 +544,7 @@ class TestOA (unittest.TestCase):
         self.assertIsNotNone(state['processed'])
         self.assertIsNotNone(state['sample'])
         self.assertIsNone(state['preview'])
+        self.assertIsNone(state['slippymap'])
         self.assertEqual(state['geometry type'], 'Point')
 
         with open(join(dirname(state_path), state['sample'])) as file:
@@ -542,6 +570,7 @@ class TestOA (unittest.TestCase):
         self.assertIsNone(state.cache)
         self.assertIsNone(state.processed)
         self.assertIsNone(state.preview)
+        self.assertIsNone(state.slippymap)
         # This test data does not contain a working conform object
         self.assertEqual(state.source_problem, 'Missing required ESRI token')
 
@@ -562,6 +591,7 @@ class TestOA (unittest.TestCase):
         self.assertEqual(state.source_problem, 'Unknown source conform type')
         self.assertIsNone(state.processed)
         self.assertIsNone(state.preview)
+        self.assertIsNone(state.slippymap)
         self.assertEqual(state.website, 'http://data.openoakland.org/dataset/property-parcels/resource/df20b818-0d16-4da8-a9c1-a7b8b720ff49')
         self.assertIsNone(state.license)
         
@@ -587,6 +617,7 @@ class TestOA (unittest.TestCase):
         self.assertIsNone(state.cache)
         self.assertIsNone(state.processed)
         self.assertIsNone(state.preview)
+        self.assertIsNone(state.slippymap)
 
     def test_single_berk(self):
         ''' Test complete process_one.process on Berkeley sample data.
@@ -604,6 +635,7 @@ class TestOA (unittest.TestCase):
         self.assertEqual(state.source_problem, 'Source is missing a conform object')
         self.assertIsNone(state.processed)
         self.assertIsNone(state.preview)
+        self.assertIsNone(state.slippymap)
         self.assertEqual(state.website, 'http://www.ci.berkeley.ca.us/datacatalog/')
         self.assertIsNone(state.license)
         
@@ -627,6 +659,7 @@ class TestOA (unittest.TestCase):
         self.assertIsNone(state.cache)
         self.assertIsNone(state.processed)
         self.assertIsNone(state.preview)
+        self.assertIsNone(state.slippymap)
         
     def test_single_berk_apn(self):
         ''' Test complete process_one.process on Berkeley sample data.
@@ -642,6 +675,7 @@ class TestOA (unittest.TestCase):
         self.assertIsNotNone(state['cache'])
         self.assertIsNotNone(state['processed'])
         self.assertIsNone(state['preview'])
+        self.assertIsNone(state['slippymap'])
         self.assertEqual(state['website'], 'http://www.ci.berkeley.ca.us/datacatalog/')
         self.assertIsNone(state['license'])
         
@@ -677,6 +711,7 @@ class TestOA (unittest.TestCase):
         self.assertIsNotNone(state['processed'])
         self.assertIsNotNone(state['sample'])
         self.assertIsNone(state['preview'])
+        self.assertIsNone(state['slippymap'])
         self.assertEqual(state['geometry type'], 'Point')
         self.assertIsNone(state['website'])
         self.assertEqual(state['license'][:21], 'Polish Law on Geodesy')
@@ -706,6 +741,7 @@ class TestOA (unittest.TestCase):
         self.assertIsNotNone(state['processed'])
         self.assertIsNotNone(state['sample'])
         self.assertIsNone(state['preview'])
+        self.assertIsNone(state['slippymap'])
         self.assertEqual(state['geometry type'], 'Point')
         self.assertIsNone(state['website'])
         self.assertEqual(state['license'][:21], 'Polish Law on Geodesy')
@@ -749,6 +785,7 @@ class TestOA (unittest.TestCase):
         self.assertEqual(state.source_problem, 'Could not conform source data')
         self.assertIsNone(state.processed)
         self.assertIsNone(state.preview)
+        self.assertIsNone(state.slippymap)
         self.assertEqual(state.website, 'http://nlftp.mlit.go.jp/isj/index.html')
         self.assertEqual(state.license, u'http://nlftp.mlit.go.jp/ksj/other/yakkan§.html')
         self.assertEqual(state.attribution_required, 'true')
@@ -778,6 +815,7 @@ class TestOA (unittest.TestCase):
         self.assertIsNone(state.source_problem)
         self.assertIsNotNone(state.processed)
         self.assertIsNone(state.preview)
+        self.assertIsNone(state.slippymap)
         self.assertEqual(state.website, 'http://nlftp.mlit.go.jp/isj/index.html')
         self.assertEqual(state.license, u'http://nlftp.mlit.go.jp/ksj/other/yakkan.html')
         self.assertEqual(state.attribution_required, 'true')
@@ -822,6 +860,7 @@ class TestOA (unittest.TestCase):
 
         self.assertIsNotNone(state['sample'])
         self.assertIsNone(state['preview'])
+        self.assertIsNone(state['slippymap'])
         self.assertIsNone(state['website'])
         self.assertIsNone(state['license'])
 
@@ -842,6 +881,7 @@ class TestOA (unittest.TestCase):
             state = dict(zip(*json.load(file)))
 
         self.assertIsNone(state['preview'])
+        self.assertIsNone(state['slippymap'])
         self.assertIsNotNone(state['processed'])
         self.assertIsNotNone(state['cache'])
         self.assertIsNotNone(state['sample'])
@@ -882,6 +922,7 @@ class TestOA (unittest.TestCase):
 
         self.assertIsNotNone(state['sample'])
         self.assertIsNone(state['preview'])
+        self.assertIsNone(state['slippymap'])
         self.assertEqual(state['website'], 'http://adresse.data.gouv.fr/download/')
         self.assertIsNone(state['license'])
         self.assertEqual(state['attribution required'], 'true')
@@ -910,6 +951,7 @@ class TestOA (unittest.TestCase):
 
         self.assertIsNotNone(state['sample'])
         self.assertIsNone(state['preview'])
+        self.assertIsNone(state['slippymap'])
         self.assertEqual(state['website'], 'http://adresse.data.gouv.fr/download/')
         self.assertIsNone(state['license'])
         self.assertEqual(state['attribution required'], 'true')
@@ -938,6 +980,7 @@ class TestOA (unittest.TestCase):
 
         self.assertIsNotNone(state['sample'])
         self.assertIsNone(state['preview'])
+        self.assertIsNone(state['slippymap'])
 
         with open(join(dirname(state_path), state['sample'])) as file:
             sample_data = json.load(file)
@@ -961,6 +1004,7 @@ class TestOA (unittest.TestCase):
 
         self.assertIsNotNone(state['sample'])
         self.assertIsNone(state['preview'])
+        self.assertIsNone(state['slippymap'])
 
         with open(join(dirname(state_path), state['sample'])) as file:
             sample_data = json.load(file)
@@ -984,6 +1028,7 @@ class TestOA (unittest.TestCase):
 
         self.assertIsNotNone(state['sample'])
         self.assertIsNone(state['preview'])
+        self.assertIsNone(state['slippymap'])
 
         with open(join(dirname(state_path), state['sample'])) as file:
             sample_data = json.load(file)
@@ -1004,6 +1049,7 @@ class TestOA (unittest.TestCase):
 
         self.assertIsNotNone(state['sample'])
         self.assertIsNone(state['preview'])
+        self.assertIsNone(state['slippymap'])
 
         with open(join(dirname(state_path), state['sample'])) as file:
             sample_data = json.load(file)
@@ -1046,6 +1092,7 @@ class TestOA (unittest.TestCase):
 
         self.assertIsNotNone(state['sample'])
         self.assertIsNone(state['preview'])
+        self.assertIsNone(state['slippymap'])
 
         with open(join(dirname(state_path), state['sample'])) as file:
             sample_data = json.load(file)
@@ -1068,6 +1115,7 @@ class TestOA (unittest.TestCase):
 
         self.assertIsNotNone(state['sample'])
         self.assertIsNone(state['preview'])
+        self.assertIsNone(state['slippymap'])
 
         with open(join(dirname(state_path), state['sample'])) as file:
             sample_data = json.load(file)
@@ -1111,6 +1159,7 @@ class TestOA (unittest.TestCase):
 
         self.assertIsNotNone(state['sample'])
         self.assertIsNone(state['preview'])
+        self.assertIsNone(state['slippymap'])
 
         with open(join(dirname(state_path), state['sample'])) as file:
             sample_data = json.load(file)
@@ -1173,6 +1222,7 @@ class TestOA (unittest.TestCase):
         self.assertIsNone(state.source_problem)
         self.assertIsNotNone(state.processed)
         self.assertIsNone(state.preview)
+        self.assertIsNone(state.slippymap)
 
         output_path = join(dirname(state_path), state.processed)
         
@@ -1214,6 +1264,7 @@ class TestOA (unittest.TestCase):
 
         self.assertIsNotNone(state['sample'])
         self.assertIsNone(state['preview'])
+        self.assertIsNone(state['slippymap'])
 
         with open(join(dirname(state_path), state['sample'])) as file:
             sample_data = json.load(file)
@@ -1386,6 +1437,7 @@ class TestOA (unittest.TestCase):
 
         self.assertIsNotNone(state['sample'])
         self.assertIsNone(state['preview'])
+        self.assertIsNone(state['slippymap'])
         
         with open(join(dirname(state_path), state['sample'])) as file:
             sample_data = json.load(file)
@@ -1426,6 +1478,7 @@ class TestOA (unittest.TestCase):
 
         self.assertIsNotNone(state['sample'])
         self.assertIsNone(state['preview'])
+        self.assertIsNone(state['slippymap'])
         
         with open(join(dirname(state_path), state['sample'])) as file:
             sample_data = json.load(file)
@@ -1466,6 +1519,7 @@ class TestOA (unittest.TestCase):
 
         self.assertIsNotNone(state['sample'])
         self.assertIsNone(state['preview'])
+        self.assertIsNone(state['slippymap'])
         
         with open(join(dirname(state_path), state['sample'])) as file:
             sample_data = json.load(file)
@@ -1519,6 +1573,9 @@ class TestState (unittest.TestCase):
         with open(join(self.output_dir, 'preview.png'), 'w') as file:
             preview_path = file.name
 
+        with open(join(self.output_dir, 'slippymap.mbtiles'), 'w') as file:
+            slippymap_path = file.name
+
         conform_result = ConformResult(processed=None, sample='/tmp/sample.json',
                                        website='http://example.com', license='ODbL',
                                        geometry_type='Point', address_count=999,
@@ -1536,7 +1593,8 @@ class TestState (unittest.TestCase):
         args = dict(source='sources/foo.json', skipped=False,
                     destination=self.output_dir, log_handler=log_handler,
                     cache_result=cache_result, conform_result=conform_result,
-                    temp_dir=self.output_dir, preview_path=preview_path)
+                    temp_dir=self.output_dir, preview_path=preview_path,
+                    slippymap_path=slippymap_path)
 
         path1 = process_one.write_state(**args)
         
@@ -1558,6 +1616,7 @@ class TestState (unittest.TestCase):
         self.assertEqual(state1['process time'], '0:00:01')
         self.assertEqual(state1['output'], 'output.txt')
         self.assertEqual(state1['preview'], 'preview.png')
+        self.assertEqual(state1['slippymap'], 'slippymap.mbtiles')
         self.assertEqual(state1['share-alike'], 'true')
         self.assertEqual(state1['attribution required'], 'true')
         self.assertEqual(state1['attribution name'], 'Example')
diff --git a/openaddr/tests/ci.py b/openaddr/tests/ci.py
index 123c4be..ad2af4e 100644
--- a/openaddr/tests/ci.py
+++ b/openaddr/tests/ci.py
@@ -2830,7 +2830,7 @@ class TestWorker (unittest.TestCase):
         s3.new_key.return_value.name = 'a-key'
         s3.new_key.return_value.bucket.name = 'a-bucket'
 
-        input1 = {'cache': False, 'sample': False, 'processed': False, 'output': False, 'preview': False}
+        input1 = {'cache': False, 'sample': False, 'processed': False, 'output': False, 'preview': False, 'slippymap': False}
         state1 = RunState(assemble_output(s3, input1, 'xx/f', 1, 'dir'))
 
         self.assertEqual(state1.cache, input1['cache'])
@@ -2838,8 +2838,9 @@ class TestWorker (unittest.TestCase):
         self.assertEqual(state1.processed, input1['processed'])
         self.assertEqual(state1.output, input1['output'])
         self.assertEqual(state1.preview, input1['preview'])
+        self.assertEqual(state1.slippymap, input1['slippymap'])
 
-        input2 = {'cache': 'cache.csv', 'sample': False, 'processed': False, 'output': False, 'preview': False}
+        input2 = {'cache': 'cache.csv', 'sample': False, 'processed': False, 'output': False, 'preview': False, 'slippymap': False}
         state2 = RunState(assemble_output(s3, input2, 'xx/f', 2, 'dir'))
 
         self.assertEqual(state2.cache, 'https://s3.amazonaws.com/a-bucket/a-key')
@@ -2848,9 +2849,10 @@ class TestWorker (unittest.TestCase):
         self.assertEqual(state2.processed, input2['processed'])
         self.assertEqual(state2.output, input2['output'])
         self.assertEqual(state2.preview, input2['preview'])
+        self.assertEqual(state2.slippymap, input2['slippymap'])
         self.assertEqual(s3.new_key.mock_calls[-2], mock.call('/runs/2/cache.csv'))
 
-        input3 = {'cache': False, 'sample': 'sample.json', 'processed': False, 'output': False, 'preview': False}
+        input3 = {'cache': False, 'sample': 'sample.json', 'processed': False, 'output': False, 'preview': False, 'slippymap': False}
         state3 = RunState(assemble_output(s3, input3, 'xx/f', 3, 'dir'))
 
         self.assertEqual(state3.cache, input3['cache'])
@@ -2858,9 +2860,10 @@ class TestWorker (unittest.TestCase):
         self.assertEqual(state3.processed, input3['processed'])
         self.assertEqual(state3.output, input3['output'])
         self.assertEqual(state3.preview, input3['preview'])
+        self.assertEqual(state3.slippymap, input3['slippymap'])
         self.assertEqual(s3.new_key.mock_calls[-2], mock.call('/runs/3/sample.json'))
 
-        input4 = {'cache': False, 'sample': False, 'processed': False, 'output': 'out.txt', 'preview': False}
+        input4 = {'cache': False, 'sample': False, 'processed': False, 'output': 'out.txt', 'preview': False, 'slippymap': False}
         state4 = RunState(assemble_output(s3, input4, 'xx/f', 4, 'dir'))
 
         self.assertEqual(state4.cache, input4['cache'])
@@ -2868,11 +2871,12 @@ class TestWorker (unittest.TestCase):
         self.assertEqual(state4.processed, input4['processed'])
         self.assertEqual(state4.output, 'https://s3.amazonaws.com/a-bucket/a-key')
         self.assertEqual(state4.preview, input4['preview'])
+        self.assertEqual(state4.slippymap, input4['slippymap'])
         self.assertEqual(s3.new_key.mock_calls[-2], mock.call('/runs/4/out.txt'))
 
         with patch('openaddr.util.package_output') as package_output:
             package_output.return_value = 'nothing.zip'
-            input5 = {'cache': False, 'sample': False, 'processed': 'data.zip', 'output': False, 'preview': False}
+            input5 = {'cache': False, 'sample': False, 'processed': 'data.zip', 'output': False, 'preview': False, 'slippymap': False}
             state5 = RunState(assemble_output(s3, input5, 'xx/f', 5, 'dir'))
 
         self.assertEqual(state5.cache, input5['cache'])
@@ -2881,17 +2885,30 @@ class TestWorker (unittest.TestCase):
         self.assertEqual(state5.process_hash, '0xWHATEVER')
         self.assertEqual(state5.output, input5['output'])
         self.assertEqual(state5.preview, input5['preview'])
+        self.assertEqual(state5.slippymap, input5['slippymap'])
         self.assertEqual(s3.new_key.mock_calls[-2], mock.call('/runs/5/xx/f.zip'))
 
-        input6 = {'cache': False, 'sample': False, 'processed': False, 'output': False, 'preview': 'preview.png'}
-        state6 = RunState(assemble_output(s3, input6, 'xx/f', 4, 'dir'))
+        input6 = {'cache': False, 'sample': False, 'processed': False, 'output': False, 'preview': 'preview.png', 'slippymap': False}
+        state6 = RunState(assemble_output(s3, input6, 'xx/f', 6, 'dir'))
 
         self.assertEqual(state6.cache, input6['cache'])
         self.assertEqual(state6.sample, input6['sample'])
         self.assertEqual(state6.processed, input6['processed'])
         self.assertEqual(state6.output, input6['output'])
         self.assertEqual(state6.preview, 'https://s3.amazonaws.com/a-bucket/a-key')
-        self.assertEqual(s3.new_key.mock_calls[-2], mock.call('/runs/4/preview.png'))
+        self.assertEqual(state6.slippymap, input6['slippymap'])
+        self.assertEqual(s3.new_key.mock_calls[-2], mock.call('/runs/6/preview.png'))
+
+        input7 = {'cache': False, 'sample': False, 'processed': False, 'output': False, 'preview': False, 'slippymap': 'slippymap.mbtiles'}
+        state7 = RunState(assemble_output(s3, input7, 'xx/f', 7, 'dir'))
+
+        self.assertEqual(state7.cache, input7['cache'])
+        self.assertEqual(state7.sample, input7['sample'])
+        self.assertEqual(state7.processed, input7['processed'])
+        self.assertEqual(state7.output, input7['output'])
+        self.assertEqual(state7.preview, input7['preview'])
+        self.assertEqual(state7.slippymap, 'https://s3.amazonaws.com/a-bucket/a-key')
+        self.assertEqual(s3.new_key.mock_calls[-2], mock.call('/runs/7/slippymap.mbtiles'))
 
     @patch('tempfile.mkdtemp')
     @patch('openaddr.compat.check_output')
@@ -2905,9 +2922,9 @@ class TestWorker (unittest.TestCase):
             os.makedirs(index_dirname)
             
             with open(index_filename, 'w') as file:
-                file.write('''[ ["skipped", "source", "cache", "sample", "website", "license", "geometry type", "address count", "version", "fingerprint", "cache time", "processed", "process time", "process hash", "output", "preview"], [false, "user_input.txt", "cache.zip", "sample.json", "http://example.com", "GPL", "Point", 62384, null, "6c4852b8c7b0f1c7dd9af289289fb70f", "0:00:01.345149", "out.csv", "0:00:33.808682", "dd9af289289fb70f6c4852b8c7b0f1c7", "output.txt", "preview.png"] ]''')
+                file.write('''[ ["skipped", "source", "cache", "sample", "website", "license", "geometry type", "address count", "version", "fingerprint", "cache time", "processed", "process time", "process hash", "output", "preview", "slippymap"], [false, "user_input.txt", "cache.zip", "sample.json", "http://example.com", "GPL", "Point", 62384, null, "6c4852b8c7b0f1c7dd9af289289fb70f", "0:00:01.345149", "out.csv", "0:00:33.808682", "dd9af289289fb70f6c4852b8c7b0f1c7", "output.txt", "preview.png", "slippymap.mbtiles"] ]''')
             
-            for name in ('cache.zip', 'sample.json', 'out.csv', 'output.txt', 'preview.png'):
+            for name in ('cache.zip', 'sample.json', 'out.csv', 'output.txt', 'preview.png', 'slippymap.mbtiles'):
                 with open(os.path.join(index_dirname, name), 'w') as file:
                     file.write('Yo')
             
@@ -3009,7 +3026,7 @@ class TestWorker (unittest.TestCase):
             os.makedirs(index_dirname)
             
             with open(index_filename, 'w') as file:
-                file.write('''[ ["skipped", "source", "cache", "sample", "website", "license", "geometry type", "address count", "version", "fingerprint", "cache time", "processed", "process time", "process hash", "output", "preview"], [true, "user_input.txt", null, null, null, null, null, null, null, null, null, null, null, null, "output.txt", null] ]''')
+                file.write('''[ ["skipped", "source", "cache", "sample", "website", "license", "geometry type", "address count", "version", "fingerprint", "cache time", "processed", "process time", "process hash", "output", "preview", "slippymap"], [true, "user_input.txt", null, null, null, null, null, null, null, null, null, null, null, null, "output.txt", null, null] ]''')
             
             with open(os.path.join(index_dirname, 'output.txt'), 'w') as file:
                 file.write('Yo')
diff --git a/openaddr/tests/slippymap.py b/openaddr/tests/slippymap.py
new file mode 100644
index 0000000..4e169b3
--- /dev/null
+++ b/openaddr/tests/slippymap.py
@@ -0,0 +1,67 @@
+from __future__ import division
+
+import os
+import unittest
+import tempfile
+import mock
+
+from os.path import join, dirname
+from zipfile import ZipFile
+from shutil import rmtree
+
+from .. import slippymap
+
+class TestSlippyMap (unittest.TestCase):
+
+    def setUp(self):
+        self.temp_dir = tempfile.mkdtemp(prefix='TestPreview-')
+    
+    def tearDown(self):
+        rmtree(self.temp_dir)
+
+    def test_render_zip(self):
+        '''
+        '''
+        zip_filename = join(dirname(__file__), 'outputs', 'alameda.zip')
+        handle, mbtiles_filename = tempfile.mkstemp(prefix='render-', suffix='.mbtiles')
+        os.close(handle)
+
+        try:
+            with mock.patch('subprocess.Popen') as Popen:
+                slippymap.generate(zip_filename, mbtiles_filename)
+
+            self.assertEqual(len(Popen.return_value.stdin.write.mock_calls), 2 * 5305)
+            self.assertEqual(len(Popen.return_value.stdin.close.mock_calls), 1)
+            self.assertEqual(Popen.mock_calls[0][1][0],
+                ('tippecanoe', '-l', 'dots', '-r', '3', '-n', 'OpenAddresses Dots',
+                '-f', '-t', tempfile.gettempdir(), '-o', mbtiles_filename))
+        finally:
+            os.remove(mbtiles_filename)
+    
+    def test_render_csv(self):
+        '''
+        '''
+        zip_filename = join(dirname(__file__), 'outputs', 'portland_metro.zip')
+        handle, mbtiles_filename = tempfile.mkstemp(prefix='render-', suffix='.mbtiles')
+        os.close(handle)
+
+        try:
+            temp_dir = tempfile.mkdtemp(prefix='test_render_csv-')
+            zipfile = ZipFile(zip_filename)
+            
+            with open(join(temp_dir, 'portland.csv'), 'wb') as file:
+                file.write(zipfile.read('portland_metro/us/or/portland_metro.csv'))
+                csv_filename = file.name
+        
+            with mock.patch('subprocess.Popen') as Popen:
+                slippymap.generate(csv_filename, mbtiles_filename)
+
+            self.assertEqual(len(Popen.return_value.stdin.write.mock_calls), 2 * 767)
+            self.assertEqual(len(Popen.return_value.stdin.close.mock_calls), 1)
+            self.assertEqual(Popen.mock_calls[0][1][0],
+                ('tippecanoe', '-l', 'dots', '-r', '3', '-n', 'OpenAddresses Dots',
+                '-f', '-t', tempfile.gettempdir(), '-o', mbtiles_filename))
+        finally:
+            os.remove(mbtiles_filename)
+            os.remove(csv_filename)
+            os.rmdir(temp_dir)
diff --git a/test.py b/test.py
index 7207d00..b45b65b 100755
--- a/test.py
+++ b/test.py
@@ -26,6 +26,7 @@ from openaddr.tests.conform import TestConformCli, TestConformTransforms, TestCo
 from openaddr.tests.render import TestRender
 from openaddr.tests.dotmap import TestDotmap
 from openaddr.tests.preview import TestPreview
+from openaddr.tests.slippymap import TestSlippyMap
 from openaddr.tests.util import TestUtilities
 from openaddr.tests.summarize import TestSummarizeFunctions
 from openaddr.tests.parcels import TestParcelsUtils, TestParcelsParse