diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5773d65..22ee3c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9'] + python-version: ['3.7', '3.8', '3.9', '3.10'] steps: - uses: actions/checkout@v2 diff --git a/CHANGES.md b/CHANGES.md index 96bc190..75f1916 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -9,6 +9,10 @@ * add `CacheControlMiddleware` middleware * enable more options to be forwarded to the `asyncpg` pool creation * add `PG_SCHEMAS` and `PG_TABLES` environment variable to specify Postgres schemas and tables +* add `TIMVT_FUNCTIONS_DIRECTORY` environment variable to look for function SQL files +* switch viewer to Maplibre +* add `Point` and `LineString` feature support in viewer +* Update dockerfiles to python3.10 and postgres14-postgis3.2 **breaking changes** diff --git a/dockerfiles/Dockerfile b/dockerfiles/Dockerfile index ed56082..ed1f9a6 100644 --- a/dockerfiles/Dockerfile +++ b/dockerfiles/Dockerfile @@ -1,4 +1,4 @@ -ARG PYTHON_VERSION=3.9 +ARG PYTHON_VERSION=3.10 FROM ghcr.io/vincentsarago/uvicorn-gunicorn:${PYTHON_VERSION} diff --git a/dockerfiles/Dockerfile.db b/dockerfiles/Dockerfile.db index 94dfd5b..af5d475 100644 --- a/dockerfiles/Dockerfile.db +++ b/dockerfiles/Dockerfile.db @@ -1,3 +1,3 @@ -FROM ghcr.io/vincentsarago/postgis:13-3.1 +FROM ghcr.io/vincentsarago/postgis:14-3.2 COPY data/*.sql /docker-entrypoint-initdb.d/ diff --git a/pyproject.toml b/pyproject.toml index b89d559..f88985c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ classifiers = [ "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Topic :: Scientific/Engineering :: GIS", ] dynamic = ["version"] diff --git a/tests/conftest.py b/tests/conftest.py index 899b5db..bc57b94 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,7 +23,7 @@ def database_url(test_db): a database url which we pass to our application through a monkeypatched environment variable. """ assert test_db.install_extension("postgis") - test_db.run_sql_file(os.path.join(DATA_DIR, "landsat_wrs.sql")) + test_db.run_sql_file(os.path.join(DATA_DIR, "data", "landsat_wrs.sql")) assert test_db.has_table("landsat_wrs") return test_db.connection.engine.url @@ -34,18 +34,11 @@ def app(database_url, monkeypatch): monkeypatch.setenv("DATABASE_URL", str(database_url)) monkeypatch.setenv("TIMVT_DEFAULT_MINZOOM", str(5)) monkeypatch.setenv("TIMVT_DEFAULT_MAXZOOM", str(12)) + monkeypatch.setenv("TIMVT_FUNCTIONS_DIRECTORY", DATA_DIR) from timvt.layer import Function from timvt.main import app - # Register Function to the internal registery - app.state.timvt_function_catalog.register( - Function.from_file( - id="squares", - infile=os.path.join(DATA_DIR, "squares.sql"), - ) - ) - # Register the same function but we different options app.state.timvt_function_catalog.register( Function.from_file( diff --git a/tests/fixtures/landsat_wrs.sql b/tests/fixtures/data/landsat_wrs.sql similarity index 100% rename from tests/fixtures/landsat_wrs.sql rename to tests/fixtures/data/landsat_wrs.sql diff --git a/tests/fixtures/landsat_poly_centroid.sql b/tests/fixtures/landsat_poly_centroid.sql new file mode 100644 index 0000000..64b4ab7 --- /dev/null +++ b/tests/fixtures/landsat_poly_centroid.sql @@ -0,0 +1,50 @@ +CREATE OR REPLACE FUNCTION landsat_poly_centroid( + -- mandatory parameters + xmin float, + ymin float, + xmax float, + ymax float, + epsg integer, + -- additional parameters + query_params json +) +RETURNS bytea +AS $$ +DECLARE + bounds geometry; + tablename text; + result bytea; +BEGIN + WITH + -- Create bbox enveloppe in given EPSG + bounds AS ( + SELECT ST_MakeEnvelope(xmin, ymin, xmax, ymax, epsg) AS geom + ), + selected_geom AS ( + SELECT t.* + FROM public.landsat_wrs t, bounds + WHERE ST_Intersects(t.geom, ST_Transform(bounds.geom, 4326)) + ), + mvtgeom AS ( + SELECT + ST_AsMVTGeom(ST_Transform(ST_Centroid(t.geom), epsg), bounds.geom) AS geom, t.path, t.row + FROM selected_geom t, bounds + UNION + SELECT ST_AsMVTGeom(ST_Transform(t.geom, epsg), bounds.geom) AS geom, t.path, t.row + FROM selected_geom t, bounds + ) + SELECT ST_AsMVT(mvtgeom.*, 'default') + + -- Put the query result into the result variale. + INTO result FROM mvtgeom; + + -- Return the answer + RETURN result; +END; +$$ +LANGUAGE 'plpgsql' +IMMUTABLE -- Same inputs always give same outputs +STRICT -- Null input gets null output +PARALLEL SAFE; + +COMMENT ON FUNCTION landsat_poly_centroid IS 'Return Combined Polygon/Centroid geometries from landsat table.'; diff --git a/tests/routes/test_metadata.py b/tests/routes/test_metadata.py index 57a23e2..2d93307 100644 --- a/tests/routes/test_metadata.py +++ b/tests/routes/test_metadata.py @@ -34,18 +34,28 @@ def test_function_index(app): response = app.get("/functions.json") assert response.status_code == 200 body = response.json() - assert len(body) == 2 - assert body[0]["id"] == "squares" - assert body[0]["function_name"] == "squares" - assert body[0]["bounds"] - assert body[0]["tileurl"] - assert "options" not in body[0] + assert len(body) == 3 + + func = list(filter(lambda x: x["id"] == "landsat_poly_centroid", body))[0] + assert func["id"] == "landsat_poly_centroid" + assert func["function_name"] == "landsat_poly_centroid" + assert func["bounds"] + assert func["tileurl"] + assert "options" not in func + + func = list(filter(lambda x: x["id"] == "squares", body))[0] + assert func["id"] == "squares" + assert func["function_name"] == "squares" + assert func["bounds"] + assert func["tileurl"] + assert "options" not in func - assert body[1]["id"] == "squares2" - assert body[0]["function_name"] == "squares" - assert body[1]["bounds"] == [0.0, 0.0, 180.0, 90.0] - assert body[1]["tileurl"] - assert body[1]["options"] == [{"name": "depth", "default": 2}] + func = list(filter(lambda x: x["id"] == "squares2", body))[0] + assert func["id"] == "squares2" + assert func["function_name"] == "squares" + assert func["bounds"] == [0.0, 0.0, 180.0, 90.0] + assert func["tileurl"] + assert func["options"] == [{"name": "depth", "default": 2}] def test_function_info(app): diff --git a/timvt/main.py b/timvt/main.py index 0d778bd..1e1d999 100644 --- a/timvt/main.py +++ b/timvt/main.py @@ -1,10 +1,12 @@ -"""TiVTiler app.""" +"""TiMVT application.""" + +import pathlib from timvt import __version__ as timvt_version from timvt.db import close_db_connection, connect_to_db, register_table_catalog from timvt.errors import DEFAULT_STATUS_CODES, add_exception_handlers from timvt.factory import TMSFactory, VectorTilerFactory -from timvt.layer import FunctionRegistry +from timvt.layer import Function, FunctionRegistry from timvt.middleware import CacheControlMiddleware from timvt.settings import ApiSettings, PostgresSettings @@ -50,6 +52,15 @@ # We add the function registry to the application state app.state.timvt_function_catalog = FunctionRegistry() +if settings.functions_directory: + functions = pathlib.Path(settings.functions_directory).glob("*.sql") + for func in functions: + name = func.name + if name.endswith(".sql"): + name = name[:-4] + app.state.timvt_function_catalog.register( + Function.from_file(id=name, infile=str(func)) + ) # Register Start/Stop application event handler to setup/stop the database connection diff --git a/timvt/settings.py b/timvt/settings.py index f409676..cf2feb6 100644 --- a/timvt/settings.py +++ b/timvt/settings.py @@ -18,6 +18,7 @@ class _ApiSettings(pydantic.BaseSettings): cors_origins: str = "*" cachecontrol: str = "public, max-age=3600" debug: bool = False + functions_directory: Optional[str] @pydantic.validator("cors_origins") def parse_cors_origin(cls, v): diff --git a/timvt/templates/viewer.html b/timvt/templates/viewer.html index 54d2013..90b9a98 100644 --- a/timvt/templates/viewer.html +++ b/timvt/templates/viewer.html @@ -3,14 +3,11 @@ - Ti VTiler + TiMVT - - - - - + +