diff --git a/pyproject.toml b/pyproject.toml index 440bde7..c23d7a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,10 +3,10 @@ name = "timvt" description = "A lightweight PostGIS based dynamic vector tile server." readme = "README.md" requires-python = ">=3.8" -license = {file = "LICENSE"} +license = { file = "LICENSE" } authors = [ - {name = "Vincent Sarago", email = "vincent@developmentseed.org"}, - {name = "David Bitner", email = "david@developmentseed.org"}, + { name = "Vincent Sarago", email = "vincent@developmentseed.org" }, + { name = "David Bitner", email = "david@developmentseed.org" }, ] keywords = ["FastAPI", "MVT", "POSTGIS"] classifiers = [ @@ -26,9 +26,10 @@ dependencies = [ "buildpg>=0.3", "fastapi>=0.87", "jinja2>=2.11.2,<4.0.0", - "morecantile>=3.1,<4.0", + "morecantile>=5.0,<6.0", "starlette-cramjam>=0.3,<0.4", "importlib_resources>=1.1.0; python_version < '3.9'", + "pydantic-settings>=2.0.3", "typing_extensions; python_version < '3.9.2'", ] @@ -46,12 +47,8 @@ test = [ "numpy", "sqlalchemy>=1.1,<1.4", ] -dev = [ - "pre-commit", -] -server = [ - "uvicorn[standard]>=0.12.0,<0.19.0", -] +dev = ["pre-commit"] +server = ["uvicorn[standard]>=0.12.0,<0.19.0"] docs = [ "nbconvert", "mkdocs", @@ -71,22 +68,22 @@ path = "timvt/__init__.py" [tool.hatch.build.targets.sdist] exclude = [ - "/tests", - "/dockerfiles", - "/docs", - "/demo", - "/data", - "docker-compose.yml", - "CONTRIBUTING.md", - "CHANGES.md", - ".pytest_cache", - ".history", - ".github", - ".env.example", - ".bumpversion.cfg", - ".flake8", - ".gitignore", - ".pre-commit-config.yaml", + "/tests", + "/dockerfiles", + "/docs", + "/demo", + "/data", + "docker-compose.yml", + "CONTRIBUTING.md", + "CHANGES.md", + ".pytest_cache", + ".history", + ".github", + ".env.example", + ".bumpversion.cfg", + ".flake8", + ".gitignore", + ".pre-commit-config.yaml", ] [build-system] @@ -96,13 +93,8 @@ build-backend = "hatchling.build" [tool.isort] profile = "black" known_first_party = ["timvt"] -known_third_party = [ - "morecantile", -] -forced_separate = [ - "fastapi", - "starlette", -] +known_third_party = ["morecantile"] +forced_separate = ["fastapi", "starlette"] default_section = "THIRDPARTY" [tool.mypy] diff --git a/timvt/__init__.py b/timvt/__init__.py index fadae60..72f9889 100644 --- a/timvt/__init__.py +++ b/timvt/__init__.py @@ -1,3 +1,3 @@ """timvt.""" -__version__ = "0.8.0a3" +__version__ = "0.8.0a4" diff --git a/timvt/db.py b/timvt/db.py index 3c7f086..bf9d96c 100644 --- a/timvt/db.py +++ b/timvt/db.py @@ -1,15 +1,14 @@ """timvt.db: database events.""" +from os import getenv from typing import Any, Optional import orjson from buildpg import asyncpg - +from fastapi import FastAPI from timvt.dbmodel import get_table_index from timvt.settings import PostgresSettings -from fastapi import FastAPI - async def con_init(conn): """Use json for json returns.""" diff --git a/timvt/dbmodel.py b/timvt/dbmodel.py index 686452f..8d4ff8c 100644 --- a/timvt/dbmodel.py +++ b/timvt/dbmodel.py @@ -4,7 +4,6 @@ from buildpg import asyncpg from pydantic import BaseModel, Field - from timvt.settings import TableSettings @@ -237,7 +236,9 @@ async def get_table_index( jsonb_build_object( 'name', attname, 'type', "type", - 'description', description + 'description', description, + 'min', NULL, + 'max', NULL ) ) FILTER (WHERE type LIKE 'timestamp%'), '[]'::jsonb) as datetime_columns, coalesce(jsonb_agg( diff --git a/timvt/factory.py b/timvt/factory.py index bca0015..ec9296e 100644 --- a/timvt/factory.py +++ b/timvt/factory.py @@ -4,23 +4,21 @@ from typing import Any, Callable, Dict, List, Literal, Optional from urllib.parse import urlencode +from fastapi import APIRouter, Depends, Path, Query +from fastapi.params import Param from morecantile import Tile, TileMatrixSet from morecantile import tms as morecantile_tms from morecantile.defaults import TileMatrixSets - -from timvt.dependencies import LayerParams, TileParams -from timvt.layer import Function, Layer, Table -from timvt.models.mapbox import TileJSON -from timvt.models.OGC import TileMatrixSetList -from timvt.resources.enums import MimeTypes - -from fastapi import APIRouter, Depends, Path, Query - from starlette.datastructures import QueryParams from starlette.requests import Request from starlette.responses import HTMLResponse, Response from starlette.routing import NoMatchFound from starlette.templating import Jinja2Templates +from timvt.dependencies import LayerParams, TileParams +from timvt.layer import Function, Layer, Table +from timvt.models.mapbox import TileJSON +from timvt.models.OGC import TileMatrixSetList +from timvt.resources.enums import MimeTypes try: from importlib.resources import files as resources_files # type: ignore @@ -114,10 +112,9 @@ def register_tiles(self): async def tile( request: Request, tile: Tile = Depends(TileParams), - TileMatrixSetId: Literal[tuple(self.supported_tms.list())] = Query( - self.default_tms, - description=f"TileMatrixSet Name (default: '{self.default_tms}')", - ), + TileMatrixSetId: Literal[ + tuple(self.supported_tms.list()) + ] = self.default_tms, layer=Depends(self.layer_dependency), ): """Return vector tile.""" @@ -146,10 +143,9 @@ async def tile( async def tilejson( request: Request, layer=Depends(self.layer_dependency), - TileMatrixSetId: Literal[tuple(self.supported_tms.list())] = Query( - self.default_tms, - description=f"TileMatrixSet Name (default: '{self.default_tms}')", - ), + TileMatrixSetId: Literal[ + tuple(self.supported_tms.list()) + ] = self.default_tms, minzoom: Optional[int] = Query( None, description="Overwrite default minzoom." ), diff --git a/timvt/layer.py b/timvt/layer.py index d3d8e19..1cc7596 100644 --- a/timvt/layer.py +++ b/timvt/layer.py @@ -6,11 +6,10 @@ from typing import Any, ClassVar, Dict, List, Optional import morecantile -from buildpg import Func +from buildpg import Func, RawDangerous from buildpg import Var as pg_variable from buildpg import asyncpg, clauses, funcs, render, select_fields from pydantic import BaseModel, root_validator - from timvt.dbmodel import Table as DBTable from timvt.errors import ( InvalidGeometryColumnName, @@ -38,12 +37,12 @@ class Layer(BaseModel, metaclass=abc.ABCMeta): id: str bounds: List[float] = [-180, -90, 180, 90] crs: str = "http://www.opengis.net/def/crs/EPSG/0/4326" - title: Optional[str] - description: Optional[str] + title: Optional[str] = None + description: Optional[str] = None minzoom: int = tile_settings.default_minzoom maxzoom: int = tile_settings.default_maxzoom default_tms: str = tile_settings.default_tms - tileurl: Optional[str] + tileurl: Optional[str] = None @abc.abstractmethod async def get_tile( @@ -89,15 +88,22 @@ class Table(Layer, DBTable): type: str = "Table" - @root_validator + @root_validator(pre=True) def bounds_default(cls, values): """Get default bounds from the first geometry columns.""" geoms = values.get("geometry_columns") if geoms: # Get the Extent of all the bounds - minx, miny, maxx, maxy = zip(*[geom.bounds for geom in geoms]) + def get_bounds(geom): + bounds = getattr(geom, "bounds", None) + if bounds is None: + bounds = geom["bounds"] + return bounds + + minx, miny, maxx, maxy = zip(*[get_bounds(geom) for geom in geoms]) values["bounds"] = [min(minx), min(miny), max(maxx), max(maxy)] - values["crs"] = f"http://www.opengis.net/def/crs/EPSG/0/{geoms[0].srid}" + srid = geoms[0]["srid"] + values["crs"] = f"http://www.opengis.net/def/crs/EPSG/0/{srid}" return values @@ -151,6 +157,12 @@ async def get_tile( tms_srid = tms.crs.to_epsg() tms_proj = tms.crs.to_proj4() + # This may be more valid but it doesn't add quotes around the column names + # _fields = select_fields(*cols) + + _fields = [f'"{f}"' for f in cols] + _fields = ", ".join(_fields) + async with pool.acquire() as conn: sql_query = """ WITH @@ -203,7 +215,7 @@ async def get_tile( sql_query, tablename=pg_variable(self.id), geometry_column=pg_variable(geometry_column.name), - fields=select_fields(*cols), + fields=RawDangerous(_fields), xmin=bbox.left, ymin=bbox.bottom, xmax=bbox.right, @@ -239,9 +251,9 @@ class Function(Layer): type: str = "Function" sql: str function_name: Optional[str] - options: Optional[List[Dict[str, Any]]] + options: Optional[List[Dict[str, Any]]] = None - @root_validator + @root_validator(pre=True) def function_name_default(cls, values): """Define default function's name to be same as id.""" function_name = values.get("function_name") diff --git a/timvt/models/mapbox.py b/timvt/models/mapbox.py index aa5c413..130e846 100644 --- a/timvt/models/mapbox.py +++ b/timvt/models/mapbox.py @@ -23,21 +23,21 @@ class TileJSON(BaseModel): tilejson: str = "2.2.0" name: Optional[str] - description: Optional[str] + description: Optional[str] = None version: str = "1.0.0" - attribution: Optional[str] - template: Optional[str] - legend: Optional[str] + attribution: Optional[str] = None + template: Optional[str] = None + legend: Optional[str] = None scheme: SchemeEnum = SchemeEnum.xyz tiles: List[str] - grids: Optional[List[str]] - data: Optional[List[str]] + grids: Optional[List[str]] = None + data: Optional[List[str]] = None minzoom: int = Field(0, ge=0, le=30) maxzoom: int = Field(30, ge=0, le=30) bounds: List[float] = [-180, -90, 180, 90] center: Optional[Tuple[float, float, int]] - @root_validator + @root_validator(pre=True) def compute_center(cls, values): """Compute center if it does not exist.""" bounds = values["bounds"] diff --git a/timvt/settings.py b/timvt/settings.py index 7ace7e7..29bbdbf 100644 --- a/timvt/settings.py +++ b/timvt/settings.py @@ -1,19 +1,21 @@ """ TiMVT config. -TiMVT uses pydantic.BaseSettings to either get settings from `.env` or environment variables +TiMVT uses BaseSettings to either get settings from `.env` or environment variables see: https://pydantic-docs.helpmanual.io/usage/settings/ """ + import sys from functools import lru_cache from typing import Any, Dict, List, Optional import pydantic +from pydantic_settings import BaseSettings # Pydantic does not support older versions of typing.TypedDict # https://github.com/pydantic/pydantic/pull/3374 -if sys.version_info < (3, 9, 2): +if sys.version_info < (3, 12, 0): from typing_extensions import TypedDict else: from typing import TypedDict @@ -28,7 +30,7 @@ class TableConfig(TypedDict, total=False): properties: Optional[List[str]] -class TableSettings(pydantic.BaseSettings): +class TableSettings(BaseSettings): """Table configuration settings""" fallback_key_names: List[str] = ["ogc_fid", "id", "pkey", "gid"] @@ -40,9 +42,10 @@ class Config: env_prefix = "TIMVT_" env_file = ".env" env_nested_delimiter = "__" + extra = "ignore" -class _ApiSettings(pydantic.BaseSettings): +class _ApiSettings(BaseSettings): """API settings""" name: str = "TiMVT" @@ -76,7 +79,7 @@ def ApiSettings() -> _ApiSettings: return _ApiSettings() -class _TileSettings(pydantic.BaseSettings): +class _TileSettings(BaseSettings): """MVT settings""" tile_resolution: int = 4096 @@ -91,6 +94,7 @@ class Config: env_prefix = "TIMVT_" env_file = ".env" + extra = "ignore" @lru_cache() @@ -99,7 +103,7 @@ def TileSettings() -> _TileSettings: return _TileSettings() -class PostgresSettings(pydantic.BaseSettings): +class PostgresSettings(BaseSettings): """Postgres-specific API settings. Attributes: @@ -110,13 +114,13 @@ class PostgresSettings(pydantic.BaseSettings): postgres_dbname: database name. """ - postgres_user: Optional[str] - postgres_pass: Optional[str] - postgres_host: Optional[str] - postgres_port: Optional[str] - postgres_dbname: Optional[str] + postgres_user: Optional[str] = None + postgres_pass: Optional[str] = None + postgres_host: Optional[str] = None + postgres_port: Optional[str] = None + postgres_dbname: Optional[str] = None - database_url: Optional[pydantic.PostgresDsn] = None + database_url: Optional[str] = None db_min_conn_size: int = 1 db_max_conn_size: int = 10 @@ -124,7 +128,7 @@ class PostgresSettings(pydantic.BaseSettings): db_max_inactive_conn_lifetime: float = 300 db_schemas: List[str] = ["public"] - db_tables: Optional[List[str]] + db_tables: Optional[List[str]] = None class Config: """model config"""