diff --git a/CHANGES.md b/CHANGES.md index a0efe128..4c14f39a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,10 @@ # Release Notes +## 3.0.0b2 (TBD) + +* add `SQLite` backend (https://github.com/developmentseed/cogeo-mosaic/pull/148) +* fix cached responsed after updating a mosaic (https://github.com/developmentseed/cogeo-mosaic/pull/148/files#r557020660) + ## 3.0.0b1 (2020-12-18) * remove `overview` command (https://github.com/developmentseed/cogeo-mosaic/issues/71#issuecomment-748265645) diff --git a/README.md b/README.md index 61410291..4c859885 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ **Read the official announcement https://medium.com/devseed/cog-talk-part-2-mosaics-bbbf474e66df** -## Install (python >=3) +## Install ```bash $ pip install pip -U $ pip install cogeo-mosaic --pre @@ -52,7 +52,7 @@ $ pip install git+http://github.com/developmentseed/cogeo-mosaic - **pygeos** hosted on pypi migth not compile on certain machine. This has been fixed in the master branch and can be installed with `pip install git+https://github.com/pygeos/pygeos.git` -# See it in actions +## See it in actions - [**TiTiler**](http://github.com/developmentseed/titiler): A lightweight Cloud Optimized GeoTIFF dynamic tile server (COG, STAC and MosaicJSON). @@ -68,6 +68,8 @@ See [LICENSE](https://github.com/developmentseed/cogeo-mosaic/blob/master/LICENS Created by [Development Seed]() +See [contributors](https://github.com/developmentseed/cogeo-mosaic/graphs/contributors) for a listing of individual contributors. + ## Changes See [CHANGES.md](https://github.com/developmentseed/cogeo-mosaic/blob/master/CHANGES.md). diff --git a/cogeo_mosaic/backends/__init__.py b/cogeo_mosaic/backends/__init__.py index 055f9e8a..281e59a0 100644 --- a/cogeo_mosaic/backends/__init__.py +++ b/cogeo_mosaic/backends/__init__.py @@ -7,6 +7,7 @@ from cogeo_mosaic.backends.dynamodb import DynamoDBBackend from cogeo_mosaic.backends.file import FileBackend from cogeo_mosaic.backends.s3 import S3Backend +from cogeo_mosaic.backends.sqlite import SQLiteBackend from cogeo_mosaic.backends.stac import STACBackend from cogeo_mosaic.backends.web import HttpBackend @@ -25,6 +26,9 @@ def MosaicBackend(url: str, *args: Any, **kwargs: Any) -> BaseBackend: if parsed.scheme == "dynamodb": return DynamoDBBackend(url, *args, **kwargs) + if parsed.scheme == "sqlite": + return SQLiteBackend(url, *args, **kwargs) + if parsed.scheme in ["https", "http"]: return HttpBackend(url, *args, **kwargs) diff --git a/cogeo_mosaic/backends/base.py b/cogeo_mosaic/backends/base.py index c33ad159..01fbd748 100644 --- a/cogeo_mosaic/backends/base.py +++ b/cogeo_mosaic/backends/base.py @@ -101,7 +101,7 @@ def assets_for_point(self, lng: float, lat: float) -> List[str]: @cached( TTLCache(maxsize=cache_config.maxsize, ttl=cache_config.ttl), - key=lambda self, x, y, z: hashkey(self.path, x, y, z), + key=lambda self, x, y, z: hashkey(self.path, x, y, z, self.mosaicid), ) def get_assets(self, x: int, y: int, z: int) -> List[str]: """Find assets.""" @@ -196,7 +196,7 @@ def update( **kwargs, ): """Update existing MosaicJSON on backend.""" - new_mosaic = self.mosaic_def.from_features( + new_mosaic = MosaicJSON.from_features( features, self.mosaic_def.minzoom, self.mosaic_def.maxzoom, diff --git a/cogeo_mosaic/backends/dynamodb.py b/cogeo_mosaic/backends/dynamodb.py index 1a9026d9..83ab7752 100644 --- a/cogeo_mosaic/backends/dynamodb.py +++ b/cogeo_mosaic/backends/dynamodb.py @@ -13,7 +13,6 @@ import attr import click import mercantile -from botocore.exceptions import ClientError from cachetools import TTLCache, cached from cachetools.keys import hashkey @@ -33,9 +32,11 @@ try: import boto3 from boto3.dynamodb.conditions import Key + from botocore.exceptions import ClientError except ImportError: # pragma: nocover boto3 = None # type: ignore Key = None # type: ignore + ClientError = None # type: ignore @attr.s @@ -151,7 +152,7 @@ def update( """Update existing MosaicJSON on backend.""" logger.debug(f"Updating {self.mosaic_name}...") - new_mosaic = self.mosaic_def.from_features( + new_mosaic = MosaicJSON.from_features( features, self.mosaic_def.minzoom, self.mosaic_def.maxzoom, @@ -284,7 +285,7 @@ def _read(self) -> MosaicJSON: # type: ignore @cached( TTLCache(maxsize=cache_config.maxsize, ttl=cache_config.ttl), - key=lambda self, x, y, z: hashkey(self.path, x, y, z), + key=lambda self, x, y, z: hashkey(self.path, x, y, z, self.mosaicid), ) def get_assets(self, x: int, y: int, z: int) -> List[str]: """Find assets.""" diff --git a/cogeo_mosaic/backends/sqlite.py b/cogeo_mosaic/backends/sqlite.py new file mode 100644 index 00000000..98eedfa2 --- /dev/null +++ b/cogeo_mosaic/backends/sqlite.py @@ -0,0 +1,366 @@ +"""cogeo-mosaic SQLite backend.""" + +import itertools +import json +import re +import sqlite3 +import warnings +from pathlib import Path +from typing import Dict, List, Sequence +from urllib.parse import urlparse + +import attr +import mercantile +from cachetools import TTLCache, cached +from cachetools.keys import hashkey + +from cogeo_mosaic.backends.base import BaseBackend +from cogeo_mosaic.backends.utils import find_quadkeys +from cogeo_mosaic.cache import cache_config +from cogeo_mosaic.errors import MosaicExistsError, MosaicNotFoundError +from cogeo_mosaic.logger import logger +from cogeo_mosaic.mosaic import MosaicJSON +from cogeo_mosaic.utils import bbox_union + +sqlite3.register_adapter(dict, json.dumps) +sqlite3.register_adapter(tuple, json.dumps) +sqlite3.register_adapter(list, json.dumps) +sqlite3.register_converter("JSON", json.loads) + + +@attr.s +class SQLiteBackend(BaseBackend): + """SQLite Backend Adapter.""" + + db_path: str = attr.ib(init=False) + mosaic_name: str = attr.ib(init=False) + db: sqlite3.Connection = attr.ib(init=False) + + _backend_name = "SQLite" + _metadata_table: str = "mosaicjson_metadata" + + def __attrs_post_init__(self): + """Post Init: parse path connect to Table. + + A path looks like + + sqlite:///{db_path}:{mosaic_name} + + """ + if not re.match(r"^sqlite:///.+\:[a-zA-Z0-9\_\-\.]+$", self.path,): + raise ValueError(f"Invalid SQLite path: {self.path}") + + parsed = urlparse(self.path) + uri_path = parsed.path[1:] # remove `/` on the left + + self.mosaic_name = uri_path.split(":")[-1] + assert ( + not self.mosaic_name == self._metadata_table + ), f"'{self._metadata_table}' is a reserved table name." + + self.db_path = uri_path.replace(f":{self.mosaic_name}", "") + + # When mosaic_def is not passed, we have to make sure the db exists + if not self.mosaic_def and not Path(self.db_path).exists(): + raise MosaicNotFoundError( + f"SQLite database not found at path {self.db_path}." + ) + + self.db = sqlite3.connect(self.db_path, detect_types=sqlite3.PARSE_DECLTYPES) + self.db.row_factory = sqlite3.Row + + # Here we make sure the mosaicJSON.name is the same + if self.mosaic_def and self.mosaic_def.name != self.mosaic_name: + warnings.warn("Updating 'mosaic.name' to match table name.") + self.mosaic_def.name = self.mosaic_name + + logger.debug(f"Using SQLite backend: {self.db_path}") + super().__attrs_post_init__() + + def close(self): + """Close SQLite connection.""" + self.db.close() + + def __exit__(self, exc_type, exc_value, traceback): + """Support using with Context Managers.""" + self.close() + + @property + def _quadkeys(self) -> List[str]: + """Return the list of quadkey tiles.""" + with self.db: + rows = self.db.execute( + f'SELECT quadkey FROM "{self.mosaic_name}";', + ).fetchall() + return [r["quadkey"] for r in rows] + + def write(self, overwrite: bool = False): + """Write mosaicjson document to an SQLite database. + + Args: + overwrite (bool): delete old mosaic items in the Table. + + Returns: + dict: dictionary with metadata constructed from the sceneid. + + Raises: + MosaicExistsError: If mosaic already exists in the Table. + + """ + if self._mosaic_exists(): + if not overwrite: + raise MosaicExistsError( + f"'{self.mosaic_name}' Table already exists in {self.db_path}, use `overwrite=True`." + ) + self.delete() + + logger.debug(f"Creating '{self.mosaic_name}' Table in {self.db_path}.") + with self.db: + self.db.execute( + f""" + CREATE TABLE IF NOT EXISTS {self._metadata_table} + ( + mosaicjson TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + version TEXT NOT NULL, + attribution TEXT, + minzoom INTEGER NOT NULL, + maxzoom INTEGER NOT NULL, + quadkey_zoom INTEGER, + bounds JSON NOT NULL, + center JSON + ); + """ + ) + self.db.execute( + f""" + CREATE TABLE "{self.mosaic_name}" + ( + quadkey TEXT NOT NULL, + assets JSON NOT NULL + ); + """ + ) + + logger.debug(f"Adding items in '{self.mosaic_name}' Table.") + with self.db: + self.db.execute( + f""" + INSERT INTO {self._metadata_table} + ( + mosaicjson, + name, + description, + version, + attribution, + minzoom, + maxzoom, + quadkey_zoom, + bounds, + center + ) + VALUES + ( + :mosaicjson, + :name, + :description, + :version, + :attribution, + :minzoom, + :maxzoom, + :quadkey_zoom, + :bounds, + :center + ); + """, + self.metadata.dict(), + ) + + self.db.executemany( + f'INSERT INTO "{self.mosaic_name}" (quadkey, assets) VALUES (?, ?);', + self.mosaic_def.tiles.items(), + ) + + def update( + self, + features: Sequence[Dict], + add_first: bool = True, + quiet: bool = False, + **kwargs, + ): + """Update existing MosaicJSON on backend.""" + logger.debug(f"Updating {self.mosaic_name}...") + + new_mosaic = MosaicJSON.from_features( + features, + self.mosaic_def.minzoom, + self.mosaic_def.maxzoom, + quadkey_zoom=self.quadkey_zoom, + quiet=quiet, + **kwargs, + ) + + bounds = bbox_union(new_mosaic.bounds, self.mosaic_def.bounds) + + self.mosaic_def._increase_version() + self.mosaic_def.bounds = bounds + self.mosaic_def.center = ( + (bounds[0] + bounds[2]) / 2, + (bounds[1] + bounds[3]) / 2, + self.mosaic_def.minzoom, + ) + + with self.db: + self.db.execute( + f""" + UPDATE {self._metadata_table} + SET mosaicjson = :mosaicjson, + name = :name, + description = :description, + version = :version, + attribution = :attribution, + minzoom = :minzoom, + maxzoom = :maxzoom, + quadkey_zoom = :quadkey_zoom, + bounds = :bounds, + center = :center + WHERE name=:name + """, + self.mosaic_def.dict(), + ) + + if add_first: + self.db.executemany( + f""" + UPDATE "{self.mosaic_name}" + SET assets = ( + SELECT json_group_array(value) + FROM ( + SELECT value FROM json_each(?) + UNION ALL + SELECT value FROM json_each(assets) + ) + ) + WHERE quadkey=?; + """, + [(assets, qk) for qk, assets in new_mosaic.tiles.items()], + ) + + else: + self.db.executemany( + f""" + UPDATE "{self.mosaic_name}" + SET assets = ( + SELECT json_group_array(value) + FROM ( + SELECT value FROM json_each(assets) + UNION ALL + SELECT value FROM json_each(?) + ) + ) + WHERE quadkey=?; + """, + [(assets, qk) for qk, assets in new_mosaic.tiles.items()], + ) + + @cached( + TTLCache(maxsize=cache_config.maxsize, ttl=cache_config.ttl), + key=lambda self: hashkey(self.path), + ) + def _read(self) -> MosaicJSON: # type: ignore + """Get Mosaic definition info.""" + meta = self._fetch_metadata() + if not meta: + raise MosaicNotFoundError(f"Mosaic not found in {self.path}") + + meta["tiles"] = {} + return MosaicJSON(**meta) + + @cached( + TTLCache(maxsize=cache_config.maxsize, ttl=cache_config.ttl), + key=lambda self, x, y, z: hashkey(self.path, x, y, z, self.mosaicid), + ) + def get_assets(self, x: int, y: int, z: int) -> List[str]: + """Find assets.""" + mercator_tile = mercantile.Tile(x=x, y=y, z=z) + quadkeys = find_quadkeys(mercator_tile, self.quadkey_zoom) + return list(itertools.chain.from_iterable([self._fetch(qk) for qk in quadkeys])) + + def _fetch_metadata(self) -> Dict: + with self.db: + row = self.db.execute( + f"SELECT * FROM {self._metadata_table} WHERE name=?;", + (self.mosaic_name,), + ).fetchone() + return dict(row) if row else {} + + def _fetch(self, quadkey: str) -> List: + with self.db: + row = self.db.execute( + f'SELECT assets FROM "{self.mosaic_name}" WHERE quadkey=?;', (quadkey,) + ).fetchone() + return row["assets"] if row else [] + + def _mosaic_exists(self) -> bool: + """Check if the mosaic Table already exists.""" + with self.db: + count = self.db.execute( + "SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?;", + (self.mosaic_name,), + ).fetchone() + return count[0] == 1 + + def delete(self): + """Delete a mosaic.""" + logger.debug( + f"Deleting all items for '{self.mosaic_name}' mosaic in {self.db_path}..." + ) + with self.db: + self.db.execute( + f"DELETE FROM {self._metadata_table} WHERE name=?;", (self.mosaic_name,) + ) + self.db.execute(f'DROP TABLE IF EXISTS "{self.mosaic_name}";') + + @classmethod + def list_mosaics_in_db(cls, db_path: str,) -> List[str]: + """List Mosaic tables in SQLite database. + + Args: + db_path (str): SQLite file. + + Returns: + list: list of mosaic names in database. + + Raises: + ValueError: if file does NOT exists. + + Examples: + >>> SQLiteBackend.list_mosaics_in_db("mosaics.db") + ["test"] + + """ + parsed = urlparse(db_path) + if parsed.scheme: + db_path = parsed.path[1:] # remove `/` on the left + + if not Path(db_path).exists(): + raise ValueError(f"SQLite database not found at path '{db_path}'.") + + db = sqlite3.connect(db_path) + db.row_factory = sqlite3.Row + with db: + rows = db.execute(f"SELECT name FROM {cls._metadata_table};").fetchall() + rows_table = db.execute( + "SELECT name FROM sqlite_master WHERE type='table';", + ).fetchall() + db.close() + + names_in_metadata = [r["name"] for r in rows] + all_tables = [r["name"] for r in rows_table] + + for name in names_in_metadata: + if name not in all_tables: + warnings.warn(f"'{name}' found in metadata, but table does not exists.") + + return [r for r in names_in_metadata if r in all_tables] diff --git a/docs/API/backends/SQLite.md b/docs/API/backends/SQLite.md new file mode 100644 index 00000000..770ffe60 --- /dev/null +++ b/docs/API/backends/SQLite.md @@ -0,0 +1,2 @@ + +![mkapi](cogeo_mosaic.backends.sqlite.SQLiteBackend|strict) diff --git a/docs/advanced/backends.md b/docs/advanced/backends.md index c4fba5d6..0eb6239a 100644 --- a/docs/advanced/backends.md +++ b/docs/advanced/backends.md @@ -10,6 +10,10 @@ Starting in version `3.0.0`, we introduced specific backend storage for: - **AWS DynamoDB** (`dynamodb://{region}/{table_name}`). If `region` is not passed, it reads the value of the `AWS_REGION` environment variable. If that environment variable does not exist, it falls back to `us-east-1`. If you choose not to pass a `region`, you still need three `/` before the table name, like so `dynamodb:///{table_name}`. +- **SQLite** (`sqlite:///{file.db}:{mosaic_name}`) + +#### Read Only Backend + - **STAC** (`stac+:https://`). Based on [SpatioTemporal Asset Catalog](https://github.com/radiantearth/stac-spec) API. To ease the usage we added a helper function to use the right backend based on the uri schema: `cogeo_mosaic.backends.MosaicBackend` @@ -26,20 +30,23 @@ with MosaicBackend("https://mosaic.com/amosaic.json.gz") as mosaic: with MosaicBackend("dynamodb://us-east-1/amosaic") as mosaic: assert isinstance(mosaic, cogeo_mosaic.backends.dynamodb.DynamoDBBackend) +with MosaicBackend("sqlite:///mosaic.db:amosaic") as mosaic: + assert isinstance(mosaic, cogeo_mosaic.backends.sqlite.SQLiteBackend) + with MosaicBackend("file:///amosaic.json.gz") as mosaic: assert isinstance(mosaic, cogeo_mosaic.backends.file.FileBackend) -# Create only -with MosaicBackend("stac+https://my-stac.api/search", {"collections": ["satellite"]}, 10, 12) as mosaic: - assert isinstance(mosaic, cogeo_mosaic.backends.stac.STACBackend) - with MosaicBackend("amosaic.json.gz") as mosaic: assert isinstance(mosaic, cogeo_mosaic.backends.file.FileBackend) + +# Read-Only +with MosaicBackend("stac+https://my-stac.api/search", {"collections": ["satellite"]}, 10, 12) as mosaic: + assert isinstance(mosaic, cogeo_mosaic.backends.stac.STACBackend) ``` ## STAC Backend -The STACBackend is purely dynamic, meaning it's not used to read or write a file. This backend will POST to the input url looking for STAC items which will then be used to create the mosaicJSON. +The STACBackend is a **read-only** backend, meaning it can't be used to write a file. This backend will POST to the input url looking for STAC items which will then be used to create the mosaicJSON in memory. ```python import datetime diff --git a/mkdocs.yml b/mkdocs.yml index 3ebb37a3..0b66be76 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -34,6 +34,7 @@ nav: - HTTP: "API/backends/HTTP.md" - s3: "API/backends/S3.md" - DynamoDB: "API/backends/DynamoDB.md" + - SQLite: "API/backends/SQLite.md" - STAC: "API/backends/STAC.md" - Migration to v3.0: "v3_migration.md" - Development - Contributing: "contributing.md" diff --git a/tests/fixtures/mosaics.db b/tests/fixtures/mosaics.db new file mode 100644 index 00000000..3f0c73fc Binary files /dev/null and b/tests/fixtures/mosaics.db differ diff --git a/tests/test_backends.py b/tests/test_backends.py index 69a2dfcd..44e0f5e7 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -19,15 +19,23 @@ from cogeo_mosaic.backends.dynamodb import DynamoDBBackend from cogeo_mosaic.backends.file import FileBackend from cogeo_mosaic.backends.s3 import S3Backend +from cogeo_mosaic.backends.sqlite import SQLiteBackend from cogeo_mosaic.backends.stac import STACBackend from cogeo_mosaic.backends.stac import _fetch as stac_search from cogeo_mosaic.backends.stac import default_stac_accessor as stac_accessor from cogeo_mosaic.backends.utils import _decompress_gz from cogeo_mosaic.backends.web import HttpBackend -from cogeo_mosaic.errors import MosaicError, MosaicExistsError, NoAssetFoundError +from cogeo_mosaic.errors import ( + MosaicError, + MosaicExistsError, + MosaicNotFoundError, + NoAssetFoundError, +) from cogeo_mosaic.mosaic import MosaicJSON +from cogeo_mosaic.utils import get_footprints mosaic_gz = os.path.join(os.path.dirname(__file__), "fixtures", "mosaic.json.gz") +mosaic_db = os.path.join(os.path.dirname(__file__), "fixtures", "mosaics.db") mosaic_bin = os.path.join(os.path.dirname(__file__), "fixtures", "mosaic.bin") mosaic_json = os.path.join(os.path.dirname(__file__), "fixtures", "mosaic.json") mosaic_jsonV1 = os.path.join(os.path.dirname(__file__), "fixtures", "mosaic_0.0.1.json") @@ -670,3 +678,138 @@ def test_BaseReader(): ] assert mosaic.spatial_info + + +def test_sqlite_backend(): + """Test sqlite backend.""" + with MosaicBackend(f"sqlite:///{mosaic_db}:test") as mosaic: + assert mosaic._backend_name == "SQLite" + assert isinstance(mosaic, SQLiteBackend) + assert ( + mosaic.mosaicid + == "f7fc24d47a79f1496dcdf9997de83e6305c252a931fba2c7d006b7d8" + ) + assert mosaic.quadkey_zoom == 7 + + info = mosaic.info() + assert not info["quadkeys"] + assert list(info.dict()) == [ + "bounds", + "center", + "minzoom", + "maxzoom", + "name", + "quadkeys", + ] + + info = mosaic.info(quadkeys=True) + assert info["quadkeys"] + + assert list(mosaic.metadata.dict(exclude_none=True).keys()) == [ + "mosaicjson", + "name", + "version", + "minzoom", + "maxzoom", + "quadkey_zoom", + "bounds", + "center", + ] + assert mosaic.assets_for_tile(150, 182, 9) == ["cog1.tif", "cog2.tif"] + assert mosaic.assets_for_point(-73, 45) == ["cog1.tif", "cog2.tif"] + + assert len(mosaic.get_assets(150, 182, 9)) == 2 + assert len(mosaic.get_assets(147, 182, 12)) == 0 + + # Validation error, mosaic_def is empty + with pytest.raises(ValidationError): + with MosaicBackend("sqlite:///:memory::test", mosaic_def={}): + pass + + # invalid scheme `sqlit://` + with pytest.raises(ValueError): + with SQLiteBackend("sqlit:///:memory::test"): + pass + + # `:` is an invalid character for mosaic name + with pytest.raises(ValueError): + with SQLiteBackend("sqlite:///:memory::test:"): + pass + + # `mosaicjson_metadata` is a reserved mosaic name + with pytest.raises(AssertionError): + with MosaicBackend("sqlite:///:memory::mosaicjson_metadata"): + pass + + # Warns when changing name + with pytest.warns(UserWarning): + with MosaicBackend(mosaic_gz) as m: + with MosaicBackend("sqlite:///:memory::test", mosaic_def=m.mosaic_def) as d: + assert d.mosaic_def.name == "test" + + # need to set overwrite when mosaic already exists + with MosaicBackend("sqlite:///:memory::test", mosaic_def=mosaic_content) as mosaic: + mosaic.write() + with pytest.raises(MosaicExistsError): + mosaic.write() + mosaic.write(overwrite=True) + + # files doesn't exists + with pytest.raises(MosaicNotFoundError): + with MosaicBackend("sqlite:///test.db:test2") as mosaic: + pass + + # mosaic doesn't exists in DB + with pytest.raises(MosaicNotFoundError): + with MosaicBackend(f"sqlite:///{mosaic_db}:test2") as mosaic: + pass + + # Test with `.` in mosaic name + with pytest.warns(UserWarning): + with MosaicBackend( + "sqlite:///:memory::test.mosaic", mosaic_def=mosaic_content + ) as m: + m.write() + assert m._mosaic_exists() + assert m.mosaic_def.name == "test.mosaic" + + m.delete() + assert not m._mosaic_exists() + assert not m._fetch_metadata() + + mosaic_oneasset = MosaicJSON.from_urls([asset1], quiet=True) + features = get_footprints([asset2], quiet=True) + + # Test update methods + with MosaicBackend("sqlite:///:memory::test", mosaic_def=mosaic_oneasset) as m: + m.write() + meta = m.metadata + assert len(m.get_assets(150, 182, 9)) == 1 + + m.update(features) + assert not m.metadata == meta + + assets = m.get_assets(150, 182, 9) + assert len(assets) == 2 + assert assets[0] == asset2 + assert assets[1] == asset1 + + # Test update with `add_first=False` + with MosaicBackend("sqlite:///:memory::test2", mosaic_def=mosaic_oneasset) as m: + m.write() + meta = m.metadata + assert len(m.get_assets(150, 182, 9)) == 1 + + m.update(features, add_first=False) + assert not m.metadata == meta + + assets = m.get_assets(150, 182, 9) + assert len(assets) == 2 + assert assets[0] == asset1 + assert assets[1] == asset2 + + assert SQLiteBackend.list_mosaics_in_db(mosaic_db) == ["test"] + assert SQLiteBackend.list_mosaics_in_db(f"sqlite:///{mosaic_db}") == ["test"] + + with pytest.raises(ValueError): + assert SQLiteBackend.list_mosaics_in_db("test.db")