Repository: opendatacube/datacube-core

Issue:
Error at construction of GridWorkflow object with non-ingested product - VDI 
```
date_range = ('2020-1-1','2020-1-1')
product = 'ga_ls_fc_3'
dc = datacube.Datacube(app='test')
gw = datacube.api.GridWorkflow(dc.index, product=product)
gw.list_tiles(product=product, time=date_range, group_by='solar_day')
```
throws following error: 

```
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-39-e8209ea2e216> in <module>
      5 dc = datacube.Datacube(app='test')
      6 gw = datacube.api.GridWorkflow(dc.index, product=product)
----> 7 gw.list_tiles(product=product, time=date_range, group_by='solar_day')
      8 
/g/data/v10/public/modules/dea/20201217/lib/python3.6/site-packages/datacube/api/grid_workflow.py in list_tiles(self, cell_index, **query)
    321         .. seealso:: :meth:`load`
    322         """
--> 323         observations = self.cell_observations(cell_index, **query)
    324         return self.tile_sources(observations, query_group_by(**query))
    325 
/g/data/v10/public/modules/dea/20201217/lib/python3.6/site-packages/datacube/api/grid_workflow.py in cell_observations(self, cell_index, geopolygon, tile_buffer, **indexers)
    219             else:
    220                 for dataset in datasets:
--> 221                     for tile_index, tile_geobox in self.grid_spec.tiles_from_geopolygon(dataset.extent,
    222                                                                                         tile_buffer=tile_buffer,
    223                                                                                         geobox_cache=geobox_cache):
AttributeError: 'NoneType' object has no attribute 'tiles_from_geopolygon'
```

@Kirill888  points to https://github.com/opendatacube/datacube-core/blob/6d75b686656d8688cc830897d596bfd26cfea336/datacube/api/grid_workflow.py#L155-L158 
assumes that product contains valid grid spec, which is not the case for non-ingested products like ga_ls_fc_3 so you need to supply grid_spec=  parameter.
Datacube code should have raised an error at construction of GridWorkflow object.




Discussion:
Expected behaviour is for `gw = datacube.api.GridWorkflow(dc.index, product=product)` to raise an error when supplied product does not have a valid grid spec.

Resolving patch:
diff --git a/datacube/api/grid_workflow.py b/datacube/api/grid_workflow.py
--- a/datacube/api/grid_workflow.py
+++ b/datacube/api/grid_workflow.py
@@ -153,10 +153,21 @@ def __init__(self, index, grid_spec=None, product=None):
         """
         self.index = index
         if grid_spec is None:
+            if product is None:
+                raise ValueError("Have to supply either grid_spec or product")
+
             product = self.index.products.get_by_name(product)
-            grid_spec = product and product.grid_spec
+            if product is None:
+                raise ValueError(f"No such product: {product}")
+
+            if product.grid_spec is None:
+                raise ValueError(f"Supplied product `{product}` does not have a gridspec")
+
+            grid_spec = product.grid_spec
         self.grid_spec = grid_spec
 
+        assert self.grid_spec is not None
+
     def cell_observations(self, cell_index=None, geopolygon=None, tile_buffer=None, **indexers):
         """
         List datasets, grouped by cell.


Test patch:
diff --git a/integration_tests/test_gridworkflow.py b/integration_tests/test_gridworkflow.py
deleted file mode 100644
--- a/integration_tests/test_gridworkflow.py
+++ /dev/null
@@ -1,19 +0,0 @@
-# This file is part of the Open Data Cube, see https://opendatacube.org for more information
-#
-# Copyright (c) 2015-2020 ODC Contributors
-# SPDX-License-Identifier: Apache-2.0
-from datacube.api.grid_workflow import GridWorkflow
-
-
-# TODO: do we still need this now that driver manager is gone?
-def test_create_gridworkflow_with_logging(index):
-    from logging import getLogger, StreamHandler
-
-    logger = getLogger(__name__)
-    handler = StreamHandler()
-    logger.addHandler(handler)
-
-    try:
-        gw = GridWorkflow(index)
-    finally:
-        logger.removeHandler(handler)
diff --git a/tests/api/test_grid_workflow.py b/tests/api/test_grid_workflow.py
--- a/tests/api/test_grid_workflow.py
+++ b/tests/api/test_grid_workflow.py
@@ -4,9 +4,12 @@
 # SPDX-License-Identifier: Apache-2.0
 import pytest
 import numpy
+from datacube.api.grid_workflow import GridWorkflow
 from datacube.model import GridSpec
 from datacube.utils import geometry
 from unittest.mock import MagicMock
+from datacube.testutils import mk_sample_product
+import datetime
 import uuid
 
 
@@ -15,53 +18,117 @@ def __reduce__(self):
         return (MagicMock, ())
 
 
+def mk_fake_index(products, datasets):
+    fakeindex = PickableMock()
+    fakeindex._db = None
+
+    fakeindex.products.get_by_name = lambda name: products.get(name, None)
+
+    fakeindex.datasets.get_field_names.return_value = ["time"]  # permit query on time
+    fakeindex.datasets.search_eager.return_value = datasets
+
+    return fakeindex
+
+
+@pytest.fixture(scope="session")
+def fake_index():
+    return mk_fake_index(
+        products=dict(
+            with_gs=mk_sample_product("with_gs", with_grid_spec=True),
+            without_gs=mk_sample_product("without_gs", with_grid_spec=False),
+        ),
+        datasets=[],
+    )
+
+
+def test_create_gridworkflow_init_failures(fake_index):
+    index = fake_index
+
+    # need product or grispec
+    with pytest.raises(ValueError):
+        GridWorkflow(index)
+
+    # test missing product
+    with pytest.raises(ValueError):
+        GridWorkflow(index, product="no-such-product")
+
+    # test missing product
+    assert fake_index.products.get_by_name("without_gs") is not None
+    assert fake_index.products.get_by_name("without_gs").grid_spec is None
+
+    with pytest.raises(ValueError):
+        GridWorkflow(index, product="without_gs")
+
+    product = fake_index.products.get_by_name("with_gs")
+    assert product is not None
+    assert product.grid_spec is not None
+    gw = GridWorkflow(index, product="with_gs")
+    assert gw.grid_spec is product.grid_spec
+
+
 def test_gridworkflow():
-    """ Test GridWorkflow with padding option. """
-    from unittest.mock import MagicMock
-    import datetime
+    """Test GridWorkflow with padding option."""
 
     # ----- fake a datacube -----
     # e.g. let there be a dataset that coincides with a grid cell
 
-    fakecrs = geometry.CRS('EPSG:4326')
+    fakecrs = geometry.CRS("EPSG:4326")
 
     grid = 100  # spatial frequency in crs units
     pixel = 10  # square pixel linear dimension in crs units
     # if cell(0,0) has lower left corner at grid origin,
     # and cell indices increase toward upper right,
     # then this will be cell(1,-2).
-    gridspec = GridSpec(crs=fakecrs, tile_size=(grid, grid), resolution=(-pixel, pixel))  # e.g. product gridspec
+    gridspec = GridSpec(
+        crs=fakecrs, tile_size=(grid, grid), resolution=(-pixel, pixel)
+    )  # e.g. product gridspec
 
     fakedataset = MagicMock()
-    fakedataset.extent = geometry.box(left=grid, bottom=-grid, right=2*grid, top=-2*grid, crs=fakecrs)
+    fakedataset.extent = geometry.box(
+        left=grid, bottom=-grid, right=2 * grid, top=-2 * grid, crs=fakecrs
+    )
     fakedataset.center_time = t = datetime.datetime(2001, 2, 15)
     fakedataset.id = uuid.uuid4()
 
     fakeindex = PickableMock()
     fakeindex._db = None
-    fakeindex.datasets.get_field_names.return_value = ['time']  # permit query on time
+    fakeindex.datasets.get_field_names.return_value = ["time"]  # permit query on time
     fakeindex.datasets.search_eager.return_value = [fakedataset]
 
     # ------ test without padding ----
 
-    from datacube.api.grid_workflow import GridWorkflow
     gw = GridWorkflow(fakeindex, gridspec)
+
+    # smoke test str/repr
+    assert len(str(gw)) > 0
+    assert len(repr(gw)) > 0
+
     # Need to force the fake index otherwise the driver manager will
     # only take its _db
     gw.index = fakeindex
-    query = dict(product='fake_product_name', time=('2001-1-1 00:00:00', '2001-3-31 23:59:59'))
+    query = dict(
+        product="fake_product_name", time=("2001-1-1 00:00:00", "2001-3-31 23:59:59")
+    )
 
     # test backend : that it finds the expected cell/dataset
     assert list(gw.cell_observations(**query).keys()) == [(1, -2)]
 
     # again but with geopolygon
-    assert list(gw.cell_observations(**query,
-                                     geopolygon=gridspec.tile_geobox((1, -2)).extent).keys()) == [(1, -2)]
+    assert list(
+        gw.cell_observations(
+            **query, geopolygon=gridspec.tile_geobox((1, -2)).extent
+        ).keys()
+    ) == [(1, -2)]
 
     with pytest.raises(ValueError) as e:
-        list(gw.cell_observations(**query, tile_buffer=(1, 1),
-                                  geopolygon=gridspec.tile_geobox((1, -2)).extent).keys())
-    assert str(e.value) == 'Cannot process tile_buffering and geopolygon together.'
+        list(
+            gw.cell_observations(
+                **query,
+                tile_buffer=(1, 1),
+                geopolygon=gridspec.tile_geobox((1, -2)).extent
+            ).keys()
+        )
+    assert str(e.value) == "Cannot process tile_buffering and geopolygon together."
 
     # test frontend
     assert len(gw.list_tiles(**query)) == 1
@@ -74,7 +141,9 @@ def test_gridworkflow():
 
     # consider cell (2,-2)
     fakedataset2 = MagicMock()
-    fakedataset2.extent = geometry.box(left=2*grid, bottom=-grid, right=3*grid, top=-2*grid, crs=fakecrs)
+    fakedataset2.extent = geometry.box(
+        left=2 * grid, bottom=-grid, right=3 * grid, top=-2 * grid, crs=fakecrs
+    )
     fakedataset2.center_time = t
     fakedataset2.id = uuid.uuid4()
 
@@ -85,21 +154,29 @@ def search_eager(lat=None, lon=None, **kwargs):
 
     # unpadded
     assert len(gw.list_tiles(**query)) == 2
-    ti = numpy.datetime64(t, 'ns')
+    ti = numpy.datetime64(t, "ns")
     assert set(gw.list_tiles(**query).keys()) == {(1, -2, ti), (2, -2, ti)}
 
     # padded
-    assert len(gw.list_tiles(tile_buffer=(20, 20), **query)) == 12  # not 18=2*9 because of grouping
+    assert (
+        len(gw.list_tiles(tile_buffer=(20, 20), **query)) == 12
+    )  # not 18=2*9 because of grouping
 
     # -------- inspect particular returned tile objects --------
 
     # check the array shape
 
     tile = gw.list_tiles(**query)[1, -2, ti]  # unpadded example
-    assert grid/pixel == 10
+    assert grid / pixel == 10
     assert tile.shape == (1, 10, 10)
 
-    padded_tile = gw.list_tiles(tile_buffer=(20, 20), **query)[1, -2, ti]  # padded example
+    # smoke test str/repr
+    assert len(str(tile)) > 0
+    assert len(repr(tile)) > 0
+
+    padded_tile = gw.list_tiles(tile_buffer=(20, 20), **query)[
+        1, -2, ti
+    ]  # padded example
     # assert grid/pixel + 2*gw2.grid_spec.padding == 14  # GREG: understand this
     assert padded_tile.shape == (1, 14, 14)
 
@@ -112,7 +189,7 @@ def search_eager(lat=None, lon=None, **kwargs):
 
     assert tile.geobox.alignment == padded_tile.geobox.alignment
     assert tile.geobox.affine * (0, 0) == padded_tile.geobox.affine * (2, 2)
-    assert tile.geobox.affine * (10, 10) == padded_tile.geobox.affine * (10+2, 10+2)
+    assert tile.geobox.affine * (10, 10) == padded_tile.geobox.affine * (10 + 2, 10 + 2)
 
     # ------- check loading --------
     # GridWorkflow accesses the load_data API
@@ -120,11 +197,12 @@ def search_eager(lat=None, lon=None, **kwargs):
     # so only thing to check here is the call interface.
 
     measurement = dict(nodata=0, dtype=numpy.int)
-    fakedataset.type.lookup_measurements.return_value = {'dummy': measurement}
+    fakedataset.type.lookup_measurements.return_value = {"dummy": measurement}
     fakedataset2.type = fakedataset.type
 
     from unittest.mock import patch
-    with patch('datacube.api.core.Datacube.load_data') as loader:
+
+    with patch("datacube.api.core.Datacube.load_data") as loader:
 
         data = GridWorkflow.load(tile)
         data2 = GridWorkflow.load(padded_tile)
@@ -139,7 +217,7 @@ def search_eager(lat=None, lon=None, **kwargs):
         assert args[0] is loadable.sources
         assert args[1] is loadable.geobox
         assert list(args[2].values())[0] is measurement
-        assert 'resampling' in kwargs
+        assert "resampling" in kwargs
 
     # ------- check single cell index extract -------
     tile = gw.list_tiles(cell_index=(1, -2), **query)
@@ -152,50 +230,54 @@ def search_eager(lat=None, lon=None, **kwargs):
     assert padded_tile[1, -2, ti].shape == (1, 14, 14)
     assert len(padded_tile[1, -2, ti].sources.values[0]) == 2
 
+    # query without product is not allowed
+    with pytest.raises(RuntimeError):
+        gw.list_cells(cell_index=(1, -2), time=query["time"])
+
 
 def test_gridworkflow_with_time_depth():
     """Test GridWorkflow with time series.
     Also test `Tile` methods `split` and `split_by_time`
     """
-    from unittest.mock import MagicMock
-    import datetime
-
-    fakecrs = geometry.CRS('EPSG:4326')
+    fakecrs = geometry.CRS("EPSG:4326")
 
     grid = 100  # spatial frequency in crs units
     pixel = 10  # square pixel linear dimension in crs units
     # if cell(0,0) has lower left corner at grid origin,
     # and cell indices increase toward upper right,
     # then this will be cell(1,-2).
-    gridspec = GridSpec(crs=fakecrs, tile_size=(grid, grid), resolution=(-pixel, pixel))  # e.g. product gridspec
+    gridspec = GridSpec(
+        crs=fakecrs, tile_size=(grid, grid), resolution=(-pixel, pixel)
+    )  # e.g. product gridspec
 
     def make_fake_datasets(num_datasets):
         start_time = datetime.datetime(2001, 2, 15)
         delta = datetime.timedelta(days=16)
         for i in range(num_datasets):
             fakedataset = MagicMock()
-            fakedataset.extent = geometry.box(left=grid, bottom=-grid, right=2*grid, top=-2*grid, crs=fakecrs)
+            fakedataset.extent = geometry.box(
+                left=grid, bottom=-grid, right=2 * grid, top=-2 * grid, crs=fakecrs
+            )
             fakedataset.center_time = start_time + (delta * i)
             yield fakedataset
 
     fakeindex = PickableMock()
-    fakeindex.datasets.get_field_names.return_value = ['time']  # permit query on time
+    fakeindex.datasets.get_field_names.return_value = ["time"]  # permit query on time
     fakeindex.datasets.search_eager.return_value = list(make_fake_datasets(100))
 
     # ------ test with time dimension ----
 
-    from datacube.api.grid_workflow import GridWorkflow
     gw = GridWorkflow(fakeindex, gridspec)
-    query = dict(product='fake_product_name')
+    query = dict(product="fake_product_name")
 
     cells = gw.list_cells(**query)
     for cell_index, cell in cells.items():
 
         #  test Tile.split()
-        for label, tile in cell.split('time'):
+        for label, tile in cell.split("time"):
             assert tile.shape == (1, 10, 10)
 
         #  test Tile.split_by_time()
-        for year, year_cell in cell.split_by_time(freq='A'):
+        for year, year_cell in cell.split_by_time(freq="A"):
             for t in year_cell.sources.time.values:
                 assert str(t)[:4] == year
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -21,10 +21,12 @@
 from datacube.index.eo3 import prep_eo3
 
 
-AWS_ENV_VARS = ("AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN"
-                "AWS_DEFAULT_REGION AWS_DEFAULT_OUTPUT AWS_PROFILE "
-                "AWS_ROLE_SESSION_NAME AWS_CA_BUNDLE "
-                "AWS_SHARED_CREDENTIALS_FILE AWS_CONFIG_FILE").split(" ")
+AWS_ENV_VARS = (
+    "AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN"
+    "AWS_DEFAULT_REGION AWS_DEFAULT_OUTPUT AWS_PROFILE "
+    "AWS_ROLE_SESSION_NAME AWS_CA_BUNDLE "
+    "AWS_SHARED_CREDENTIALS_FILE AWS_CONFIG_FILE"
+).split(" ")
 
 
 @pytest.fixture
@@ -34,30 +36,30 @@ def example_gdal_path(data_folder):
     Use this fixture by specifiying an argument named 'example_gdal_path' in your
     test method.
     """
-    return str(os.path.join(data_folder, 'sample_tile_151_-29.tif'))
+    return str(os.path.join(data_folder, "sample_tile_151_-29.tif"))
 
 
 @pytest.fixture
 def no_crs_gdal_path(data_folder):
     """Return the pathname of a GDAL file that doesn't contain a valid CRS."""
-    return str(os.path.join(data_folder, 'no_crs_ds.tif'))
+    return str(os.path.join(data_folder, "no_crs_ds.tif"))
 
 
 @pytest.fixture
 def data_folder():
     """Return a string path to the location `test/data`"""
-    return os.path.join(os.path.split(os.path.realpath(__file__))[0], 'data')
+    return os.path.join(os.path.split(os.path.realpath(__file__))[0], "data")
 
 
 @pytest.fixture
 def example_netcdf_path(request):
     """Return a string path to `sample_tile.nc` in the test data dir"""
-    return str(request.fspath.dirpath('data/sample_tile.nc'))
+    return str(request.fspath.dirpath("data/sample_tile.nc"))
 
 
 @pytest.fixture
 def eo3_metadata_file(data_folder):
-    return os.path.join(data_folder, 'eo3.yaml')
+    return os.path.join(data_folder, "eo3.yaml")
 
 
 @pytest.fixture
@@ -69,81 +71,182 @@ def eo3_metadata(eo3_metadata_file):
 @pytest.fixture
 def eo3_dataset_s2(eo3_metadata):
     ds_doc = {
-        '$schema': 'https://schemas.opendatacube.org/dataset',
-        'id': '8b0e2770-5d4e-5238-8995-4aa91691ab85',
-        'product': {'name': 's2b_msil2a'},
-        'label': 'S2B_MSIL2A_20200101T070219_N0213_R120_T39LVG_20200101T091825',
-
-        'crs': 'epsg:32739',
-        'grids': {'g20m': {'shape': [5490, 5490],
-                           'transform': [20, 0, 399960, 0, -20, 8700040, 0, 0, 1]},
-                  'g60m': {'shape': [1830, 1830],
-                           'transform': [60, 0, 399960, 0, -60, 8700040, 0, 0, 1]},
-                  'default': {'shape': [10980, 10980],
-                              'transform': [10, 0, 399960, 0, -10, 8700040, 0, 0, 1]}},
-        'geometry': {'type': 'Polygon',
-                     'coordinates': [[[509759.0000000001, 8590241.0],
-                                      [399960.99999999977, 8590241.0],
-                                      [399960.99999999977, 8700039.0],
-                                      [509758.99999999965, 8700039.0],
-                                      [509759.0000000001, 8590241.0]]]},
-        'properties': {'eo:gsd': 10,
-                       'datetime': '2020-01-01T07:02:54.188Z',
-                       'eo:platform': 'sentinel-2b',
-                       'eo:instrument': 'msi',
-                       'eo:cloud_cover': 0,
-                       'odc:file_format': 'GeoTIFF',
-                       'odc:region_code': '39LVG',
-                       'odc:processing_datetime': '2020-01-01T07:02:54.188Z'},
-
-        'measurements': {'red': {'path': 'B04.tif'},
-                         'scl': {'grid': 'g20m', 'path': 'SCL.tif'},
-                         'blue': {'path': 'B02.tif'},
-                         'green': {'path': 'B03.tif'},
-                         'nir_1': {'path': 'B08.tif'},
-                         'nir_2': {'grid': 'g20m', 'path': 'B8A.tif'},
-                         'swir_1': {'grid': 'g20m', 'path': 'B11.tif'},
-                         'swir_2': {'grid': 'g20m', 'path': 'B12.tif'},
-                         'red_edge_1': {'grid': 'g20m', 'path': 'B05.tif'},
-                         'red_edge_2': {'grid': 'g20m', 'path': 'B06.tif'},
-                         'red_edge_3': {'grid': 'g20m', 'path': 'B07.tif'},
-                         'water_vapour': {'grid': 'g60m', 'path': 'B09.tif'},
-                         'coastal_aerosol': {'grid': 'g60m', 'path': 'B01.tif'}},
-        'lineage': {}}
+        "$schema": "https://schemas.opendatacube.org/dataset",
+        "id": "8b0e2770-5d4e-5238-8995-4aa91691ab85",
+        "product": {"name": "s2b_msil2a"},
+        "label": "S2B_MSIL2A_20200101T070219_N0213_R120_T39LVG_20200101T091825",
+        "crs": "epsg:32739",
+        "grids": {
+            "g20m": {
+                "shape": [5490, 5490],
+                "transform": [20, 0, 399960, 0, -20, 8700040, 0, 0, 1],
+            },
+            "g60m": {
+                "shape": [1830, 1830],
+                "transform": [60, 0, 399960, 0, -60, 8700040, 0, 0, 1],
+            },
+            "default": {
+                "shape": [10980, 10980],
+                "transform": [10, 0, 399960, 0, -10, 8700040, 0, 0, 1],
+            },
+        },
+        "geometry": {
+            "type": "Polygon",
+            "coordinates": [
+                [
+                    [509759.0000000001, 8590241.0],
+                    [399960.99999999977, 8590241.0],
+                    [399960.99999999977, 8700039.0],
+                    [509758.99999999965, 8700039.0],
+                    [509759.0000000001, 8590241.0],
+                ]
+            ],
+        },
+        "properties": {
+            "eo:gsd": 10,
+            "datetime": "2020-01-01T07:02:54.188Z",
+            "eo:platform": "sentinel-2b",
+            "eo:instrument": "msi",
+            "eo:cloud_cover": 0,
+            "odc:file_format": "GeoTIFF",
+            "odc:region_code": "39LVG",
+            "odc:processing_datetime": "2020-01-01T07:02:54.188Z",
+        },
+        "measurements": {
+            "red": {"path": "B04.tif"},
+            "scl": {"grid": "g20m", "path": "SCL.tif"},
+            "blue": {"path": "B02.tif"},
+            "green": {"path": "B03.tif"},
+            "nir_1": {"path": "B08.tif"},
+            "nir_2": {"grid": "g20m", "path": "B8A.tif"},
+            "swir_1": {"grid": "g20m", "path": "B11.tif"},
+            "swir_2": {"grid": "g20m", "path": "B12.tif"},
+            "red_edge_1": {"grid": "g20m", "path": "B05.tif"},
+            "red_edge_2": {"grid": "g20m", "path": "B06.tif"},
+            "red_edge_3": {"grid": "g20m", "path": "B07.tif"},
+            "water_vapour": {"grid": "g60m", "path": "B09.tif"},
+            "coastal_aerosol": {"grid": "g60m", "path": "B01.tif"},
+        },
+        "lineage": {},
+    }
     product_doc = {
-        'name': 's2b_msil2a',
-        'description': 'Sentinel-2B Level 2 COGs',
-        'metadata_type': 'eo3',
-        'metadata': {'product': {'name': 's2b_msil2a'}},
-        'measurements':
-        [{'name': 'coastal_aerosol', 'dtype': 'uint16', 'units': '1', 'nodata': 0, 'aliases': ['band_01', 'B01']},
-         {'name': 'blue', 'dtype': 'uint16', 'units': '1', 'nodata': 0, 'aliases': ['band_02', 'B02']},
-         {'name': 'green', 'dtype': 'uint16', 'units': '1', 'nodata': 0, 'aliases': ['band_03', 'B03']},
-         {'name': 'red', 'dtype': 'uint16', 'units': '1', 'nodata': 0, 'aliases': ['band_04', 'B04']},
-         {'name': 'red_edge_1', 'dtype': 'uint16', 'units': '1', 'nodata': 0, 'aliases': ['band_05', 'B05']},
-         {'name': 'red_edge_2', 'dtype': 'uint16', 'units': '1', 'nodata': 0, 'aliases': ['band_06', 'B06']},
-         {'name': 'red_edge_3', 'dtype': 'uint16', 'units': '1', 'nodata': 0, 'aliases': ['band_07', 'B07']},
-         {'name': 'nir_1', 'dtype': 'uint16', 'units': '1', 'nodata': 0, 'aliases': ['band_08', 'B08']},
-         {'name': 'nir_2', 'dtype': 'uint16', 'units': '1', 'nodata': 0, 'aliases': ['band_8a', 'B8A']},
-         {'name': 'water_vapour', 'dtype': 'uint16', 'units': '1', 'nodata': 0, 'aliases': ['band_09', 'B09']},
-         {'name': 'swir_1', 'dtype': 'uint16', 'units': '1', 'nodata': 0, 'aliases': ['band_11', 'B11']},
-         {'name': 'swir_2', 'dtype': 'uint16', 'units': '1', 'nodata': 0, 'aliases': ['band_12', 'B12']},
-         {'name': 'scl', 'dtype': 'uint8', 'units': '1', 'nodata': 0, 'aliases': ['mask', 'qa'],
-          'flags_definition': {'sca': {'description': 'Sen2Cor Scene Classification',
-                                       'bits': [0, 1, 2, 3, 4, 5, 6, 7],
-                                       'values': {
-                                           '0': 'nodata',
-                                           '1': 'defective',
-                                           '2': 'dark',
-                                           '3': 'shadow',
-                                           '4': 'vegetation',
-                                           '5': 'bare',
-                                           '6': 'water',
-                                           '7': 'unclassified',
-                                           '8': 'cloud medium probability',
-                                           '9': 'cloud high probability',
-                                           '10': 'thin cirrus',
-                                           '11': 'snow or ice'}}}}]
+        "name": "s2b_msil2a",
+        "description": "Sentinel-2B Level 2 COGs",
+        "metadata_type": "eo3",
+        "metadata": {"product": {"name": "s2b_msil2a"}},
+        "measurements": [
+            {
+                "name": "coastal_aerosol",
+                "dtype": "uint16",
+                "units": "1",
+                "nodata": 0,
+                "aliases": ["band_01", "B01"],
+            },
+            {
+                "name": "blue",
+                "dtype": "uint16",
+                "units": "1",
+                "nodata": 0,
+                "aliases": ["band_02", "B02"],
+            },
+            {
+                "name": "green",
+                "dtype": "uint16",
+                "units": "1",
+                "nodata": 0,
+                "aliases": ["band_03", "B03"],
+            },
+            {
+                "name": "red",
+                "dtype": "uint16",
+                "units": "1",
+                "nodata": 0,
+                "aliases": ["band_04", "B04"],
+            },
+            {
+                "name": "red_edge_1",
+                "dtype": "uint16",
+                "units": "1",
+                "nodata": 0,
+                "aliases": ["band_05", "B05"],
+            },
+            {
+                "name": "red_edge_2",
+                "dtype": "uint16",
+                "units": "1",
+                "nodata": 0,
+                "aliases": ["band_06", "B06"],
+            },
+            {
+                "name": "red_edge_3",
+                "dtype": "uint16",
+                "units": "1",
+                "nodata": 0,
+                "aliases": ["band_07", "B07"],
+            },
+            {
+                "name": "nir_1",
+                "dtype": "uint16",
+                "units": "1",
+                "nodata": 0,
+                "aliases": ["band_08", "B08"],
+            },
+            {
+                "name": "nir_2",
+                "dtype": "uint16",
+                "units": "1",
+                "nodata": 0,
+                "aliases": ["band_8a", "B8A"],
+            },
+            {
+                "name": "water_vapour",
+                "dtype": "uint16",
+                "units": "1",
+                "nodata": 0,
+                "aliases": ["band_09", "B09"],
+            },
+            {
+                "name": "swir_1",
+                "dtype": "uint16",
+                "units": "1",
+                "nodata": 0,
+                "aliases": ["band_11", "B11"],
+            },
+            {
+                "name": "swir_2",
+                "dtype": "uint16",
+                "units": "1",
+                "nodata": 0,
+                "aliases": ["band_12", "B12"],
+            },
+            {
+                "name": "scl",
+                "dtype": "uint8",
+                "units": "1",
+                "nodata": 0,
+                "aliases": ["mask", "qa"],
+                "flags_definition": {
+                    "sca": {
+                        "description": "Sen2Cor Scene Classification",
+                        "bits": [0, 1, 2, 3, 4, 5, 6, 7],
+                        "values": {
+                            "0": "nodata",
+                            "1": "defective",
+                            "2": "dark",
+                            "3": "shadow",
+                            "4": "vegetation",
+                            "5": "bare",
+                            "6": "water",
+                            "7": "unclassified",
+                            "8": "cloud medium probability",
+                            "9": "cloud high probability",
+                            "10": "thin cirrus",
+                            "11": "snow or ice",
+                        },
+                    }
+                },
+            },
+        ],
     }
 
     return Dataset(DatasetType(eo3_metadata, product_doc), prep_eo3(ds_doc))
@@ -156,7 +259,7 @@ def eo3_dataset_s2(eo3_metadata):
 def tmpnetcdf_filename(tmpdir):
     """Return a generated filename for a non-existant netcdf file"""
     global netcdf_num
-    filename = str(tmpdir.join('testfile_np_%s.nc' % netcdf_num))
+    filename = str(tmpdir.join("testfile_np_%s.nc" % netcdf_num))
     netcdf_num += 1
     return filename
 
@@ -169,7 +272,14 @@ def odc_style_xr_dataset():
     affine = Affine.scale(0.1, 0.1) * Affine.translation(20, 30)
     geobox = geometry.GeoBox(100, 100, affine, geometry.CRS(GEO_PROJ))
 
-    return Datacube.create_storage({}, geobox, [Measurement(name='B10', dtype='int16', nodata=0, units='1')])
+    return Datacube.create_storage(
+        {}, geobox, [Measurement(name="B10", dtype="int16", nodata=0, units="1")]
+    )
+
+
+@pytest.fixture(scope="session")
+def workdir(tmpdir_factory):
+    return tmpdir_factory.mktemp("workdir")
 
 
 @pytest.fixture
@@ -178,17 +288,18 @@ def without_aws_env(monkeypatch):
         monkeypatch.delenv(e, raising=False)
 
 
-GEO_PROJ = 'GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],' \
-           'AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433],' \
-           'AUTHORITY["EPSG","4326"]]'
+GEO_PROJ = (
+    'GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],'
+    'AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433],'
+    'AUTHORITY["EPSG","4326"]]'
+)
 
 
 @pytest.fixture(scope="module")
 def dask_client():
     from distributed import Client
-    client = Client(processes=False,
-                    threads_per_worker=1,
-                    dashboard_address=None)
+
+    client = Client(processes=False, threads_per_worker=1, dashboard_address=None)
     yield client
     client.close()
     del client
diff --git a/tests/storage/test_netcdfwriter.py b/tests/storage/test_netcdfwriter.py
--- a/tests/storage/test_netcdfwriter.py
+++ b/tests/storage/test_netcdfwriter.py
@@ -9,6 +9,7 @@
 from hypothesis import given
 from hypothesis.strategies import text
 import string
+from uuid import uuid4
 
 from datacube.drivers.netcdf._write import _get_units
 from datacube.drivers.netcdf.writer import create_netcdf, create_coordinate, create_variable, netcdfy_data, \
@@ -180,7 +181,8 @@ def test_create_sinus_netcdf(tmpnetcdf_filename):
 @given(s1=text(alphabet=string.printable, max_size=100),
        s2=text(alphabet=string.printable, max_size=100),
        s3=text(alphabet=string.printable, max_size=100))
-def test_create_string_variable(tmpnetcdf_filename, s1, s2, s3):
+def test_create_string_variable(workdir, s1, s2, s3):
+    tmpnetcdf_filename = workdir.join(f"testfile_np_{uuid4()}.nc")
     str_var = 'str_var'
     nco = create_netcdf(tmpnetcdf_filename)
     coord = create_coordinate(nco, 'greg', numpy.array([1.0, 3.0, 9.0]), 'cubic gregs')
diff --git a/tests/test_utils_docs.py b/tests/test_utils_docs.py
--- a/tests/test_utils_docs.py
+++ b/tests/test_utils_docs.py
@@ -363,7 +363,7 @@ def visitor(node, name=None, depth=0, out=None):
     assert set(fv.keys()) == set('ABCDE')
     assert isinstance(dg, list)
     assert len(dg) == 4
-    assert [len(l) for l in dg] == [1, 3, 2, 1]
+    assert [len(dss) for dss in dg] == [1, 3, 2, 1]
 
     def to_set(xx):
         return set(x.id for x in xx)