├── .bumpversion.cfg ├── .env.example ├── .flake8 ├── .github ├── codecov.yml └── workflows │ ├── ci.yml │ └── deploy_mkdocs.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGES.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── data ├── countries.sql ├── create_sentinel_and_landsat_grid.md ├── functions │ ├── hexagon.sql │ ├── landsat_poly_centroid.sql │ └── squares.sql ├── landsat_wrs.sql └── sentinel_mgrs.sql ├── demo ├── leaflet │ ├── index_3035.html │ ├── index_4326.html │ └── src │ │ ├── proj4-compressed.js │ │ └── proj4leaflet.js └── mapbox │ └── index.html ├── docker-compose.yml ├── dockerfiles ├── Dockerfile ├── Dockerfile.db └── scripts │ └── wait-for-it.sh ├── docs ├── logos │ ├── TiMVT_logo_large.png │ ├── TiMVT_logo_medium.png │ ├── TiMVT_logo_no_text_large.png │ └── TiMVT_logo_small.png ├── mkdocs.yml └── src │ ├── api │ └── timvt │ │ ├── db.md │ │ ├── dependencies.md │ │ ├── factory.md │ │ ├── layer.md │ │ ├── models │ │ ├── OGC.md │ │ └── mapbox.md │ │ ├── resources │ │ └── enums.md │ │ └── settings.md │ ├── contributing.md │ ├── function_layers.md │ ├── img │ └── favicon.ico │ ├── index.md │ ├── overrides │ └── main.html │ └── release-notes.md ├── pyproject.toml ├── tests ├── benchmarks.py ├── conftest.py ├── fixtures │ ├── data │ │ └── landsat_wrs.sql │ ├── landsat_poly_centroid.sql │ └── squares.sql ├── routes │ ├── __init__.py │ ├── test_metadata.py │ ├── test_tiles.py │ └── test_tms.py └── test_main.py └── timvt ├── __init__.py ├── db.py ├── dbmodel.py ├── dependencies.py ├── errors.py ├── factory.py ├── layer.py ├── main.py ├── middleware.py ├── models ├── OGC.py ├── __init__.py └── mapbox.py ├── resources ├── __init__.py └── enums.py ├── settings.py └── templates ├── index.html └── viewer.html /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.8.0a3 3 | commit = True 4 | tag = True 5 | tag_name = {new_version} 6 | 7 | [bumpversion:file:timvt/__init__.py] 8 | search = __version__ = "{current_version}" 9 | replace = __version__ = "{new_version}" 10 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # DB connection 2 | POSTGRES_USER=username 3 | POSTGRES_PASS=password 4 | POSTGRES_DBNAME=postgis 5 | POSTGRES_HOST=0.0.0.0 6 | POSTGRES_PORT=5432 7 | 8 | # You can also define the DATABASE_URL directly 9 | # DATABASE_URL=postgresql://username:password@0.0.0.0:5432/postgis 10 | 11 | # Tile settings 12 | TIMVT_TILE_RESOLUTION=4096 13 | TIMVT_TILE_BUFFER=256 14 | TIMVT_MAX_FEATURES_PER_TILE=10000 15 | 16 | # Default Table/Function min/max zoom 17 | TIMVT_DEFAULT_MINZOOM=8 18 | TIMVT_DEFAULT_MAXZOOM=19 19 | 20 | # TiMVT settings 21 | TIMVT_NAME = "Fast MVT Server" 22 | TIMVT_CORS_ORIGINS="*" 23 | TIMVT_DEBUG=TRUE 24 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E501,W503,E203 3 | exclude = .git,__pycache__,docs/source/conf.py,old,build,dist 4 | max-complexity = 12 5 | max-line-length = 90 6 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | comment: off 2 | 3 | coverage: 4 | status: 5 | project: 6 | default: 7 | target: auto 8 | threshold: 5 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | # On every pull request, but only on push to master 4 | on: 5 | push: 6 | branches: 7 | - master 8 | tags: 9 | - '*' 10 | paths: 11 | # Only run test and docker publish if somde code have changed 12 | - 'pyproject.toml' 13 | - 'timvt/**' 14 | - 'tests/**' 15 | - '.pre-commit-config.yaml' 16 | - 'dockerfiles/**' 17 | pull_request: 18 | env: 19 | LATEST_PY_VERSION: '3.10' 20 | 21 | jobs: 22 | tests: 23 | runs-on: ubuntu-20.04 24 | strategy: 25 | matrix: 26 | python-version: ['3.8', '3.9', '3.10', '3.11'] 27 | 28 | steps: 29 | - uses: actions/checkout@v3 30 | - name: Set up Python ${{ matrix.python-version }} 31 | uses: actions/setup-python@v4 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | 35 | - name: install lib postgres 36 | run: | 37 | sudo apt update 38 | wget -q https://www.postgresql.org/media/keys/ACCC4CF8.asc -O- | sudo apt-key add - 39 | echo "deb [arch=amd64] http://apt.postgresql.org/pub/repos/apt/ focal-pgdg main" | sudo tee /etc/apt/sources.list.d/postgresql.list 40 | sudo apt update 41 | sudo apt-get install --yes libpq-dev postgis postgresql-14-postgis-3 42 | 43 | - name: Install dependencies 44 | run: | 45 | python -m pip install --upgrade pip 46 | python -m pip install .["test"] 47 | 48 | - name: Run pre-commit 49 | if: ${{ matrix.python-version == env.LATEST_PY_VERSION }} 50 | run: | 51 | python -m pip install pre-commit codecov 52 | pre-commit run --all-files 53 | 54 | - name: Run tests 55 | run: python -m pytest --cov timvt --cov-report xml --cov-report term-missing 56 | 57 | - name: Upload Results 58 | if: ${{ matrix.python-version == env.LATEST_PY_VERSION }} 59 | uses: codecov/codecov-action@v1 60 | with: 61 | file: ./coverage.xml 62 | flags: unittests 63 | name: ${{ matrix.python-version }} 64 | fail_ci_if_error: false 65 | 66 | benchmark: 67 | needs: [tests] 68 | runs-on: ubuntu-20.04 69 | steps: 70 | - uses: actions/checkout@v3 71 | - name: Set up Python 72 | uses: actions/setup-python@v4 73 | with: 74 | python-version: ${{ env.LATEST_PY_VERSION }} 75 | 76 | - name: install lib postgres 77 | run: | 78 | sudo apt update 79 | wget -q https://www.postgresql.org/media/keys/ACCC4CF8.asc -O- | sudo apt-key add - 80 | echo "deb [arch=amd64] http://apt.postgresql.org/pub/repos/apt/ focal-pgdg main" | sudo tee /etc/apt/sources.list.d/postgresql.list 81 | sudo apt update 82 | sudo apt-get install --yes libpq-dev postgis postgresql-14-postgis-3 83 | 84 | - name: Install dependencies 85 | run: | 86 | python -m pip install --upgrade pip 87 | python -m pip install .["test"] 88 | 89 | - name: Run Benchmark 90 | run: python -m pytest tests/benchmarks.py --benchmark-only --benchmark-columns 'min, max, mean, median' --benchmark-json output.json 91 | 92 | # - name: Store and benchmark result 93 | # uses: benchmark-action/github-action-benchmark@v1 94 | # with: 95 | # name: TiMVT Benchmarks 96 | # tool: 'pytest' 97 | # output-file-path: output.json 98 | # alert-threshold: '130%' 99 | # comment-on-alert: true 100 | # fail-on-alert: true 101 | # # GitHub API token to make a commit comment 102 | # github-token: ${{ secrets.GITHUB_TOKEN }} 103 | # # Make a commit on `gh-pages` only if master 104 | # auto-push: ${{ github.ref == 'refs/heads/master' }} 105 | # benchmark-data-dir-path: benchmarks 106 | 107 | publish: 108 | needs: [tests] 109 | runs-on: ubuntu-latest 110 | if: contains(github.ref, 'tags') && github.event_name == 'push' 111 | steps: 112 | - uses: actions/checkout@v3 113 | - name: Set up Python 114 | uses: actions/setup-python@v4 115 | with: 116 | python-version: ${{ env.LATEST_PY_VERSION }} 117 | 118 | - name: Install dependencies 119 | run: | 120 | python -m pip install --upgrade pip 121 | python -m pip install hatch 122 | python -m hatch build 123 | 124 | - name: Set tag version 125 | id: tag 126 | run: | 127 | echo "version=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT 128 | 129 | - name: Set module version 130 | id: module 131 | run: | 132 | echo "version=$(hatch --quiet version)" >> $GITHUB_OUTPUT 133 | 134 | - name: Build and publish 135 | if: ${{ steps.tag.outputs.version }} == ${{ steps.module.outputs.version}} 136 | env: 137 | HATCH_INDEX_USER: ${{ secrets.PYPI_USERNAME }} 138 | HATCH_INDEX_AUTH: ${{ secrets.PYPI_PASSWORD }} 139 | run: | 140 | python -m hatch publish 141 | 142 | publish-docker: 143 | needs: [tests] 144 | if: github.ref == 'refs/heads/master' || startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release' 145 | runs-on: ubuntu-latest 146 | steps: 147 | - name: Checkout 148 | uses: actions/checkout@v3 149 | 150 | - name: Set up QEMU 151 | uses: docker/setup-qemu-action@v1 152 | 153 | - name: Set up Docker Buildx 154 | uses: docker/setup-buildx-action@v1 155 | 156 | - name: Login to Github 157 | uses: docker/login-action@v1 158 | with: 159 | registry: ghcr.io 160 | username: ${{ github.actor }} 161 | password: ${{ secrets.GITHUB_TOKEN }} 162 | 163 | - name: Set tag version 164 | id: tag 165 | # https://stackoverflow.com/questions/58177786/get-the-current-pushed-tag-in-github-actions 166 | run: echo ::set-output name=tag::${GITHUB_REF#refs/*/} 167 | 168 | # Push `latest` when commiting to master 169 | - name: Build and push 170 | if: github.ref == 'refs/heads/master' 171 | uses: docker/build-push-action@v2 172 | with: 173 | platforms: linux/amd64,linux/arm64 174 | context: . 175 | file: dockerfiles/Dockerfile 176 | push: true 177 | tags: | 178 | ghcr.io/${{ github.repository }}:latest 179 | 180 | # Push `{VERSION}` when pushing a new tag 181 | - name: Build and push 182 | if: startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release' 183 | uses: docker/build-push-action@v2 184 | with: 185 | platforms: linux/amd64,linux/arm64 186 | context: . 187 | file: dockerfiles/Dockerfile 188 | push: true 189 | tags: | 190 | ghcr.io/${{ github.repository }}:${{ steps.tag.outputs.tag }} 191 | -------------------------------------------------------------------------------- /.github/workflows/deploy_mkdocs.yml: -------------------------------------------------------------------------------- 1 | name: Publish docs via GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | # Only rebuild website when docs have changed 9 | - 'README.md' 10 | - 'CHANGES.md' 11 | - 'CONTRIBUTING.md' 12 | - 'docs/**' 13 | 14 | jobs: 15 | build: 16 | name: Deploy docs 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout master 20 | uses: actions/checkout@v3 21 | 22 | - name: Set up Python 3.8 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: 3.8 26 | 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | python -m pip install -e .["docs"] 31 | 32 | - name: Create API docs 33 | env: 34 | # we need to set a fake PG url or import will fail 35 | DATABASE_URL: postgresql://username:password@0.0.0.0:5439/postgis 36 | run: | 37 | pdocs as_markdown \ 38 | --output_dir docs/src/api \ 39 | --exclude_source \ 40 | --overwrite \ 41 | timvt.settings \ 42 | timvt.layer \ 43 | timvt.factory \ 44 | timvt.dependencies \ 45 | timvt.db \ 46 | timvt.resources.enums \ 47 | timvt.models.mapbox \ 48 | timvt.models.OGC 49 | 50 | - name: Deploy docs 51 | run: mkdocs gh-deploy --force -f docs/mkdocs.yml 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | cdk.out/ 104 | 105 | # pycharm 106 | .idea/ 107 | 108 | docs/api 109 | 110 | benchmark/ 111 | 112 | .pgdata 113 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 22.3.0 4 | hooks: 5 | - id: black 6 | language_version: python 7 | 8 | - repo: https://github.com/PyCQA/isort 9 | rev: 5.12.0 10 | hooks: 11 | - id: isort 12 | language_version: python 13 | 14 | - repo: https://github.com/PyCQA/flake8 15 | rev: 3.8.3 16 | hooks: 17 | - id: flake8 18 | language_version: python 19 | 20 | - repo: https://github.com/PyCQA/pydocstyle 21 | rev: 6.1.1 22 | hooks: 23 | - id: pydocstyle 24 | language_version: python 25 | additional_dependencies: 26 | - toml 27 | 28 | - repo: https://github.com/pre-commit/mirrors-mypy 29 | rev: v0.991 30 | hooks: 31 | - id: mypy 32 | language_version: python 33 | 34 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # Release Notes 2 | 3 | ## 0.8.0a3 (2023-03-14) 4 | 5 | * fix factories `url_for` type (for starlette >=0.26) 6 | 7 | ## 0.8.0a2 (2022-12-14) 8 | 9 | * replace `VectorTilerFactory.tms_dependency` attribute by `TilerFactory.supported_tms`. This attribute gets a `morecantile.defaults.TileMatrixSets` store and will create the tms dependencies dynamically 10 | * replace `TMSFactory.tms_dependency` attribute by `TMSFactory.supported_tms`. This attribute gets a `morecantile.defaults.TileMatrixSets` store and will create the tms dependencies dynamically 11 | * add `default_tms` in `VectorTilerFactory` to set the default TMS identifier supported by the tiler (e.g `WebMercatorQuad`) 12 | 13 | ## 0.8.0a1 (2022-11-21) 14 | 15 | * update hatch config 16 | 17 | ## 0.8.0a0 (2022-11-16) 18 | 19 | * remove `.pbf` extension in tiles endpoints 20 | * add `orjson` as an optional dependency (for faster JSON encoding/decoding within the database communication) 21 | * enable `geom` query parameter to select the `geometry column` (defaults to the first one) 22 | * add FastAPI application `exception handler` in default app 23 | * add `CacheControlMiddleware` middleware 24 | * enable more options to be forwarded to the `asyncpg` pool creation 25 | * add `PG_SCHEMAS` and `PG_TABLES` environment variable to specify Postgres schemas and tables 26 | * add `TIMVT_FUNCTIONS_DIRECTORY` environment variable to look for function SQL files 27 | * switch viewer to Maplibre 28 | * add `Point` and `LineString` feature support in viewer 29 | * Update dockerfiles to python3.10 and postgres14-postgis3.3 30 | * update FastAPI requirement to >0.87 31 | * remove endpoint Tags 32 | * make orjson a default requirement 33 | 34 | **breaking changes** 35 | 36 | * renamed `app.state.function_catalog` to `app.state.timvt_function_catalog` 37 | * changed `timvt.layer.Table` format 38 | * `table_catalog` is now of `Dict[str, Dict[str, Any]]` type (instead of `List[Dict[str, Any]]`) 39 | * renamed `timvt.db.table_index` to `timvt.dbmodel.get_table_index` 40 | * default to only view tables within the `public` schema 41 | * renamed *base exception class* to `TiMVTError` 42 | * remove python 3.7 support 43 | 44 | ## 0.7.0 (2022-06-09) 45 | 46 | * update database settings input 47 | * add `default_tms` in Layer definition to specify the Min/Max zoom TileMatrixSet 48 | * update `starlette-cramjam` requirement 49 | 50 | **breaking changes** 51 | 52 | * deprecating the use of `.pbf` in tile's path 53 | 54 | ## 0.6.0 (2022-04-14) 55 | 56 | * update `morecantile` requirement to `>3.1,=<4.0` 57 | 58 | ## 0.5.0 (2022-04-13) 59 | 60 | * switch to `pyproject.toml` and repo cleanup 61 | 62 | ## 0.4.1 (2022-02-10) 63 | 64 | * update viewer 65 | 66 | ## 0.4.0 (2022-02-10) 67 | 68 | * Refactor Function Registry to be hosted in the application state (`app.state.function_catalog) as the Table catalog. 69 | * move `timvt.function.Registry` to `timvt.layer.FunctionRegistry` 70 | 71 | ## 0.3.0 (2022-02-09) 72 | 73 | * update settings management from starlette to pydantic and use `TIMVT_` prefix 74 | 75 | ## 0.2.1 (2022-01-25) 76 | 77 | * update FastAPI version requirement to allow `>=0.73` 78 | 79 | ## 0.2.0 (2022-01-05) 80 | 81 | * Faster and cleaner SQL code 82 | * Compare Tile and Table geometries in Table CRS (speedup) 83 | * Allow non-epsg based TileMatrixSet 84 | * update morecantile requirement to `>=3.0.2` 85 | * add `geometry_srid` in Table metadata 86 | * refactor `Function` layers. 87 | 88 | **breaking changes** 89 | 90 | * Function layer signature change 91 | ```sql 92 | -- before 93 | CREATE FUNCTION name( 94 | -- bounding box 95 | xmin float, 96 | ymin float, 97 | xmax float, 98 | ymax float, 99 | -- EPSG (SRID) of the bounding box coordinates 100 | epsg integer, 101 | -- additional parameters 102 | value0 int, 103 | value1 int 104 | ) 105 | RETURNS bytea 106 | 107 | -- now 108 | CREATE FUNCTION name( 109 | -- bounding box 110 | xmin float, 111 | ymin float, 112 | xmax float, 113 | ymax float, 114 | -- EPSG (SRID) of the bounding box coordinates 115 | epsg integer, 116 | -- additional parameters 117 | query_params json 118 | ) 119 | RETURNS bytea 120 | ``` 121 | 122 | ## 0.1.0 (2021-10-12) 123 | 124 | Initial release 125 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Development - Contributing 2 | 3 | Issues and pull requests are more than welcome: https://github.com/developmentseed/timvt/issues 4 | 5 | **dev install** 6 | 7 | ```bash 8 | $ git clone https://github.com/developmentseed/timvt.git 9 | $ cd timvt 10 | $ pip install -e .["test,dev"] 11 | ``` 12 | 13 | You can then run the tests with the following command: 14 | 15 | ```sh 16 | python -m pytest --cov timvt --cov-report term-missing 17 | ``` 18 | 19 | **pre-commit** 20 | 21 | This repo is set to use `pre-commit` to run *isort*, *flake8*, *pydocstring*, *black* ("uncompromising Python code formatter") and mypy when committing new code. 22 | 23 | ```bash 24 | $ pre-commit install 25 | ``` 26 | 27 | ### Docs 28 | 29 | ```bash 30 | $ git clone https://github.com/developmentseed/timvt.git 31 | $ cd timvt 32 | $ pip install -e .["docs"] 33 | ``` 34 | 35 | Hot-reloading docs: 36 | 37 | ```bash 38 | $ mkdocs serve 39 | ``` 40 | 41 | To manually deploy docs (note you should never need to do this because Github 42 | Actions deploys automatically for new commits.): 43 | 44 | ```bash 45 | $ mkdocs gh-deploy 46 | ``` 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Development Seed 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

A lightweight PostGIS based dynamic vector tile server.

4 |

5 | 6 |

7 | 8 | Test 9 | 10 | 11 | Coverage 12 | 13 | 14 | Package version 15 | 16 | 17 | License 18 | 19 | 20 |

21 | 22 | --- 23 | 24 | **Documentation**: https://developmentseed.org/timvt/ 25 | 26 | **Source Code**: https://github.com/developmentseed/timvt 27 | 28 | --- 29 | 30 | > :warning: This project is on pause while we focus on [`developmentseed/tipg`](https://github.com/developmentseed/tipg) :warning: 31 | > 32 | > ref: https://github.com/developmentseed/timvt/discussions/96 33 | 34 | --- 35 | 36 | `TiMVT`, pronounced **tee-MVT**, is a python package which helps creating lightweight [Vector Tiles](https://github.com/mapbox/vector-tile-spec) service from [PostGIS](https://github.com/postgis/postgis) Database. 37 | 38 | Built on top of the *modern and fast* [FastAPI](https://fastapi.tiangolo.com) framework, timvt is written in Python using async/await asynchronous code to improve the performances and handle heavy loads. 39 | 40 | `TiMVT` is mostly inspired from the awesome [urbica/martin](https://github.com/urbica/martin) and [CrunchyData/pg_tileserv](https://github.com/CrunchyData/pg_tileserv) projects. 41 | 42 | ## Features 43 | 44 | - Multiple TileMatrixSets via [morecantile](https://github.com/developmentseed/morecantile). Default is set to WebMercatorQuad which is the usual Web Mercator projection used in most of Wep Map libraries.) 45 | - Built with [FastAPI](https://fastapi.tiangolo.com) 46 | - Table and Function layers 47 | - Async API using [asyncpg](https://github.com/MagicStack/asyncpg) 48 | 49 | 50 | ## Install 51 | 52 | Install `TiMVT` from pypi 53 | ```bash 54 | # update pip (optional) 55 | python -m pip install pip -U 56 | 57 | # install timvt 58 | python -m pip install timvt 59 | ``` 60 | 61 | or install from source: 62 | 63 | ```bash 64 | $ git clone https://github.com/developmentseed/timvt.git 65 | $ cd timvt 66 | $ python -m pip install -e . 67 | ``` 68 | 69 | ## PostGIS/Postgres 70 | 71 | `TiMVT` rely mostly on [`ST_AsMVT`](https://postgis.net/docs/ST_AsMVT.html) function and will need PostGIS >= 2.5. 72 | 73 | If you want more info about `ST_AsMVT` function or on the subject of creating Vector Tile from PostGIS, please read this great article from Paul Ramsey: https://info.crunchydata.com/blog/dynamic-vector-tiles-from-postgis 74 | 75 | ### Configuration 76 | 77 | To be able to create Vector Tile, the application will need access to the PostGIS database. `TiMVT` uses [pydantic](https://pydantic-docs.helpmanual.io/usage/settings/)'s configuration pattern which make use of environment variable and/or `.env` file to pass variable to the application. 78 | 79 | Example of `.env` file can be found in [.env.example](https://github.com/developmentseed/timvt/blob/master/.env.example) 80 | ``` 81 | POSTGRES_USER=username 82 | POSTGRES_PASS=password 83 | POSTGRES_DBNAME=postgis 84 | POSTGRES_HOST=0.0.0.0 85 | POSTGRES_PORT=5432 86 | 87 | # Or you can also define the DATABASE_URL directly 88 | DATABASE_URL=postgresql://username:password@0.0.0.0:5432/postgis 89 | ``` 90 | 91 | ## Minimal Application 92 | 93 | ```python 94 | from timvt.db import close_db_connection, connect_to_db 95 | from timvt.factory import VectorTilerFactory 96 | from timvt.layer import FunctionRegistry 97 | from fastapi import FastAPI, Request 98 | 99 | # Create Application. 100 | app = FastAPI() 101 | 102 | # Add Function registry to the application state 103 | app.state.timvt_function_catalog = FunctionRegistry() 104 | 105 | # Register Start/Stop application event handler to setup/stop the database connection 106 | # and populate `app.state.table_catalog` 107 | @app.on_event("startup") 108 | async def startup_event(): 109 | """Application startup: register the database connection and create table list.""" 110 | await connect_to_db(app) 111 | 112 | 113 | @app.on_event("shutdown") 114 | async def shutdown_event(): 115 | """Application shutdown: de-register the database connection.""" 116 | await close_db_connection(app) 117 | 118 | # Register endpoints. 119 | mvt_tiler = VectorTilerFactory( 120 | with_tables_metadata=True, 121 | with_functions_metadata=True, # add Functions metadata endpoints (/functions.json, /{function_name}.json) 122 | with_viewer=True, 123 | ) 124 | app.include_router(mvt_tiler.router, tags=["Tiles"]) 125 | ``` 126 | 127 | ## Default Application 128 | 129 | While we encourage users to write their own application using `TiMVT` package, we also provide a default `production ready` application: 130 | 131 | ```bash 132 | # Install timvt dependencies and Uvicorn (a lightning-fast ASGI server) 133 | $ pip install timvt 'uvicorn[standard]>=0.12.0,<0.14.0' 134 | 135 | # Set Database URL environment variable so TiMVT can access it 136 | $ export DATABASE_URL=postgresql://username:password@0.0.0.0:5432/postgis 137 | 138 | # Launch Demo Application 139 | $ uvicorn timvt.main:app --reload 140 | ``` 141 | 142 | You can also use the official docker image 143 | 144 | ``` 145 | $ docker run \ 146 | -p 8081:8081 \ 147 | -e PORT=8081 \ 148 | -e DATABASE_URL=postgresql://username:password@0.0.0.0:5432/postgis \ 149 | ghcr.io/developmentseed/timvt:latest 150 | ``` 151 | 152 | `:endpoint:/docs` 153 | 154 | ![](https://user-images.githubusercontent.com/10407788/202146065-2ddcf159-123c-48f9-a208-7dcd46201cb4.png) 155 | 156 | 157 | ## Contribution & Development 158 | 159 | See [CONTRIBUTING.md](https://github.com/developmentseed/timvt/blob/master/CONTRIBUTING.md) 160 | 161 | ## License 162 | 163 | See [LICENSE](https://github.com/developmentseed/timvt/blob/master/LICENSE) 164 | 165 | ## Authors 166 | 167 | Created by [Development Seed]() 168 | 169 | ## Changes 170 | 171 | See [CHANGES.md](https://github.com/developmentseed/timvt/blob/master/CHANGES.md). 172 | 173 | -------------------------------------------------------------------------------- /data/create_sentinel_and_landsat_grid.md: -------------------------------------------------------------------------------- 1 | # Create Sentinel/Landsat Grids 2 | 3 | ### Landsat-8 4 | ``` 5 | $ wget http://storage.googleapis.com/gcp-public-data-landsat/index.csv.gz 6 | $ cat index.csv.gz | gunzip | grep "LANDSAT_8" | cut -d',' -f2 | cut -d'_' -f3 | sort | uniq | tail -n +2 > pathrow.txt 7 | 8 | $ wget https://prd-wret.s3.us-west-2.amazonaws.com/assets/palladium/production/s3fs-public/atoms/files/WRS2_descending_0.zip 9 | $ fio cat WRS2_descending.shp | jq -c '.properties={"PR": .properties.PR, "PATH": .properties.PATH, "ROW": .properties.ROW}' > WRS2_descending.geojson 10 | $ cat WRS2_descending.geojson | python filter.py --tiles pathrow.txt --property PR > landsat_wrs.geojson 11 | $ ogr2ogr -f PGDump landsat_wrs.sql landsat_wrs.geojson -lco GEOMETRY_NAME=geom 12 | ``` 13 | 14 | 15 | ### Sentinel-2 16 | ``` 17 | $ wget https://storage.googleapis.com/gcp-public-data-sentinel-2/index.csv.gz 18 | $ cat index.csv.gz | gunzip | cut -d',' -f1 | cut -d'_' -f10 | sed 's/^T//' | sort | uniq > tiles.txt 19 | 20 | $ wget https://sentinel.esa.int/documents/247904/1955685/S2A_OPER_GIP_TILPAR_MPC__20151209T095117_V20150622T000000_21000101T000000_B00.kml 21 | $ ogr2ogr -f "GeoJSON" -t_srs EPSG:4326 S2A_OPER_GIP_TILPAR_MPC__20151209T095117_V20150622T000000_21000101T000000_B00.geojson S2A_OPER_GIP_TILPAR_MPC__20151209T095117_V20150622T000000_21000101T000000_B00.kml 22 | $ cat S2A_OPER_GIP_TILPAR_MPC__20151209T095117_V20150622T000000_21000101T000000_B00.geojson | jq -c '.features[] | .properties={"Name": .properties.Name} | .geometry=(.geometry.geometries | map(select(.type == "Polygon"))[0])' | fio collect > sentinel2_tiles.geojson 23 | 24 | $ fio cat sentinel2_tiles.geojson | python filter.py --tiles tiles.txt --property Name > sentinel_mgrs.geojson 25 | $ ogr2ogr -f PGDump sentinel_mgrs.sql sentinel_mgrs.geojson -lco GEOMETRY_NAME=geom 26 | ``` 27 | 28 | `filter.py` 29 | 30 | ```python 31 | # requirements `click cligj fiona` 32 | 33 | import json 34 | import click 35 | import cligj 36 | 37 | 38 | @click.command() 39 | @cligj.features_in_arg 40 | @click.option('--tiles', type=click.Path(exists=True), required=True) 41 | @click.option("--property", type=str, help="Define accessor property", required=True) 42 | def main(features, tiles, property): 43 | with open(tiles, 'r') as f: 44 | list_tile = set(f.read().splitlines()) 45 | 46 | for feat in features: 47 | if feat['properties'][property] in list_tile: 48 | click.echo(json.dumps(feat)) 49 | 50 | 51 | if __name__ == '__main__': 52 | main() 53 | ``` -------------------------------------------------------------------------------- /data/functions/hexagon.sql: -------------------------------------------------------------------------------- 1 | -- Custom "hexagon" layer 2 | -- Adapted from https://github.com/CrunchyData/pg_tileserv 3 | -- 4 | -- 5 | -- Given an input ZXY tile coordinate, output a set of hexagons 6 | -- (and hexagon coordinates) in web mercator that cover that tile 7 | CREATE OR REPLACE FUNCTION tilehexagons( 8 | bounds geometry, 9 | step integer, 10 | epsg integer, 11 | OUT geom geometry(Polygon, 3857), OUT i integer, OUT j integer) 12 | RETURNS SETOF record AS $$ 13 | DECLARE 14 | maxbounds geometry := ST_TileEnvelope(0, 0, 0); 15 | edge float8; 16 | BEGIN 17 | edge := (ST_XMax(bounds) - ST_XMin(bounds)) / pow(2, step); 18 | FOR geom, i, j IN 19 | SELECT ST_SetSRID(hexagon(h.i, h.j, edge), epsg), h.i, h.j 20 | FROM hexagoncoordinates(bounds, edge) h 21 | LOOP 22 | IF maxbounds ~ geom AND bounds && geom THEN 23 | RETURN NEXT; 24 | END IF; 25 | END LOOP; 26 | END; 27 | $$ 28 | LANGUAGE 'plpgsql' 29 | IMMUTABLE 30 | STRICT 31 | PARALLEL SAFE; 32 | 33 | -- Given coordinates in the hexagon tiling that has this 34 | -- edge size, return the built-out hexagon 35 | CREATE OR REPLACE FUNCTION hexagon( 36 | i integer, 37 | j integer, 38 | edge float8 39 | ) 40 | RETURNS geometry AS $$ 41 | DECLARE 42 | h float8 := edge*cos(pi()/6.0); 43 | cx float8 := 1.5*i*edge; 44 | cy float8 := h*(2*j+abs(i%2)); 45 | BEGIN 46 | RETURN ST_MakePolygon(ST_MakeLine(ARRAY[ 47 | ST_MakePoint(cx - 1.0*edge, cy + 0), 48 | ST_MakePoint(cx - 0.5*edge, cy + -1*h), 49 | ST_MakePoint(cx + 0.5*edge, cy + -1*h), 50 | ST_MakePoint(cx + 1.0*edge, cy + 0), 51 | ST_MakePoint(cx + 0.5*edge, cy + h), 52 | ST_MakePoint(cx - 0.5*edge, cy + h), 53 | ST_MakePoint(cx - 1.0*edge, cy + 0) 54 | ])); 55 | END; 56 | $$ 57 | LANGUAGE 'plpgsql' 58 | IMMUTABLE 59 | STRICT 60 | PARALLEL SAFE; 61 | 62 | -- Given a square bounds, find all the hexagonal cells 63 | -- of a hex tiling (determined by edge size) 64 | -- that might cover that square (slightly over-determined) 65 | CREATE OR REPLACE FUNCTION hexagoncoordinates( 66 | bounds geometry, 67 | edge float8, 68 | OUT i integer, 69 | OUT j integer 70 | ) 71 | RETURNS SETOF record AS $$ 72 | DECLARE 73 | h float8 := edge*cos(pi()/6); 74 | mini integer := floor(st_xmin(bounds) / (1.5*edge)); 75 | minj integer := floor(st_ymin(bounds) / (2*h)); 76 | maxi integer := ceil(st_xmax(bounds) / (1.5*edge)); 77 | maxj integer := ceil(st_ymax(bounds) / (2*h)); 78 | BEGIN 79 | FOR i, j IN 80 | SELECT a, b 81 | FROM generate_series(mini, maxi) a, 82 | generate_series(minj, maxj) b 83 | LOOP 84 | RETURN NEXT; 85 | END LOOP; 86 | END; 87 | $$ 88 | LANGUAGE 'plpgsql' 89 | IMMUTABLE 90 | STRICT 91 | PARALLEL SAFE; 92 | 93 | -- Given an input tile, generate the covering hexagons Step parameter determines 94 | -- how many hexagons to generate per tile. 95 | CREATE OR REPLACE FUNCTION hexagon( 96 | -- mandatory parameters 97 | xmin float, 98 | ymin float, 99 | xmax float, 100 | ymax float, 101 | epsg integer, 102 | -- additional parameters 103 | query_params json 104 | ) 105 | RETURNS bytea AS $$ 106 | DECLARE 107 | result bytea; 108 | step integer; 109 | bounds geometry; 110 | BEGIN 111 | -- Find the bbox bounds 112 | bounds := ST_MakeEnvelope(xmin, ymin, xmax, ymax, epsg); 113 | 114 | step := coalesce((query_params ->> 'step')::int, 4); 115 | 116 | WITH 117 | rows AS ( 118 | -- All the hexes that interact with this tile 119 | SELECT h.i, h.j, h.geom 120 | FROM TileHexagons(bounds, step, epsg) h 121 | ), 122 | mvt AS ( 123 | -- Usual tile processing, ST_AsMVTGeom simplifies, quantizes, 124 | -- and clips to tile boundary 125 | SELECT ST_AsMVTGeom(rows.geom, bounds) AS geom, rows.i, rows.j 126 | FROM rows 127 | ) 128 | -- Generate MVT encoding of final input record 129 | SELECT ST_AsMVT(mvt, 'default') 130 | INTO result 131 | FROM mvt; 132 | 133 | RETURN result; 134 | END; 135 | $$ 136 | LANGUAGE 'plpgsql' 137 | STABLE 138 | STRICT 139 | PARALLEL SAFE; 140 | 141 | -------------------------------------------------------------------------------- /data/functions/landsat_poly_centroid.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION landsat_poly_centroid( 2 | -- mandatory parameters 3 | xmin float, 4 | ymin float, 5 | xmax float, 6 | ymax float, 7 | epsg integer, 8 | -- additional parameters 9 | query_params json 10 | ) 11 | RETURNS bytea 12 | AS $$ 13 | DECLARE 14 | bounds geometry; 15 | tablename text; 16 | result bytea; 17 | BEGIN 18 | WITH 19 | -- Create bbox enveloppe in given EPSG 20 | bounds AS ( 21 | SELECT ST_MakeEnvelope(xmin, ymin, xmax, ymax, epsg) AS geom 22 | ), 23 | selected_geom AS ( 24 | SELECT t.* 25 | FROM public.landsat_wrs t, bounds 26 | WHERE ST_Intersects(t.geom, ST_Transform(bounds.geom, 4326)) 27 | ), 28 | mvtgeom AS ( 29 | SELECT 30 | ST_AsMVTGeom(ST_Transform(ST_Centroid(t.geom), epsg), bounds.geom) AS geom, t.path, t.row 31 | FROM selected_geom t, bounds 32 | UNION 33 | SELECT ST_AsMVTGeom(ST_Transform(t.geom, epsg), bounds.geom) AS geom, t.path, t.row 34 | FROM selected_geom t, bounds 35 | ) 36 | SELECT ST_AsMVT(mvtgeom.*, 'default') 37 | 38 | -- Put the query result into the result variale. 39 | INTO result FROM mvtgeom; 40 | 41 | -- Return the answer 42 | RETURN result; 43 | END; 44 | $$ 45 | LANGUAGE 'plpgsql' 46 | IMMUTABLE -- Same inputs always give same outputs 47 | STRICT -- Null input gets null output 48 | PARALLEL SAFE; 49 | 50 | COMMENT ON FUNCTION landsat_poly_centroid IS 'Return Combined Polygon/Centroid geometries from landsat table.'; 51 | -------------------------------------------------------------------------------- /data/functions/squares.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION squares( 2 | -- mandatory parameters 3 | xmin float, 4 | ymin float, 5 | xmax float, 6 | ymax float, 7 | epsg integer, 8 | -- additional parameters 9 | query_params json 10 | ) 11 | RETURNS bytea AS $$ 12 | DECLARE 13 | result bytea; 14 | sq_width float; 15 | bbox_xmin float; 16 | bbox_ymin float; 17 | bounds geometry; 18 | depth integer; 19 | BEGIN 20 | -- Find the bbox bounds 21 | bounds := ST_MakeEnvelope(xmin, ymin, xmax, ymax, epsg); 22 | 23 | -- Find the bottom corner of the bounds 24 | bbox_xmin := ST_XMin(bounds); 25 | bbox_ymin := ST_YMin(bounds); 26 | 27 | -- We want bbox divided up into depth*depth squares per bbox, 28 | -- so what is the width of a square? 29 | depth := coalesce((query_params ->> 'depth')::int, 2); 30 | 31 | sq_width := (ST_XMax(bounds) - ST_XMin(bounds)) / depth; 32 | 33 | WITH mvtgeom AS ( 34 | SELECT 35 | -- Fill in the bbox with all the squares 36 | ST_AsMVTGeom( 37 | ST_SetSRID( 38 | ST_MakeEnvelope( 39 | bbox_xmin + sq_width * (a - 1), 40 | bbox_ymin + sq_width * (b - 1), 41 | bbox_xmin + sq_width * a, 42 | bbox_ymin + sq_width * b 43 | ), 44 | epsg 45 | ), 46 | bounds 47 | ) 48 | 49 | -- Drive the square generator with a two-dimensional 50 | -- generate_series setup 51 | FROM generate_series(1, depth) a, generate_series(1, depth) b 52 | ) 53 | SELECT ST_AsMVT(mvtgeom.*, 'default') 54 | 55 | -- Put the query result into the result variale. 56 | INTO result FROM mvtgeom; 57 | 58 | -- Return the answer 59 | RETURN result; 60 | END; 61 | $$ 62 | LANGUAGE 'plpgsql' 63 | IMMUTABLE -- Same inputs always give same outputs 64 | STRICT -- Null input gets null output 65 | PARALLEL SAFE; 66 | -------------------------------------------------------------------------------- /demo/leaflet/index_3035.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | 11 | 12 | 13 | EuropeanETRS89_LAEAQuad 14 | 15 | 16 |
17 | 18 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /demo/leaflet/index_4326.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | 11 | 12 | 13 | WGS1984Quad 14 | 15 | 16 |
17 | 18 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /demo/leaflet/src/proj4leaflet.js: -------------------------------------------------------------------------------- 1 | (function (factory) { 2 | var L, proj4; 3 | if (typeof define === 'function' && define.amd) { 4 | // AMD 5 | define(['leaflet', 'proj4'], factory); 6 | } else if (typeof module === 'object' && typeof module.exports === "object") { 7 | // Node/CommonJS 8 | L = require('leaflet'); 9 | proj4 = require('proj4'); 10 | module.exports = factory(L, proj4); 11 | } else { 12 | // Browser globals 13 | if (typeof window.L === 'undefined' || typeof window.proj4 === 'undefined') 14 | throw 'Leaflet and proj4 must be loaded first'; 15 | factory(window.L, window.proj4); 16 | } 17 | }(function (L, proj4) { 18 | if (proj4.__esModule && proj4.default) { 19 | // If proj4 was bundled as an ES6 module, unwrap it to get 20 | // to the actual main proj4 object. 21 | // See discussion in https://github.com/kartena/Proj4Leaflet/pull/147 22 | proj4 = proj4.default; 23 | } 24 | 25 | L.Proj = {}; 26 | 27 | L.Proj._isProj4Obj = function(a) { 28 | return (typeof a.inverse !== 'undefined' && 29 | typeof a.forward !== 'undefined'); 30 | }; 31 | 32 | L.Proj.Projection = L.Class.extend({ 33 | initialize: function(code, def, bounds) { 34 | var isP4 = L.Proj._isProj4Obj(code); 35 | this._proj = isP4 ? code : this._projFromCodeDef(code, def); 36 | this.bounds = isP4 ? def : bounds; 37 | }, 38 | 39 | project: function (latlng) { 40 | var point = this._proj.forward([latlng.lng, latlng.lat]); 41 | return new L.Point(point[0], point[1]); 42 | }, 43 | 44 | unproject: function (point, unbounded) { 45 | var point2 = this._proj.inverse([point.x, point.y]); 46 | return new L.LatLng(point2[1], point2[0], unbounded); 47 | }, 48 | 49 | _projFromCodeDef: function(code, def) { 50 | if (def) { 51 | proj4.defs(code, def); 52 | } else if (proj4.defs[code] === undefined) { 53 | var urn = code.split(':'); 54 | if (urn.length > 3) { 55 | code = urn[urn.length - 3] + ':' + urn[urn.length - 1]; 56 | } 57 | if (proj4.defs[code] === undefined) { 58 | throw 'No projection definition for code ' + code; 59 | } 60 | } 61 | 62 | return proj4(code); 63 | } 64 | }); 65 | 66 | L.Proj.CRS = L.Class.extend({ 67 | includes: L.CRS, 68 | 69 | options: { 70 | transformation: new L.Transformation(1, 0, -1, 0) 71 | }, 72 | 73 | initialize: function(a, b, c) { 74 | var code, 75 | proj, 76 | def, 77 | options; 78 | 79 | if (L.Proj._isProj4Obj(a)) { 80 | proj = a; 81 | code = proj.srsCode; 82 | options = b || {}; 83 | 84 | this.projection = new L.Proj.Projection(proj, L.bounds(options.bounds)); 85 | } else { 86 | code = a; 87 | def = b; 88 | options = c || {}; 89 | this.projection = new L.Proj.Projection(code, def, L.bounds(options.bounds)); 90 | } 91 | 92 | L.Util.setOptions(this, options); 93 | this.code = code; 94 | this.transformation = this.options.transformation; 95 | 96 | if (this.options.origin) { 97 | this.transformation = 98 | new L.Transformation(1, -this.options.origin[0], 99 | -1, this.options.origin[1]); 100 | } 101 | 102 | if (this.options.scales) { 103 | this._scales = this.options.scales; 104 | } else if (this.options.resolutions) { 105 | this._scales = []; 106 | for (var i = this.options.resolutions.length - 1; i >= 0; i--) { 107 | if (this.options.resolutions[i]) { 108 | this._scales[i] = 1 / this.options.resolutions[i]; 109 | } 110 | } 111 | } 112 | 113 | this.infinite = !L.bounds(this.options.bounds); 114 | 115 | }, 116 | 117 | scale: function(zoom) { 118 | var iZoom = Math.floor(zoom), 119 | baseScale, 120 | nextScale, 121 | scaleDiff, 122 | zDiff; 123 | if (zoom === iZoom) { 124 | return this._scales[zoom]; 125 | } else { 126 | // Non-integer zoom, interpolate 127 | baseScale = this._scales[iZoom]; 128 | nextScale = this._scales[iZoom + 1]; 129 | scaleDiff = nextScale - baseScale; 130 | zDiff = (zoom - iZoom); 131 | return baseScale + scaleDiff * zDiff; 132 | } 133 | }, 134 | 135 | zoom: function(scale) { 136 | // Find closest number in this._scales, down 137 | var downScale = this._closestElement(this._scales, scale), 138 | downZoom = this._scales.indexOf(downScale), 139 | nextScale, 140 | nextZoom, 141 | scaleDiff; 142 | // Check if scale is downScale => return array index 143 | if (scale === downScale) { 144 | return downZoom; 145 | } 146 | if (downScale === undefined) { 147 | return -Infinity; 148 | } 149 | // Interpolate 150 | nextZoom = downZoom + 1; 151 | nextScale = this._scales[nextZoom]; 152 | if (nextScale === undefined) { 153 | return Infinity; 154 | } 155 | scaleDiff = nextScale - downScale; 156 | return (scale - downScale) / scaleDiff + downZoom; 157 | }, 158 | 159 | distance: L.CRS.Earth.distance, 160 | 161 | R: L.CRS.Earth.R, 162 | 163 | /* Get the closest lowest element in an array */ 164 | _closestElement: function(array, element) { 165 | var low; 166 | for (var i = array.length; i--;) { 167 | if (array[i] <= element && (low === undefined || low < array[i])) { 168 | low = array[i]; 169 | } 170 | } 171 | return low; 172 | } 173 | }); 174 | 175 | L.Proj.GeoJSON = L.GeoJSON.extend({ 176 | initialize: function(geojson, options) { 177 | this._callLevel = 0; 178 | L.GeoJSON.prototype.initialize.call(this, geojson, options); 179 | }, 180 | 181 | addData: function(geojson) { 182 | var crs; 183 | 184 | if (geojson) { 185 | if (geojson.crs && geojson.crs.type === 'name') { 186 | crs = new L.Proj.CRS(geojson.crs.properties.name); 187 | } else if (geojson.crs && geojson.crs.type) { 188 | crs = new L.Proj.CRS(geojson.crs.type + ':' + geojson.crs.properties.code); 189 | } 190 | 191 | if (crs !== undefined) { 192 | this.options.coordsToLatLng = function(coords) { 193 | var point = L.point(coords[0], coords[1]); 194 | return crs.projection.unproject(point); 195 | }; 196 | } 197 | } 198 | 199 | // Base class' addData might call us recursively, but 200 | // CRS shouldn't be cleared in that case, since CRS applies 201 | // to the whole GeoJSON, inluding sub-features. 202 | this._callLevel++; 203 | try { 204 | L.GeoJSON.prototype.addData.call(this, geojson); 205 | } finally { 206 | this._callLevel--; 207 | if (this._callLevel === 0) { 208 | delete this.options.coordsToLatLng; 209 | } 210 | } 211 | } 212 | }); 213 | 214 | L.Proj.geoJson = function(geojson, options) { 215 | return new L.Proj.GeoJSON(geojson, options); 216 | }; 217 | 218 | L.Proj.ImageOverlay = L.ImageOverlay.extend({ 219 | initialize: function (url, bounds, options) { 220 | L.ImageOverlay.prototype.initialize.call(this, url, null, options); 221 | this._projectedBounds = bounds; 222 | }, 223 | 224 | // Danger ahead: Overriding internal methods in Leaflet. 225 | // Decided to do this rather than making a copy of L.ImageOverlay 226 | // and doing very tiny modifications to it. 227 | // Future will tell if this was wise or not. 228 | _animateZoom: function (event) { 229 | var scale = this._map.getZoomScale(event.zoom); 230 | var northWest = L.point(this._projectedBounds.min.x, this._projectedBounds.max.y); 231 | var offset = this._projectedToNewLayerPoint(northWest, event.zoom, event.center); 232 | 233 | L.DomUtil.setTransform(this._image, offset, scale); 234 | }, 235 | 236 | _reset: function () { 237 | var zoom = this._map.getZoom(); 238 | var pixelOrigin = this._map.getPixelOrigin(); 239 | var bounds = L.bounds( 240 | this._transform(this._projectedBounds.min, zoom)._subtract(pixelOrigin), 241 | this._transform(this._projectedBounds.max, zoom)._subtract(pixelOrigin) 242 | ); 243 | var size = bounds.getSize(); 244 | 245 | L.DomUtil.setPosition(this._image, bounds.min); 246 | this._image.style.width = size.x + 'px'; 247 | this._image.style.height = size.y + 'px'; 248 | }, 249 | 250 | _projectedToNewLayerPoint: function (point, zoom, center) { 251 | var viewHalf = this._map.getSize()._divideBy(2); 252 | var newTopLeft = this._map.project(center, zoom)._subtract(viewHalf)._round(); 253 | var topLeft = newTopLeft.add(this._map._getMapPanePos()); 254 | 255 | return this._transform(point, zoom)._subtract(topLeft); 256 | }, 257 | 258 | _transform: function (point, zoom) { 259 | var crs = this._map.options.crs; 260 | var transformation = crs.transformation; 261 | var scale = crs.scale(zoom); 262 | 263 | return transformation.transform(point, scale); 264 | } 265 | }); 266 | 267 | L.Proj.imageOverlay = function (url, bounds, options) { 268 | return new L.Proj.ImageOverlay(url, bounds, options); 269 | }; 270 | 271 | return L.Proj; 272 | })); -------------------------------------------------------------------------------- /demo/mapbox/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Ti VTiler 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 30 | 31 | 32 | 33 |
34 | 35 | 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | app: 5 | build: 6 | context: . 7 | dockerfile: dockerfiles/Dockerfile 8 | environment: 9 | - HOST=0.0.0.0 10 | - PORT=8081 11 | - PYTHONWARNINGS=ignore 12 | - POSTGRES_USER=username 13 | - POSTGRES_PASS=password 14 | - POSTGRES_DBNAME=postgis 15 | - POSTGRES_HOST=database 16 | - POSTGRES_PORT=5432 17 | - DEBUG=TRUE 18 | ports: 19 | - "${MY_DOCKER_IP:-127.0.0.1}:8081:8081" 20 | depends_on: 21 | - database 22 | command: 23 | bash -c "bash /tmp/scripts/wait-for-it.sh database:5432 --timeout=30 && /start.sh" 24 | volumes: 25 | - ./dockerfiles/scripts:/tmp/scripts 26 | 27 | database: 28 | build: 29 | context: . 30 | dockerfile: dockerfiles/Dockerfile.db 31 | environment: 32 | - POSTGRES_USER=username 33 | - POSTGRES_PASSWORD=password 34 | - POSTGRES_DB=postgis 35 | ports: 36 | - "5439:5432" 37 | command: postgres -N 500 38 | volumes: 39 | - ./.pgdata:/var/lib/postgresql/data 40 | 41 | # pg_tileserv: 42 | # image: pramsey/pg_tileserv:latest 43 | # environment: 44 | # - DATABASE_URL=postgresql://username:password@database:5432/postgis 45 | # ports: 46 | # - "7800:7800" 47 | # depends_on: 48 | # - database 49 | 50 | # martin: 51 | # image: maplibre/martin 52 | # environment: 53 | # - DATABASE_URL=postgresql://username:password@database:5432/postgis 54 | # ports: 55 | # - "3000:3000" 56 | # depends_on: 57 | # - database 58 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PYTHON_VERSION=3.10 2 | 3 | FROM ghcr.io/vincentsarago/uvicorn-gunicorn:${PYTHON_VERSION} 4 | 5 | WORKDIR /tmp 6 | 7 | COPY README.md README.md 8 | COPY LICENSE LICENSE 9 | COPY timvt/ timvt/ 10 | COPY pyproject.toml pyproject.toml 11 | 12 | RUN pip install . --no-cache-dir 13 | RUN rm -rf timvt/ README.md pyproject.toml LICENSE 14 | 15 | ENV MODULE_NAME timvt.main 16 | ENV VARIABLE_NAME app 17 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile.db: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/vincentsarago/postgis:14-3.3 2 | 3 | COPY data/*.sql /docker-entrypoint-initdb.d/ 4 | -------------------------------------------------------------------------------- /dockerfiles/scripts/wait-for-it.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use this script to test if a given TCP host/port are available 3 | 4 | ###################################################### 5 | # Copied from https://github.com/vishnubob/wait-for-it 6 | ###################################################### 7 | 8 | WAITFORIT_cmdname=${0##*/} 9 | 10 | echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 11 | 12 | usage() 13 | { 14 | cat << USAGE >&2 15 | Usage: 16 | $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] 17 | -h HOST | --host=HOST Host or IP under test 18 | -p PORT | --port=PORT TCP port under test 19 | Alternatively, you specify the host and port as host:port 20 | -s | --strict Only execute subcommand if the test succeeds 21 | -q | --quiet Don't output any status messages 22 | -t TIMEOUT | --timeout=TIMEOUT 23 | Timeout in seconds, zero for no timeout 24 | -- COMMAND ARGS Execute command with args after the test finishes 25 | USAGE 26 | exit 1 27 | } 28 | 29 | wait_for() 30 | { 31 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 32 | echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 33 | else 34 | echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" 35 | fi 36 | WAITFORIT_start_ts=$(date +%s) 37 | while : 38 | do 39 | if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then 40 | nc -z $WAITFORIT_HOST $WAITFORIT_PORT 41 | WAITFORIT_result=$? 42 | else 43 | (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 44 | WAITFORIT_result=$? 45 | fi 46 | if [[ $WAITFORIT_result -eq 0 ]]; then 47 | WAITFORIT_end_ts=$(date +%s) 48 | echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" 49 | break 50 | fi 51 | sleep 1 52 | done 53 | return $WAITFORIT_result 54 | } 55 | 56 | wait_for_wrapper() 57 | { 58 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 59 | if [[ $WAITFORIT_QUIET -eq 1 ]]; then 60 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 61 | else 62 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 63 | fi 64 | WAITFORIT_PID=$! 65 | trap "kill -INT -$WAITFORIT_PID" INT 66 | wait $WAITFORIT_PID 67 | WAITFORIT_RESULT=$? 68 | if [[ $WAITFORIT_RESULT -ne 0 ]]; then 69 | echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 70 | fi 71 | return $WAITFORIT_RESULT 72 | } 73 | 74 | # process arguments 75 | while [[ $# -gt 0 ]] 76 | do 77 | case "$1" in 78 | *:* ) 79 | WAITFORIT_hostport=(${1//:/ }) 80 | WAITFORIT_HOST=${WAITFORIT_hostport[0]} 81 | WAITFORIT_PORT=${WAITFORIT_hostport[1]} 82 | shift 1 83 | ;; 84 | --child) 85 | WAITFORIT_CHILD=1 86 | shift 1 87 | ;; 88 | -q | --quiet) 89 | WAITFORIT_QUIET=1 90 | shift 1 91 | ;; 92 | -s | --strict) 93 | WAITFORIT_STRICT=1 94 | shift 1 95 | ;; 96 | -h) 97 | WAITFORIT_HOST="$2" 98 | if [[ $WAITFORIT_HOST == "" ]]; then break; fi 99 | shift 2 100 | ;; 101 | --host=*) 102 | WAITFORIT_HOST="${1#*=}" 103 | shift 1 104 | ;; 105 | -p) 106 | WAITFORIT_PORT="$2" 107 | if [[ $WAITFORIT_PORT == "" ]]; then break; fi 108 | shift 2 109 | ;; 110 | --port=*) 111 | WAITFORIT_PORT="${1#*=}" 112 | shift 1 113 | ;; 114 | -t) 115 | WAITFORIT_TIMEOUT="$2" 116 | if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi 117 | shift 2 118 | ;; 119 | --timeout=*) 120 | WAITFORIT_TIMEOUT="${1#*=}" 121 | shift 1 122 | ;; 123 | --) 124 | shift 125 | WAITFORIT_CLI=("$@") 126 | break 127 | ;; 128 | --help) 129 | usage 130 | ;; 131 | *) 132 | echoerr "Unknown argument: $1" 133 | usage 134 | ;; 135 | esac 136 | done 137 | 138 | if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then 139 | echoerr "Error: you need to provide a host and port to test." 140 | usage 141 | fi 142 | 143 | WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} 144 | WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} 145 | WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} 146 | WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} 147 | 148 | # Check to see if timeout is from busybox? 149 | WAITFORIT_TIMEOUT_PATH=$(type -p timeout) 150 | WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) 151 | 152 | WAITFORIT_BUSYTIMEFLAG="" 153 | if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then 154 | WAITFORIT_ISBUSY=1 155 | # Check if busybox timeout uses -t flag 156 | # (recent Alpine versions don't support -t anymore) 157 | if timeout &>/dev/stdout | grep -q -e '-t '; then 158 | WAITFORIT_BUSYTIMEFLAG="-t" 159 | fi 160 | else 161 | WAITFORIT_ISBUSY=0 162 | fi 163 | 164 | if [[ $WAITFORIT_CHILD -gt 0 ]]; then 165 | wait_for 166 | WAITFORIT_RESULT=$? 167 | exit $WAITFORIT_RESULT 168 | else 169 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 170 | wait_for_wrapper 171 | WAITFORIT_RESULT=$? 172 | else 173 | wait_for 174 | WAITFORIT_RESULT=$? 175 | fi 176 | fi 177 | 178 | if [[ $WAITFORIT_CLI != "" ]]; then 179 | if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then 180 | echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" 181 | exit $WAITFORIT_RESULT 182 | fi 183 | exec "${WAITFORIT_CLI[@]}" 184 | else 185 | exit $WAITFORIT_RESULT 186 | fi 187 | -------------------------------------------------------------------------------- /docs/logos/TiMVT_logo_large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/timvt/b1c6f6158a05708365fb68f037b53481cffe9383/docs/logos/TiMVT_logo_large.png -------------------------------------------------------------------------------- /docs/logos/TiMVT_logo_medium.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/timvt/b1c6f6158a05708365fb68f037b53481cffe9383/docs/logos/TiMVT_logo_medium.png -------------------------------------------------------------------------------- /docs/logos/TiMVT_logo_no_text_large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/timvt/b1c6f6158a05708365fb68f037b53481cffe9383/docs/logos/TiMVT_logo_no_text_large.png -------------------------------------------------------------------------------- /docs/logos/TiMVT_logo_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/timvt/b1c6f6158a05708365fb68f037b53481cffe9383/docs/logos/TiMVT_logo_small.png -------------------------------------------------------------------------------- /docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: timvt 2 | site_description: A lightweight PostGIS based dynamic vector tile server. 3 | 4 | docs_dir: 'src' 5 | site_dir: 'build' 6 | 7 | repo_name: "developmentseed/timvt" 8 | repo_url: "https://github.com/developmentseed/timvt" 9 | 10 | extra: 11 | social: 12 | - icon: "fontawesome/brands/github" 13 | link: "https://github.com/developmentseed" 14 | - icon: "fontawesome/brands/twitter" 15 | link: "https://twitter.com/developmentseed" 16 | - icon: "fontawesome/brands/medium" 17 | link: "https://medium.com/devseed" 18 | 19 | nav: 20 | - TiMVT: "index.md" 21 | - Function Layers: "function_layers.md" 22 | - API: 23 | - dependencies: api/timvt/dependencies.md 24 | - factory: api/timvt/factory.md 25 | - layer: api/timvt/layer.md 26 | - db: api/timvt/db.md 27 | - settings: api/timvt/settings.md 28 | - enums: api/timvt/resources/enums.md 29 | - models: 30 | - mapbox: api/timvt/models/mapbox.md 31 | - OGC: api/timvt/models/OGC.md 32 | 33 | - Development - Contributing: "contributing.md" 34 | - Release Notes: "release-notes.md" 35 | 36 | plugins: 37 | - search 38 | 39 | theme: 40 | name: material 41 | palette: 42 | primary: indigo 43 | scheme: default 44 | custom_dir: 'src/overrides' 45 | favicon: img/favicon.ico 46 | 47 | # https://github.com/kylebarron/cogeo-mosaic/blob/mkdocs/mkdocs.yml#L50-L75 48 | markdown_extensions: 49 | - admonition 50 | - attr_list 51 | - codehilite: 52 | guess_lang: false 53 | - def_list 54 | - footnotes 55 | - pymdownx.arithmatex 56 | - pymdownx.betterem 57 | - pymdownx.caret: 58 | insert: false 59 | - pymdownx.details 60 | - pymdownx.emoji 61 | - pymdownx.escapeall: 62 | hardbreak: true 63 | nbsp: true 64 | - pymdownx.magiclink: 65 | hide_protocol: true 66 | repo_url_shortener: true 67 | - pymdownx.smartsymbols 68 | - pymdownx.superfences 69 | - pymdownx.tasklist: 70 | custom_checkbox: true 71 | - pymdownx.tilde 72 | - toc: 73 | permalink: true 74 | -------------------------------------------------------------------------------- /docs/src/api/timvt/db.md: -------------------------------------------------------------------------------- 1 | # Module timvt.db 2 | 3 | timvt.db: database events. 4 | 5 | None 6 | 7 | ## Variables 8 | 9 | ```python3 10 | pg_settings 11 | ``` 12 | 13 | ## Functions 14 | 15 | 16 | ### close_db_connection 17 | 18 | ```python3 19 | def close_db_connection( 20 | app: fastapi.applications.FastAPI 21 | ) -> None 22 | ``` 23 | 24 | 25 | Close connection. 26 | 27 | 28 | ### connect_to_db 29 | 30 | ```python3 31 | def connect_to_db( 32 | app: fastapi.applications.FastAPI 33 | ) -> None 34 | ``` 35 | 36 | 37 | Connect. 38 | 39 | 40 | ### table_index 41 | 42 | ```python3 43 | def table_index( 44 | db_pool: buildpg.asyncpg.BuildPgPool 45 | ) -> Sequence 46 | ``` 47 | 48 | 49 | Fetch Table index. -------------------------------------------------------------------------------- /docs/src/api/timvt/dependencies.md: -------------------------------------------------------------------------------- 1 | # Module timvt.dependencies 2 | 3 | TiVTiler.dependencies: endpoint's dependencies. 4 | 5 | None 6 | 7 | ## Functions 8 | 9 | 10 | ### LayerParams 11 | 12 | ```python3 13 | def LayerParams( 14 | request: starlette.requests.Request, 15 | layer: str = Path(Ellipsis) 16 | ) -> timvt.layer.Layer 17 | ``` 18 | 19 | 20 | Return Layer Object. 21 | 22 | 23 | ### TileMatrixSetParams 24 | 25 | ```python3 26 | def TileMatrixSetParams( 27 | TileMatrixSetId: timvt.dependencies.TileMatrixSetNames = Query(TileMatrixSetNames.WebMercatorQuad) 28 | ) -> morecantile.models.TileMatrixSet 29 | ``` 30 | 31 | 32 | TileMatrixSet parameters. 33 | 34 | 35 | ### TileParams 36 | 37 | ```python3 38 | def TileParams( 39 | z: int = Path(Ellipsis), 40 | x: int = Path(Ellipsis), 41 | y: int = Path(Ellipsis) 42 | ) -> morecantile.commons.Tile 43 | ``` 44 | 45 | 46 | Tile parameters. 47 | 48 | ## Classes 49 | 50 | ### TileMatrixSetNames 51 | 52 | ```python3 53 | class TileMatrixSetNames( 54 | /, 55 | *args, 56 | **kwargs 57 | ) 58 | ``` 59 | 60 | #### Ancestors (in MRO) 61 | 62 | * enum.Enum 63 | 64 | #### Class variables 65 | 66 | ```python3 67 | CanadianNAD83_LCC 68 | ``` 69 | 70 | ```python3 71 | EuropeanETRS89_LAEAQuad 72 | ``` 73 | 74 | ```python3 75 | LINZAntarticaMapTilegrid 76 | ``` 77 | 78 | ```python3 79 | NZTM2000 80 | ``` 81 | 82 | ```python3 83 | NZTM2000Quad 84 | ``` 85 | 86 | ```python3 87 | UPSAntarcticWGS84Quad 88 | ``` 89 | 90 | ```python3 91 | UPSArcticWGS84Quad 92 | ``` 93 | 94 | ```python3 95 | UTM31WGS84Quad 96 | ``` 97 | 98 | ```python3 99 | WGS1984Quad 100 | ``` 101 | 102 | ```python3 103 | WebMercatorQuad 104 | ``` 105 | 106 | ```python3 107 | WorldCRS84Quad 108 | ``` 109 | 110 | ```python3 111 | WorldMercatorWGS84Quad 112 | ``` 113 | 114 | ```python3 115 | name 116 | ``` 117 | 118 | ```python3 119 | value 120 | ``` -------------------------------------------------------------------------------- /docs/src/api/timvt/factory.md: -------------------------------------------------------------------------------- 1 | # Module timvt.factory 2 | 3 | timvt.endpoints.factory: router factories. 4 | 5 | None 6 | 7 | ## Variables 8 | 9 | ```python3 10 | TILE_RESPONSE_PARAMS 11 | ``` 12 | 13 | ```python3 14 | templates 15 | ``` 16 | 17 | ## Functions 18 | 19 | 20 | ### queryparams_to_kwargs 21 | 22 | ```python3 23 | def queryparams_to_kwargs( 24 | q: starlette.datastructures.QueryParams, 25 | ignore_keys: List = [] 26 | ) -> Dict 27 | ``` 28 | 29 | 30 | Convert query params to dict. 31 | 32 | ## Classes 33 | 34 | ### TMSFactory 35 | 36 | ```python3 37 | class TMSFactory( 38 | supported_tms: Type[timvt.dependencies.TileMatrixSetNames] = , 39 | tms_dependency: Callable[..., morecantile.models.TileMatrixSet] = , 40 | router: fastapi.routing.APIRouter = , 41 | router_prefix: str = '' 42 | ) 43 | ``` 44 | 45 | #### Class variables 46 | 47 | ```python3 48 | router_prefix 49 | ``` 50 | 51 | ```python3 52 | supported_tms 53 | ``` 54 | 55 | #### Methods 56 | 57 | 58 | #### register_routes 59 | 60 | ```python3 61 | def register_routes( 62 | self 63 | ) 64 | ``` 65 | 66 | 67 | Register TMS endpoint routes. 68 | 69 | 70 | #### tms_dependency 71 | 72 | ```python3 73 | def tms_dependency( 74 | TileMatrixSetId: timvt.dependencies.TileMatrixSetNames = Query(TileMatrixSetNames.WebMercatorQuad) 75 | ) -> morecantile.models.TileMatrixSet 76 | ``` 77 | 78 | 79 | TileMatrixSet parameters. 80 | 81 | 82 | #### url_for 83 | 84 | ```python3 85 | def url_for( 86 | self, 87 | request: starlette.requests.Request, 88 | name: str, 89 | **path_params: Any 90 | ) -> str 91 | ``` 92 | 93 | 94 | Return full url (with prefix) for a specific endpoint. 95 | 96 | ### VectorTilerFactory 97 | 98 | ```python3 99 | class VectorTilerFactory( 100 | router: fastapi.routing.APIRouter = , 101 | tms_dependency: Callable[..., morecantile.models.TileMatrixSet] = , 102 | layer_dependency: Callable[..., timvt.layer.Layer] = , 103 | with_tables_metadata: bool = False, 104 | with_functions_metadata: bool = False, 105 | with_viewer: bool = False, 106 | router_prefix: str = '' 107 | ) 108 | ``` 109 | 110 | #### Class variables 111 | 112 | ```python3 113 | router_prefix 114 | ``` 115 | 116 | ```python3 117 | with_functions_metadata 118 | ``` 119 | 120 | ```python3 121 | with_tables_metadata 122 | ``` 123 | 124 | ```python3 125 | with_viewer 126 | ``` 127 | 128 | #### Methods 129 | 130 | 131 | #### layer_dependency 132 | 133 | ```python3 134 | def layer_dependency( 135 | request: starlette.requests.Request, 136 | layer: str = Path(Ellipsis) 137 | ) -> timvt.layer.Layer 138 | ``` 139 | 140 | 141 | Return Layer Object. 142 | 143 | 144 | #### register_functions_metadata 145 | 146 | ```python3 147 | def register_functions_metadata( 148 | self 149 | ) 150 | ``` 151 | 152 | 153 | Register function metadata endpoints. 154 | 155 | 156 | #### register_routes 157 | 158 | ```python3 159 | def register_routes( 160 | self 161 | ) 162 | ``` 163 | 164 | 165 | Register Routes. 166 | 167 | 168 | #### register_tables_metadata 169 | 170 | ```python3 171 | def register_tables_metadata( 172 | self 173 | ) 174 | ``` 175 | 176 | 177 | Register metadata endpoints. 178 | 179 | 180 | #### register_tiles 181 | 182 | ```python3 183 | def register_tiles( 184 | self 185 | ) 186 | ``` 187 | 188 | 189 | Register /tiles endpoints. 190 | 191 | 192 | #### register_viewer 193 | 194 | ```python3 195 | def register_viewer( 196 | self 197 | ) 198 | ``` 199 | 200 | 201 | Register viewer. 202 | 203 | 204 | #### tms_dependency 205 | 206 | ```python3 207 | def tms_dependency( 208 | TileMatrixSetId: timvt.dependencies.TileMatrixSetNames = Query(TileMatrixSetNames.WebMercatorQuad) 209 | ) -> morecantile.models.TileMatrixSet 210 | ``` 211 | 212 | 213 | TileMatrixSet parameters. 214 | 215 | 216 | #### url_for 217 | 218 | ```python3 219 | def url_for( 220 | self, 221 | request: starlette.requests.Request, 222 | name: str, 223 | **path_params: Any 224 | ) -> str 225 | ``` 226 | 227 | 228 | Return full url (with prefix) for a specific endpoint. -------------------------------------------------------------------------------- /docs/src/api/timvt/layer.md: -------------------------------------------------------------------------------- 1 | # Module timvt.layer 2 | 3 | timvt models. 4 | 5 | None 6 | 7 | ## Variables 8 | 9 | ```python3 10 | tile_settings 11 | ``` 12 | 13 | ## Classes 14 | 15 | ### Function 16 | 17 | ```python3 18 | class Function( 19 | __pydantic_self__, 20 | **data: Any 21 | ) 22 | ``` 23 | 24 | #### Attributes 25 | 26 | | Name | Type | Description | Default | 27 | |---|---|---|---| 28 | | id | str | Layer's name. | None | 29 | | bounds | list | Layer's bounds (left, bottom, right, top). | None | 30 | | minzoom | int | Layer's min zoom level. | None | 31 | | maxzoom | int | Layer's max zoom level. | None | 32 | | tileurl | str | Layer's tiles url. | None | 33 | | type | str | Layer's type. | None | 34 | | function_name | str | Nane of the SQL function to call. Defaults to `id`. | `id` | 35 | | sql | str | Valid SQL function which returns Tile data. | None | 36 | | options | list | options available for the SQL function. | None | 37 | 38 | #### Ancestors (in MRO) 39 | 40 | * timvt.layer.Layer 41 | * pydantic.main.BaseModel 42 | * pydantic.utils.Representation 43 | 44 | #### Class variables 45 | 46 | ```python3 47 | Config 48 | ``` 49 | 50 | #### Static methods 51 | 52 | 53 | #### construct 54 | 55 | ```python3 56 | def construct( 57 | _fields_set: Union[ForwardRef('SetStr'), NoneType] = None, 58 | **values: Any 59 | ) -> 'Model' 60 | ``` 61 | 62 | 63 | Creates a new model setting __dict__ and __fields_set__ from trusted or pre-validated data. 64 | 65 | Default values are respected, but no other validation is performed. 66 | Behaves as if `Config.extra = 'allow'` was set since it adds all passed values 67 | 68 | 69 | #### from_file 70 | 71 | ```python3 72 | def from_file( 73 | id: str, 74 | infile: str, 75 | **kwargs: Any 76 | ) 77 | ``` 78 | 79 | 80 | load sql from file 81 | 82 | 83 | #### from_orm 84 | 85 | ```python3 86 | def from_orm( 87 | obj: Any 88 | ) -> 'Model' 89 | ``` 90 | 91 | 92 | 93 | 94 | #### function_name_default 95 | 96 | ```python3 97 | def function_name_default( 98 | values 99 | ) 100 | ``` 101 | 102 | 103 | Define default function's name to be same as id. 104 | 105 | 106 | #### parse_file 107 | 108 | ```python3 109 | def parse_file( 110 | path: Union[str, pathlib.Path], 111 | *, 112 | content_type: 'unicode' = None, 113 | encoding: 'unicode' = 'utf8', 114 | proto: pydantic.parse.Protocol = None, 115 | allow_pickle: bool = False 116 | ) -> 'Model' 117 | ``` 118 | 119 | 120 | 121 | 122 | #### parse_obj 123 | 124 | ```python3 125 | def parse_obj( 126 | obj: Any 127 | ) -> 'Model' 128 | ``` 129 | 130 | 131 | 132 | 133 | #### parse_raw 134 | 135 | ```python3 136 | def parse_raw( 137 | b: Union[str, bytes], 138 | *, 139 | content_type: 'unicode' = None, 140 | encoding: 'unicode' = 'utf8', 141 | proto: pydantic.parse.Protocol = None, 142 | allow_pickle: bool = False 143 | ) -> 'Model' 144 | ``` 145 | 146 | 147 | 148 | 149 | #### schema 150 | 151 | ```python3 152 | def schema( 153 | by_alias: bool = True, 154 | ref_template: 'unicode' = '#/definitions/{model}' 155 | ) -> 'DictStrAny' 156 | ``` 157 | 158 | 159 | 160 | 161 | #### schema_json 162 | 163 | ```python3 164 | def schema_json( 165 | *, 166 | by_alias: bool = True, 167 | ref_template: 'unicode' = '#/definitions/{model}', 168 | **dumps_kwargs: Any 169 | ) -> 'unicode' 170 | ``` 171 | 172 | 173 | 174 | 175 | #### update_forward_refs 176 | 177 | ```python3 178 | def update_forward_refs( 179 | **localns: Any 180 | ) -> None 181 | ``` 182 | 183 | 184 | Try to update ForwardRefs on fields based on this Model, globalns and localns. 185 | 186 | 187 | #### validate 188 | 189 | ```python3 190 | def validate( 191 | value: Any 192 | ) -> 'Model' 193 | ``` 194 | 195 | 196 | 197 | #### Methods 198 | 199 | 200 | #### copy 201 | 202 | ```python3 203 | def copy( 204 | self: 'Model', 205 | *, 206 | include: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 207 | exclude: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 208 | update: 'DictStrAny' = None, 209 | deep: bool = False 210 | ) -> 'Model' 211 | ``` 212 | 213 | 214 | Duplicate a model, optionally choose which fields to include, exclude and change. 215 | 216 | **Parameters:** 217 | 218 | | Name | Type | Description | Default | 219 | |---|---|---|---| 220 | | include | None | fields to include in new model | None | 221 | | exclude | None | fields to exclude from new model, as with values this takes precedence over include | None | 222 | | update | None | values to change/add in the new model. Note: the data is not validated before creating 223 | the new model: you should trust this data | None | 224 | | deep | None | set to `True` to make a deep copy of the model | None | 225 | 226 | **Returns:** 227 | 228 | | Type | Description | 229 | |---|---| 230 | | None | new model instance | 231 | 232 | 233 | #### dict 234 | 235 | ```python3 236 | def dict( 237 | self, 238 | *, 239 | include: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 240 | exclude: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 241 | by_alias: bool = False, 242 | skip_defaults: bool = None, 243 | exclude_unset: bool = False, 244 | exclude_defaults: bool = False, 245 | exclude_none: bool = False 246 | ) -> 'DictStrAny' 247 | ``` 248 | 249 | 250 | Generate a dictionary representation of the model, optionally specifying which fields to include or exclude. 251 | 252 | 253 | #### get_tile 254 | 255 | ```python3 256 | def get_tile( 257 | self, 258 | pool: buildpg.asyncpg.BuildPgPool, 259 | tile: morecantile.commons.Tile, 260 | tms: morecantile.models.TileMatrixSet, 261 | **kwargs: Any 262 | ) 263 | ``` 264 | 265 | 266 | Get Tile Data. 267 | 268 | 269 | #### json 270 | 271 | ```python3 272 | def json( 273 | self, 274 | *, 275 | include: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 276 | exclude: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 277 | by_alias: bool = False, 278 | skip_defaults: bool = None, 279 | exclude_unset: bool = False, 280 | exclude_defaults: bool = False, 281 | exclude_none: bool = False, 282 | encoder: Union[Callable[[Any], Any], NoneType] = None, 283 | models_as_dict: bool = True, 284 | **dumps_kwargs: Any 285 | ) -> 'unicode' 286 | ``` 287 | 288 | 289 | Generate a JSON representation of the model, `include` and `exclude` arguments as per `dict()`. 290 | 291 | `encoder` is an optional function to supply as `default` to json.dumps(), other arguments as per `json.dumps()`. 292 | 293 | ### FunctionRegistry 294 | 295 | ```python3 296 | class FunctionRegistry( 297 | 298 | ) 299 | ``` 300 | 301 | #### Class variables 302 | 303 | ```python3 304 | funcs 305 | ``` 306 | 307 | #### Static methods 308 | 309 | 310 | #### get 311 | 312 | ```python3 313 | def get( 314 | key: str 315 | ) 316 | ``` 317 | 318 | 319 | lookup function by name 320 | 321 | 322 | #### register 323 | 324 | ```python3 325 | def register( 326 | *args: timvt.layer.Function 327 | ) 328 | ``` 329 | 330 | 331 | register function(s) 332 | 333 | ### Layer 334 | 335 | ```python3 336 | class Layer( 337 | __pydantic_self__, 338 | **data: Any 339 | ) 340 | ``` 341 | 342 | #### Attributes 343 | 344 | | Name | Type | Description | Default | 345 | |---|---|---|---| 346 | | id | str | Layer's name. | None | 347 | | bounds | list | Layer's bounds (left, bottom, right, top). | None | 348 | | minzoom | int | Layer's min zoom level. | None | 349 | | maxzoom | int | Layer's max zoom level. | None | 350 | | tileurl | str | Layer's tiles url. | None | 351 | 352 | #### Ancestors (in MRO) 353 | 354 | * pydantic.main.BaseModel 355 | * pydantic.utils.Representation 356 | 357 | #### Descendants 358 | 359 | * timvt.layer.Table 360 | * timvt.layer.Function 361 | 362 | #### Class variables 363 | 364 | ```python3 365 | Config 366 | ``` 367 | 368 | #### Static methods 369 | 370 | 371 | #### construct 372 | 373 | ```python3 374 | def construct( 375 | _fields_set: Union[ForwardRef('SetStr'), NoneType] = None, 376 | **values: Any 377 | ) -> 'Model' 378 | ``` 379 | 380 | 381 | Creates a new model setting __dict__ and __fields_set__ from trusted or pre-validated data. 382 | 383 | Default values are respected, but no other validation is performed. 384 | Behaves as if `Config.extra = 'allow'` was set since it adds all passed values 385 | 386 | 387 | #### from_orm 388 | 389 | ```python3 390 | def from_orm( 391 | obj: Any 392 | ) -> 'Model' 393 | ``` 394 | 395 | 396 | 397 | 398 | #### parse_file 399 | 400 | ```python3 401 | def parse_file( 402 | path: Union[str, pathlib.Path], 403 | *, 404 | content_type: 'unicode' = None, 405 | encoding: 'unicode' = 'utf8', 406 | proto: pydantic.parse.Protocol = None, 407 | allow_pickle: bool = False 408 | ) -> 'Model' 409 | ``` 410 | 411 | 412 | 413 | 414 | #### parse_obj 415 | 416 | ```python3 417 | def parse_obj( 418 | obj: Any 419 | ) -> 'Model' 420 | ``` 421 | 422 | 423 | 424 | 425 | #### parse_raw 426 | 427 | ```python3 428 | def parse_raw( 429 | b: Union[str, bytes], 430 | *, 431 | content_type: 'unicode' = None, 432 | encoding: 'unicode' = 'utf8', 433 | proto: pydantic.parse.Protocol = None, 434 | allow_pickle: bool = False 435 | ) -> 'Model' 436 | ``` 437 | 438 | 439 | 440 | 441 | #### schema 442 | 443 | ```python3 444 | def schema( 445 | by_alias: bool = True, 446 | ref_template: 'unicode' = '#/definitions/{model}' 447 | ) -> 'DictStrAny' 448 | ``` 449 | 450 | 451 | 452 | 453 | #### schema_json 454 | 455 | ```python3 456 | def schema_json( 457 | *, 458 | by_alias: bool = True, 459 | ref_template: 'unicode' = '#/definitions/{model}', 460 | **dumps_kwargs: Any 461 | ) -> 'unicode' 462 | ``` 463 | 464 | 465 | 466 | 467 | #### update_forward_refs 468 | 469 | ```python3 470 | def update_forward_refs( 471 | **localns: Any 472 | ) -> None 473 | ``` 474 | 475 | 476 | Try to update ForwardRefs on fields based on this Model, globalns and localns. 477 | 478 | 479 | #### validate 480 | 481 | ```python3 482 | def validate( 483 | value: Any 484 | ) -> 'Model' 485 | ``` 486 | 487 | 488 | 489 | #### Methods 490 | 491 | 492 | #### copy 493 | 494 | ```python3 495 | def copy( 496 | self: 'Model', 497 | *, 498 | include: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 499 | exclude: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 500 | update: 'DictStrAny' = None, 501 | deep: bool = False 502 | ) -> 'Model' 503 | ``` 504 | 505 | 506 | Duplicate a model, optionally choose which fields to include, exclude and change. 507 | 508 | **Parameters:** 509 | 510 | | Name | Type | Description | Default | 511 | |---|---|---|---| 512 | | include | None | fields to include in new model | None | 513 | | exclude | None | fields to exclude from new model, as with values this takes precedence over include | None | 514 | | update | None | values to change/add in the new model. Note: the data is not validated before creating 515 | the new model: you should trust this data | None | 516 | | deep | None | set to `True` to make a deep copy of the model | None | 517 | 518 | **Returns:** 519 | 520 | | Type | Description | 521 | |---|---| 522 | | None | new model instance | 523 | 524 | 525 | #### dict 526 | 527 | ```python3 528 | def dict( 529 | self, 530 | *, 531 | include: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 532 | exclude: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 533 | by_alias: bool = False, 534 | skip_defaults: bool = None, 535 | exclude_unset: bool = False, 536 | exclude_defaults: bool = False, 537 | exclude_none: bool = False 538 | ) -> 'DictStrAny' 539 | ``` 540 | 541 | 542 | Generate a dictionary representation of the model, optionally specifying which fields to include or exclude. 543 | 544 | 545 | #### get_tile 546 | 547 | ```python3 548 | def get_tile( 549 | self, 550 | pool: buildpg.asyncpg.BuildPgPool, 551 | tile: morecantile.commons.Tile, 552 | tms: morecantile.models.TileMatrixSet, 553 | **kwargs: Any 554 | ) -> bytes 555 | ``` 556 | 557 | 558 | Return Tile Data. 559 | 560 | **Parameters:** 561 | 562 | | Name | Type | Description | Default | 563 | |---|---|---|---| 564 | | pool | asyncpg.BuildPgPool | AsyncPG database connection pool. | None | 565 | | tile | morecantile.Tile | Tile object with X,Y,Z indices. | None | 566 | | tms | morecantile.TileMatrixSet | Tile Matrix Set. | None | 567 | | kwargs | any, optiona | Optional parameters to forward to the SQL function. | None | 568 | 569 | **Returns:** 570 | 571 | | Type | Description | 572 | |---|---| 573 | | bytes | Mapbox Vector Tiles. | 574 | 575 | 576 | #### json 577 | 578 | ```python3 579 | def json( 580 | self, 581 | *, 582 | include: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 583 | exclude: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 584 | by_alias: bool = False, 585 | skip_defaults: bool = None, 586 | exclude_unset: bool = False, 587 | exclude_defaults: bool = False, 588 | exclude_none: bool = False, 589 | encoder: Union[Callable[[Any], Any], NoneType] = None, 590 | models_as_dict: bool = True, 591 | **dumps_kwargs: Any 592 | ) -> 'unicode' 593 | ``` 594 | 595 | 596 | Generate a JSON representation of the model, `include` and `exclude` arguments as per `dict()`. 597 | 598 | `encoder` is an optional function to supply as `default` to json.dumps(), other arguments as per `json.dumps()`. 599 | 600 | ### Table 601 | 602 | ```python3 603 | class Table( 604 | __pydantic_self__, 605 | **data: Any 606 | ) 607 | ``` 608 | 609 | #### Attributes 610 | 611 | | Name | Type | Description | Default | 612 | |---|---|---|---| 613 | | id | str | Layer's name. | None | 614 | | bounds | list | Layer's bounds (left, bottom, right, top). | None | 615 | | minzoom | int | Layer's min zoom level. | None | 616 | | maxzoom | int | Layer's max zoom level. | None | 617 | | tileurl | str | Layer's tiles url. | None | 618 | | type | str | Layer's type. | None | 619 | | schema | str | Table's database schema (e.g public). | None | 620 | | geometry_type | str | Table's geometry type (e.g polygon). | None | 621 | | srid | int | Table's SRID | None | 622 | | geometry_column | str | Name of the geomtry column in the table. | None | 623 | | properties | Dict | Properties available in the table. | None | 624 | 625 | #### Ancestors (in MRO) 626 | 627 | * timvt.layer.Layer 628 | * pydantic.main.BaseModel 629 | * pydantic.utils.Representation 630 | 631 | #### Class variables 632 | 633 | ```python3 634 | Config 635 | ``` 636 | 637 | #### Static methods 638 | 639 | 640 | #### construct 641 | 642 | ```python3 643 | def construct( 644 | _fields_set: Union[ForwardRef('SetStr'), NoneType] = None, 645 | **values: Any 646 | ) -> 'Model' 647 | ``` 648 | 649 | 650 | Creates a new model setting __dict__ and __fields_set__ from trusted or pre-validated data. 651 | 652 | Default values are respected, but no other validation is performed. 653 | Behaves as if `Config.extra = 'allow'` was set since it adds all passed values 654 | 655 | 656 | #### from_orm 657 | 658 | ```python3 659 | def from_orm( 660 | obj: Any 661 | ) -> 'Model' 662 | ``` 663 | 664 | 665 | 666 | 667 | #### parse_file 668 | 669 | ```python3 670 | def parse_file( 671 | path: Union[str, pathlib.Path], 672 | *, 673 | content_type: 'unicode' = None, 674 | encoding: 'unicode' = 'utf8', 675 | proto: pydantic.parse.Protocol = None, 676 | allow_pickle: bool = False 677 | ) -> 'Model' 678 | ``` 679 | 680 | 681 | 682 | 683 | #### parse_obj 684 | 685 | ```python3 686 | def parse_obj( 687 | obj: Any 688 | ) -> 'Model' 689 | ``` 690 | 691 | 692 | 693 | 694 | #### parse_raw 695 | 696 | ```python3 697 | def parse_raw( 698 | b: Union[str, bytes], 699 | *, 700 | content_type: 'unicode' = None, 701 | encoding: 'unicode' = 'utf8', 702 | proto: pydantic.parse.Protocol = None, 703 | allow_pickle: bool = False 704 | ) -> 'Model' 705 | ``` 706 | 707 | 708 | 709 | 710 | #### schema 711 | 712 | ```python3 713 | def schema( 714 | by_alias: bool = True, 715 | ref_template: 'unicode' = '#/definitions/{model}' 716 | ) -> 'DictStrAny' 717 | ``` 718 | 719 | 720 | 721 | 722 | #### schema_json 723 | 724 | ```python3 725 | def schema_json( 726 | *, 727 | by_alias: bool = True, 728 | ref_template: 'unicode' = '#/definitions/{model}', 729 | **dumps_kwargs: Any 730 | ) -> 'unicode' 731 | ``` 732 | 733 | 734 | 735 | 736 | #### update_forward_refs 737 | 738 | ```python3 739 | def update_forward_refs( 740 | **localns: Any 741 | ) -> None 742 | ``` 743 | 744 | 745 | Try to update ForwardRefs on fields based on this Model, globalns and localns. 746 | 747 | 748 | #### validate 749 | 750 | ```python3 751 | def validate( 752 | value: Any 753 | ) -> 'Model' 754 | ``` 755 | 756 | 757 | 758 | #### Methods 759 | 760 | 761 | #### copy 762 | 763 | ```python3 764 | def copy( 765 | self: 'Model', 766 | *, 767 | include: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 768 | exclude: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 769 | update: 'DictStrAny' = None, 770 | deep: bool = False 771 | ) -> 'Model' 772 | ``` 773 | 774 | 775 | Duplicate a model, optionally choose which fields to include, exclude and change. 776 | 777 | **Parameters:** 778 | 779 | | Name | Type | Description | Default | 780 | |---|---|---|---| 781 | | include | None | fields to include in new model | None | 782 | | exclude | None | fields to exclude from new model, as with values this takes precedence over include | None | 783 | | update | None | values to change/add in the new model. Note: the data is not validated before creating 784 | the new model: you should trust this data | None | 785 | | deep | None | set to `True` to make a deep copy of the model | None | 786 | 787 | **Returns:** 788 | 789 | | Type | Description | 790 | |---|---| 791 | | None | new model instance | 792 | 793 | 794 | #### dict 795 | 796 | ```python3 797 | def dict( 798 | self, 799 | *, 800 | include: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 801 | exclude: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 802 | by_alias: bool = False, 803 | skip_defaults: bool = None, 804 | exclude_unset: bool = False, 805 | exclude_defaults: bool = False, 806 | exclude_none: bool = False 807 | ) -> 'DictStrAny' 808 | ``` 809 | 810 | 811 | Generate a dictionary representation of the model, optionally specifying which fields to include or exclude. 812 | 813 | 814 | #### get_tile 815 | 816 | ```python3 817 | def get_tile( 818 | self, 819 | pool: buildpg.asyncpg.BuildPgPool, 820 | tile: morecantile.commons.Tile, 821 | tms: morecantile.models.TileMatrixSet, 822 | **kwargs: Any 823 | ) 824 | ``` 825 | 826 | 827 | Get Tile Data. 828 | 829 | 830 | #### json 831 | 832 | ```python3 833 | def json( 834 | self, 835 | *, 836 | include: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 837 | exclude: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 838 | by_alias: bool = False, 839 | skip_defaults: bool = None, 840 | exclude_unset: bool = False, 841 | exclude_defaults: bool = False, 842 | exclude_none: bool = False, 843 | encoder: Union[Callable[[Any], Any], NoneType] = None, 844 | models_as_dict: bool = True, 845 | **dumps_kwargs: Any 846 | ) -> 'unicode' 847 | ``` 848 | 849 | 850 | Generate a JSON representation of the model, `include` and `exclude` arguments as per `dict()`. 851 | 852 | `encoder` is an optional function to supply as `default` to json.dumps(), other arguments as per `json.dumps()`. -------------------------------------------------------------------------------- /docs/src/api/timvt/models/OGC.md: -------------------------------------------------------------------------------- 1 | # Module timvt.models.OGC 2 | 3 | timvt.models.OGC: Open GeoSpatial Consortium models. 4 | 5 | None 6 | 7 | ## Classes 8 | 9 | ### TileMatrixSetLink 10 | 11 | ```python3 12 | class TileMatrixSetLink( 13 | __pydantic_self__, 14 | **data: Any 15 | ) 16 | ``` 17 | 18 | #### Ancestors (in MRO) 19 | 20 | * pydantic.main.BaseModel 21 | * pydantic.utils.Representation 22 | 23 | #### Class variables 24 | 25 | ```python3 26 | Config 27 | ``` 28 | 29 | #### Static methods 30 | 31 | 32 | #### construct 33 | 34 | ```python3 35 | def construct( 36 | _fields_set: Union[ForwardRef('SetStr'), NoneType] = None, 37 | **values: Any 38 | ) -> 'Model' 39 | ``` 40 | 41 | 42 | Creates a new model setting __dict__ and __fields_set__ from trusted or pre-validated data. 43 | 44 | Default values are respected, but no other validation is performed. 45 | Behaves as if `Config.extra = 'allow'` was set since it adds all passed values 46 | 47 | 48 | #### from_orm 49 | 50 | ```python3 51 | def from_orm( 52 | obj: Any 53 | ) -> 'Model' 54 | ``` 55 | 56 | 57 | 58 | 59 | #### parse_file 60 | 61 | ```python3 62 | def parse_file( 63 | path: Union[str, pathlib.Path], 64 | *, 65 | content_type: 'unicode' = None, 66 | encoding: 'unicode' = 'utf8', 67 | proto: pydantic.parse.Protocol = None, 68 | allow_pickle: bool = False 69 | ) -> 'Model' 70 | ``` 71 | 72 | 73 | 74 | 75 | #### parse_obj 76 | 77 | ```python3 78 | def parse_obj( 79 | obj: Any 80 | ) -> 'Model' 81 | ``` 82 | 83 | 84 | 85 | 86 | #### parse_raw 87 | 88 | ```python3 89 | def parse_raw( 90 | b: Union[str, bytes], 91 | *, 92 | content_type: 'unicode' = None, 93 | encoding: 'unicode' = 'utf8', 94 | proto: pydantic.parse.Protocol = None, 95 | allow_pickle: bool = False 96 | ) -> 'Model' 97 | ``` 98 | 99 | 100 | 101 | 102 | #### schema 103 | 104 | ```python3 105 | def schema( 106 | by_alias: bool = True, 107 | ref_template: 'unicode' = '#/definitions/{model}' 108 | ) -> 'DictStrAny' 109 | ``` 110 | 111 | 112 | 113 | 114 | #### schema_json 115 | 116 | ```python3 117 | def schema_json( 118 | *, 119 | by_alias: bool = True, 120 | ref_template: 'unicode' = '#/definitions/{model}', 121 | **dumps_kwargs: Any 122 | ) -> 'unicode' 123 | ``` 124 | 125 | 126 | 127 | 128 | #### update_forward_refs 129 | 130 | ```python3 131 | def update_forward_refs( 132 | **localns: Any 133 | ) -> None 134 | ``` 135 | 136 | 137 | Try to update ForwardRefs on fields based on this Model, globalns and localns. 138 | 139 | 140 | #### validate 141 | 142 | ```python3 143 | def validate( 144 | value: Any 145 | ) -> 'Model' 146 | ``` 147 | 148 | 149 | 150 | #### Methods 151 | 152 | 153 | #### copy 154 | 155 | ```python3 156 | def copy( 157 | self: 'Model', 158 | *, 159 | include: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 160 | exclude: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 161 | update: 'DictStrAny' = None, 162 | deep: bool = False 163 | ) -> 'Model' 164 | ``` 165 | 166 | 167 | Duplicate a model, optionally choose which fields to include, exclude and change. 168 | 169 | **Parameters:** 170 | 171 | | Name | Type | Description | Default | 172 | |---|---|---|---| 173 | | include | None | fields to include in new model | None | 174 | | exclude | None | fields to exclude from new model, as with values this takes precedence over include | None | 175 | | update | None | values to change/add in the new model. Note: the data is not validated before creating 176 | the new model: you should trust this data | None | 177 | | deep | None | set to `True` to make a deep copy of the model | None | 178 | 179 | **Returns:** 180 | 181 | | Type | Description | 182 | |---|---| 183 | | None | new model instance | 184 | 185 | 186 | #### dict 187 | 188 | ```python3 189 | def dict( 190 | self, 191 | *, 192 | include: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 193 | exclude: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 194 | by_alias: bool = False, 195 | skip_defaults: bool = None, 196 | exclude_unset: bool = False, 197 | exclude_defaults: bool = False, 198 | exclude_none: bool = False 199 | ) -> 'DictStrAny' 200 | ``` 201 | 202 | 203 | Generate a dictionary representation of the model, optionally specifying which fields to include or exclude. 204 | 205 | 206 | #### json 207 | 208 | ```python3 209 | def json( 210 | self, 211 | *, 212 | include: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 213 | exclude: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 214 | by_alias: bool = False, 215 | skip_defaults: bool = None, 216 | exclude_unset: bool = False, 217 | exclude_defaults: bool = False, 218 | exclude_none: bool = False, 219 | encoder: Union[Callable[[Any], Any], NoneType] = None, 220 | models_as_dict: bool = True, 221 | **dumps_kwargs: Any 222 | ) -> 'unicode' 223 | ``` 224 | 225 | 226 | Generate a JSON representation of the model, `include` and `exclude` arguments as per `dict()`. 227 | 228 | `encoder` is an optional function to supply as `default` to json.dumps(), other arguments as per `json.dumps()`. 229 | 230 | ### TileMatrixSetList 231 | 232 | ```python3 233 | class TileMatrixSetList( 234 | __pydantic_self__, 235 | **data: Any 236 | ) 237 | ``` 238 | 239 | #### Ancestors (in MRO) 240 | 241 | * pydantic.main.BaseModel 242 | * pydantic.utils.Representation 243 | 244 | #### Class variables 245 | 246 | ```python3 247 | Config 248 | ``` 249 | 250 | #### Static methods 251 | 252 | 253 | #### construct 254 | 255 | ```python3 256 | def construct( 257 | _fields_set: Union[ForwardRef('SetStr'), NoneType] = None, 258 | **values: Any 259 | ) -> 'Model' 260 | ``` 261 | 262 | 263 | Creates a new model setting __dict__ and __fields_set__ from trusted or pre-validated data. 264 | 265 | Default values are respected, but no other validation is performed. 266 | Behaves as if `Config.extra = 'allow'` was set since it adds all passed values 267 | 268 | 269 | #### from_orm 270 | 271 | ```python3 272 | def from_orm( 273 | obj: Any 274 | ) -> 'Model' 275 | ``` 276 | 277 | 278 | 279 | 280 | #### parse_file 281 | 282 | ```python3 283 | def parse_file( 284 | path: Union[str, pathlib.Path], 285 | *, 286 | content_type: 'unicode' = None, 287 | encoding: 'unicode' = 'utf8', 288 | proto: pydantic.parse.Protocol = None, 289 | allow_pickle: bool = False 290 | ) -> 'Model' 291 | ``` 292 | 293 | 294 | 295 | 296 | #### parse_obj 297 | 298 | ```python3 299 | def parse_obj( 300 | obj: Any 301 | ) -> 'Model' 302 | ``` 303 | 304 | 305 | 306 | 307 | #### parse_raw 308 | 309 | ```python3 310 | def parse_raw( 311 | b: Union[str, bytes], 312 | *, 313 | content_type: 'unicode' = None, 314 | encoding: 'unicode' = 'utf8', 315 | proto: pydantic.parse.Protocol = None, 316 | allow_pickle: bool = False 317 | ) -> 'Model' 318 | ``` 319 | 320 | 321 | 322 | 323 | #### schema 324 | 325 | ```python3 326 | def schema( 327 | by_alias: bool = True, 328 | ref_template: 'unicode' = '#/definitions/{model}' 329 | ) -> 'DictStrAny' 330 | ``` 331 | 332 | 333 | 334 | 335 | #### schema_json 336 | 337 | ```python3 338 | def schema_json( 339 | *, 340 | by_alias: bool = True, 341 | ref_template: 'unicode' = '#/definitions/{model}', 342 | **dumps_kwargs: Any 343 | ) -> 'unicode' 344 | ``` 345 | 346 | 347 | 348 | 349 | #### update_forward_refs 350 | 351 | ```python3 352 | def update_forward_refs( 353 | **localns: Any 354 | ) -> None 355 | ``` 356 | 357 | 358 | Try to update ForwardRefs on fields based on this Model, globalns and localns. 359 | 360 | 361 | #### validate 362 | 363 | ```python3 364 | def validate( 365 | value: Any 366 | ) -> 'Model' 367 | ``` 368 | 369 | 370 | 371 | #### Methods 372 | 373 | 374 | #### copy 375 | 376 | ```python3 377 | def copy( 378 | self: 'Model', 379 | *, 380 | include: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 381 | exclude: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 382 | update: 'DictStrAny' = None, 383 | deep: bool = False 384 | ) -> 'Model' 385 | ``` 386 | 387 | 388 | Duplicate a model, optionally choose which fields to include, exclude and change. 389 | 390 | **Parameters:** 391 | 392 | | Name | Type | Description | Default | 393 | |---|---|---|---| 394 | | include | None | fields to include in new model | None | 395 | | exclude | None | fields to exclude from new model, as with values this takes precedence over include | None | 396 | | update | None | values to change/add in the new model. Note: the data is not validated before creating 397 | the new model: you should trust this data | None | 398 | | deep | None | set to `True` to make a deep copy of the model | None | 399 | 400 | **Returns:** 401 | 402 | | Type | Description | 403 | |---|---| 404 | | None | new model instance | 405 | 406 | 407 | #### dict 408 | 409 | ```python3 410 | def dict( 411 | self, 412 | *, 413 | include: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 414 | exclude: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 415 | by_alias: bool = False, 416 | skip_defaults: bool = None, 417 | exclude_unset: bool = False, 418 | exclude_defaults: bool = False, 419 | exclude_none: bool = False 420 | ) -> 'DictStrAny' 421 | ``` 422 | 423 | 424 | Generate a dictionary representation of the model, optionally specifying which fields to include or exclude. 425 | 426 | 427 | #### json 428 | 429 | ```python3 430 | def json( 431 | self, 432 | *, 433 | include: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 434 | exclude: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 435 | by_alias: bool = False, 436 | skip_defaults: bool = None, 437 | exclude_unset: bool = False, 438 | exclude_defaults: bool = False, 439 | exclude_none: bool = False, 440 | encoder: Union[Callable[[Any], Any], NoneType] = None, 441 | models_as_dict: bool = True, 442 | **dumps_kwargs: Any 443 | ) -> 'unicode' 444 | ``` 445 | 446 | 447 | Generate a JSON representation of the model, `include` and `exclude` arguments as per `dict()`. 448 | 449 | `encoder` is an optional function to supply as `default` to json.dumps(), other arguments as per `json.dumps()`. 450 | 451 | ### TileMatrixSetRef 452 | 453 | ```python3 454 | class TileMatrixSetRef( 455 | __pydantic_self__, 456 | **data: Any 457 | ) 458 | ``` 459 | 460 | #### Ancestors (in MRO) 461 | 462 | * pydantic.main.BaseModel 463 | * pydantic.utils.Representation 464 | 465 | #### Class variables 466 | 467 | ```python3 468 | Config 469 | ``` 470 | 471 | #### Static methods 472 | 473 | 474 | #### construct 475 | 476 | ```python3 477 | def construct( 478 | _fields_set: Union[ForwardRef('SetStr'), NoneType] = None, 479 | **values: Any 480 | ) -> 'Model' 481 | ``` 482 | 483 | 484 | Creates a new model setting __dict__ and __fields_set__ from trusted or pre-validated data. 485 | 486 | Default values are respected, but no other validation is performed. 487 | Behaves as if `Config.extra = 'allow'` was set since it adds all passed values 488 | 489 | 490 | #### from_orm 491 | 492 | ```python3 493 | def from_orm( 494 | obj: Any 495 | ) -> 'Model' 496 | ``` 497 | 498 | 499 | 500 | 501 | #### parse_file 502 | 503 | ```python3 504 | def parse_file( 505 | path: Union[str, pathlib.Path], 506 | *, 507 | content_type: 'unicode' = None, 508 | encoding: 'unicode' = 'utf8', 509 | proto: pydantic.parse.Protocol = None, 510 | allow_pickle: bool = False 511 | ) -> 'Model' 512 | ``` 513 | 514 | 515 | 516 | 517 | #### parse_obj 518 | 519 | ```python3 520 | def parse_obj( 521 | obj: Any 522 | ) -> 'Model' 523 | ``` 524 | 525 | 526 | 527 | 528 | #### parse_raw 529 | 530 | ```python3 531 | def parse_raw( 532 | b: Union[str, bytes], 533 | *, 534 | content_type: 'unicode' = None, 535 | encoding: 'unicode' = 'utf8', 536 | proto: pydantic.parse.Protocol = None, 537 | allow_pickle: bool = False 538 | ) -> 'Model' 539 | ``` 540 | 541 | 542 | 543 | 544 | #### schema 545 | 546 | ```python3 547 | def schema( 548 | by_alias: bool = True, 549 | ref_template: 'unicode' = '#/definitions/{model}' 550 | ) -> 'DictStrAny' 551 | ``` 552 | 553 | 554 | 555 | 556 | #### schema_json 557 | 558 | ```python3 559 | def schema_json( 560 | *, 561 | by_alias: bool = True, 562 | ref_template: 'unicode' = '#/definitions/{model}', 563 | **dumps_kwargs: Any 564 | ) -> 'unicode' 565 | ``` 566 | 567 | 568 | 569 | 570 | #### update_forward_refs 571 | 572 | ```python3 573 | def update_forward_refs( 574 | **localns: Any 575 | ) -> None 576 | ``` 577 | 578 | 579 | Try to update ForwardRefs on fields based on this Model, globalns and localns. 580 | 581 | 582 | #### validate 583 | 584 | ```python3 585 | def validate( 586 | value: Any 587 | ) -> 'Model' 588 | ``` 589 | 590 | 591 | 592 | #### Methods 593 | 594 | 595 | #### copy 596 | 597 | ```python3 598 | def copy( 599 | self: 'Model', 600 | *, 601 | include: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 602 | exclude: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 603 | update: 'DictStrAny' = None, 604 | deep: bool = False 605 | ) -> 'Model' 606 | ``` 607 | 608 | 609 | Duplicate a model, optionally choose which fields to include, exclude and change. 610 | 611 | **Parameters:** 612 | 613 | | Name | Type | Description | Default | 614 | |---|---|---|---| 615 | | include | None | fields to include in new model | None | 616 | | exclude | None | fields to exclude from new model, as with values this takes precedence over include | None | 617 | | update | None | values to change/add in the new model. Note: the data is not validated before creating 618 | the new model: you should trust this data | None | 619 | | deep | None | set to `True` to make a deep copy of the model | None | 620 | 621 | **Returns:** 622 | 623 | | Type | Description | 624 | |---|---| 625 | | None | new model instance | 626 | 627 | 628 | #### dict 629 | 630 | ```python3 631 | def dict( 632 | self, 633 | *, 634 | include: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 635 | exclude: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 636 | by_alias: bool = False, 637 | skip_defaults: bool = None, 638 | exclude_unset: bool = False, 639 | exclude_defaults: bool = False, 640 | exclude_none: bool = False 641 | ) -> 'DictStrAny' 642 | ``` 643 | 644 | 645 | Generate a dictionary representation of the model, optionally specifying which fields to include or exclude. 646 | 647 | 648 | #### json 649 | 650 | ```python3 651 | def json( 652 | self, 653 | *, 654 | include: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 655 | exclude: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 656 | by_alias: bool = False, 657 | skip_defaults: bool = None, 658 | exclude_unset: bool = False, 659 | exclude_defaults: bool = False, 660 | exclude_none: bool = False, 661 | encoder: Union[Callable[[Any], Any], NoneType] = None, 662 | models_as_dict: bool = True, 663 | **dumps_kwargs: Any 664 | ) -> 'unicode' 665 | ``` 666 | 667 | 668 | Generate a JSON representation of the model, `include` and `exclude` arguments as per `dict()`. 669 | 670 | `encoder` is an optional function to supply as `default` to json.dumps(), other arguments as per `json.dumps()`. -------------------------------------------------------------------------------- /docs/src/api/timvt/models/mapbox.md: -------------------------------------------------------------------------------- 1 | # Module timvt.models.mapbox 2 | 3 | Tilejson response models. 4 | 5 | None 6 | 7 | ## Classes 8 | 9 | ### SchemeEnum 10 | 11 | ```python3 12 | class SchemeEnum( 13 | /, 14 | *args, 15 | **kwargs 16 | ) 17 | ``` 18 | 19 | #### Ancestors (in MRO) 20 | 21 | * builtins.str 22 | * enum.Enum 23 | 24 | #### Class variables 25 | 26 | ```python3 27 | name 28 | ``` 29 | 30 | ```python3 31 | tms 32 | ``` 33 | 34 | ```python3 35 | value 36 | ``` 37 | 38 | ```python3 39 | xyz 40 | ``` 41 | 42 | ### TileJSON 43 | 44 | ```python3 45 | class TileJSON( 46 | __pydantic_self__, 47 | **data: Any 48 | ) 49 | ``` 50 | 51 | #### Ancestors (in MRO) 52 | 53 | * pydantic.main.BaseModel 54 | * pydantic.utils.Representation 55 | 56 | #### Class variables 57 | 58 | ```python3 59 | Config 60 | ``` 61 | 62 | #### Static methods 63 | 64 | 65 | #### compute_center 66 | 67 | ```python3 68 | def compute_center( 69 | values 70 | ) 71 | ``` 72 | 73 | 74 | Compute center if it does not exist. 75 | 76 | 77 | #### construct 78 | 79 | ```python3 80 | def construct( 81 | _fields_set: Union[ForwardRef('SetStr'), NoneType] = None, 82 | **values: Any 83 | ) -> 'Model' 84 | ``` 85 | 86 | 87 | Creates a new model setting __dict__ and __fields_set__ from trusted or pre-validated data. 88 | 89 | Default values are respected, but no other validation is performed. 90 | Behaves as if `Config.extra = 'allow'` was set since it adds all passed values 91 | 92 | 93 | #### from_orm 94 | 95 | ```python3 96 | def from_orm( 97 | obj: Any 98 | ) -> 'Model' 99 | ``` 100 | 101 | 102 | 103 | 104 | #### parse_file 105 | 106 | ```python3 107 | def parse_file( 108 | path: Union[str, pathlib.Path], 109 | *, 110 | content_type: 'unicode' = None, 111 | encoding: 'unicode' = 'utf8', 112 | proto: pydantic.parse.Protocol = None, 113 | allow_pickle: bool = False 114 | ) -> 'Model' 115 | ``` 116 | 117 | 118 | 119 | 120 | #### parse_obj 121 | 122 | ```python3 123 | def parse_obj( 124 | obj: Any 125 | ) -> 'Model' 126 | ``` 127 | 128 | 129 | 130 | 131 | #### parse_raw 132 | 133 | ```python3 134 | def parse_raw( 135 | b: Union[str, bytes], 136 | *, 137 | content_type: 'unicode' = None, 138 | encoding: 'unicode' = 'utf8', 139 | proto: pydantic.parse.Protocol = None, 140 | allow_pickle: bool = False 141 | ) -> 'Model' 142 | ``` 143 | 144 | 145 | 146 | 147 | #### schema 148 | 149 | ```python3 150 | def schema( 151 | by_alias: bool = True, 152 | ref_template: 'unicode' = '#/definitions/{model}' 153 | ) -> 'DictStrAny' 154 | ``` 155 | 156 | 157 | 158 | 159 | #### schema_json 160 | 161 | ```python3 162 | def schema_json( 163 | *, 164 | by_alias: bool = True, 165 | ref_template: 'unicode' = '#/definitions/{model}', 166 | **dumps_kwargs: Any 167 | ) -> 'unicode' 168 | ``` 169 | 170 | 171 | 172 | 173 | #### update_forward_refs 174 | 175 | ```python3 176 | def update_forward_refs( 177 | **localns: Any 178 | ) -> None 179 | ``` 180 | 181 | 182 | Try to update ForwardRefs on fields based on this Model, globalns and localns. 183 | 184 | 185 | #### validate 186 | 187 | ```python3 188 | def validate( 189 | value: Any 190 | ) -> 'Model' 191 | ``` 192 | 193 | 194 | 195 | #### Methods 196 | 197 | 198 | #### copy 199 | 200 | ```python3 201 | def copy( 202 | self: 'Model', 203 | *, 204 | include: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 205 | exclude: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 206 | update: 'DictStrAny' = None, 207 | deep: bool = False 208 | ) -> 'Model' 209 | ``` 210 | 211 | 212 | Duplicate a model, optionally choose which fields to include, exclude and change. 213 | 214 | **Parameters:** 215 | 216 | | Name | Type | Description | Default | 217 | |---|---|---|---| 218 | | include | None | fields to include in new model | None | 219 | | exclude | None | fields to exclude from new model, as with values this takes precedence over include | None | 220 | | update | None | values to change/add in the new model. Note: the data is not validated before creating 221 | the new model: you should trust this data | None | 222 | | deep | None | set to `True` to make a deep copy of the model | None | 223 | 224 | **Returns:** 225 | 226 | | Type | Description | 227 | |---|---| 228 | | None | new model instance | 229 | 230 | 231 | #### dict 232 | 233 | ```python3 234 | def dict( 235 | self, 236 | *, 237 | include: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 238 | exclude: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 239 | by_alias: bool = False, 240 | skip_defaults: bool = None, 241 | exclude_unset: bool = False, 242 | exclude_defaults: bool = False, 243 | exclude_none: bool = False 244 | ) -> 'DictStrAny' 245 | ``` 246 | 247 | 248 | Generate a dictionary representation of the model, optionally specifying which fields to include or exclude. 249 | 250 | 251 | #### json 252 | 253 | ```python3 254 | def json( 255 | self, 256 | *, 257 | include: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 258 | exclude: Union[ForwardRef('AbstractSetIntStr'), ForwardRef('MappingIntStrAny')] = None, 259 | by_alias: bool = False, 260 | skip_defaults: bool = None, 261 | exclude_unset: bool = False, 262 | exclude_defaults: bool = False, 263 | exclude_none: bool = False, 264 | encoder: Union[Callable[[Any], Any], NoneType] = None, 265 | models_as_dict: bool = True, 266 | **dumps_kwargs: Any 267 | ) -> 'unicode' 268 | ``` 269 | 270 | 271 | Generate a JSON representation of the model, `include` and `exclude` arguments as per `dict()`. 272 | 273 | `encoder` is an optional function to supply as `default` to json.dumps(), other arguments as per `json.dumps()`. -------------------------------------------------------------------------------- /docs/src/api/timvt/resources/enums.md: -------------------------------------------------------------------------------- 1 | # Module timvt.resources.enums 2 | 3 | timvt.resources.enums. 4 | 5 | None 6 | 7 | ## Classes 8 | 9 | ### MimeTypes 10 | 11 | ```python3 12 | class MimeTypes( 13 | /, 14 | *args, 15 | **kwargs 16 | ) 17 | ``` 18 | 19 | #### Ancestors (in MRO) 20 | 21 | * builtins.str 22 | * enum.Enum 23 | 24 | #### Class variables 25 | 26 | ```python3 27 | geojson 28 | ``` 29 | 30 | ```python3 31 | html 32 | ``` 33 | 34 | ```python3 35 | json 36 | ``` 37 | 38 | ```python3 39 | mvt 40 | ``` 41 | 42 | ```python3 43 | name 44 | ``` 45 | 46 | ```python3 47 | pbf 48 | ``` 49 | 50 | ```python3 51 | text 52 | ``` 53 | 54 | ```python3 55 | value 56 | ``` 57 | 58 | ```python3 59 | xml 60 | ``` 61 | 62 | ### VectorType 63 | 64 | ```python3 65 | class VectorType( 66 | /, 67 | *args, 68 | **kwargs 69 | ) 70 | ``` 71 | 72 | #### Ancestors (in MRO) 73 | 74 | * builtins.str 75 | * enum.Enum 76 | 77 | #### Class variables 78 | 79 | ```python3 80 | mvt 81 | ``` 82 | 83 | ```python3 84 | name 85 | ``` 86 | 87 | ```python3 88 | pbf 89 | ``` 90 | 91 | ```python3 92 | value 93 | ``` -------------------------------------------------------------------------------- /docs/src/api/timvt/settings.md: -------------------------------------------------------------------------------- 1 | # Module timvt.settings 2 | 3 | TiMVT config. 4 | 5 | TiVTiler uses pydantic.BaseSettings to either get settings from `.env` or environment variables 6 | see: https://pydantic-docs.helpmanual.io/usage/settings/ 7 | 8 | ## Functions 9 | 10 | 11 | ### ApiSettings 12 | 13 | ```python3 14 | def ApiSettings( 15 | 16 | ) -> timvt.settings._ApiSettings 17 | ``` 18 | 19 | 20 | This function returns a cached instance of the APISettings object. 21 | 22 | Caching is used to prevent re-reading the environment every time the API settings are used in an endpoint. 23 | If you want to change an environment variable and reset the cache (e.g., during testing), this can be done 24 | using the `lru_cache` instance method `get_api_settings.cache_clear()`. 25 | 26 | From https://github.com/dmontagu/fastapi-utils/blob/af95ff4a8195caaa9edaa3dbd5b6eeb09691d9c7/fastapi_utils/api_settings.py#L60-L69 27 | 28 | 29 | ### PostgresSettings 30 | 31 | ```python3 32 | def PostgresSettings( 33 | 34 | ) -> timvt.settings._PostgresSettings 35 | ``` 36 | 37 | 38 | This function returns a cached instance of the APISettings object. 39 | 40 | Caching is used to prevent re-reading the environment every time the API settings are used in an endpoint. 41 | If you want to change an environment variable and reset the cache (e.g., during testing), this can be done 42 | using the `lru_cache` instance method `get_api_settings.cache_clear()`. 43 | 44 | From https://github.com/dmontagu/fastapi-utils/blob/af95ff4a8195caaa9edaa3dbd5b6eeb09691d9c7/fastapi_utils/api_settings.py#L60-L69 45 | 46 | 47 | ### TileSettings 48 | 49 | ```python3 50 | def TileSettings( 51 | 52 | ) -> timvt.settings._TileSettings 53 | ``` 54 | 55 | 56 | Cache settings. -------------------------------------------------------------------------------- /docs/src/contributing.md: -------------------------------------------------------------------------------- 1 | ../../CONTRIBUTING.md -------------------------------------------------------------------------------- /docs/src/function_layers.md: -------------------------------------------------------------------------------- 1 | 2 | As for [`pg_tileserv`](https://github.com/CrunchyData/pg_tileserv) and [`martin`](https://github.com/urbica/martin), TiMVT can support `Function` layer/source. 3 | 4 | `Functions` are database functions which can be used to create vector tiles and must of the form: 5 | 6 | ```sql 7 | CREATE FUNCTION name( 8 | -- bounding box 9 | xmin float, 10 | ymin float, 11 | xmax float, 12 | ymax float, 13 | -- EPSG (SRID) of the bounding box coordinates 14 | epsg integer, 15 | -- additional parameters 16 | query_params json 17 | ) 18 | RETURNS bytea 19 | ``` 20 | 21 | Argument | Type | Description 22 | ------------ | ----- | ----------------------- 23 | xmin | float | left coordinate 24 | ymin | float | bottom coordinate 25 | xmax | float | right coordinate 26 | ymax | float | top coordinate 27 | epsg | float | bounding box EPSG (SRID) number 28 | query_params | json | Additional Query string parameters 29 | 30 | ### Query Parameters 31 | 32 | `TiMVT` will forward all query parameters to the function as a JSON object. It's on the user to properly parse the JSON object in the database function. 33 | 34 | ```python 35 | url = "https://endpoint/tiles/my_function/1/1/1?value1=2&value2=3" 36 | query_params = '{"value1": "2", "value2": "3"}' 37 | 38 | url = "https://endpoint/tiles/my_function/1/1/1?v=2&v=3" 39 | query_params = '{"v": ["2", "3"]}' 40 | ``` 41 | !!! important 42 | 43 | `Functions` are not *hard coded* into the database but dynamically registered/unregistered by the application on each tile call. 44 | 45 | ## Minimal Application 46 | 47 | ```python 48 | from timvt.db import close_db_connection, connect_to_db 49 | from timvt.factory import VectorTilerFactory 50 | from timvt.layer import FunctionRegistry 51 | from timvt.layer import Function 52 | 53 | from fastapi import FastAPI, Request 54 | 55 | 56 | # Create FastAPI Application. 57 | app = FastAPI() 58 | 59 | # Add Function registery to the application state 60 | app.state.timvt_function_catalog = FunctionRegistry() 61 | 62 | # Register Start/Stop application event handler to setup/stop the database connection 63 | # and populate `app.state.table_catalog` 64 | @app.on_event("startup") 65 | async def startup_event(): 66 | """Application startup: register the database connection and create table list.""" 67 | await connect_to_db(app) 68 | 69 | @app.on_event("shutdown") 70 | async def shutdown_event(): 71 | """Application shutdown: de-register the database connection.""" 72 | await close_db_connection(app) 73 | 74 | # Register Function to the application internal registry 75 | app.state.timvt_function_catalog.register( 76 | Function.from_file( 77 | id="squares", # By default TiMVT will call a function call `squares` 78 | infile="my_sql_file.sql", # PATH TO SQL FILE 79 | ) 80 | ) 81 | 82 | # Register endpoints 83 | mvt_tiler = VectorTilerFactory( 84 | with_tables_metadata=True, 85 | with_functions_metadata=True, # add Functions metadata endpoints (/functions.json, /{function_name}.json) 86 | with_viewer=True, 87 | ) 88 | app.include_router(mvt_tiler.router) 89 | ``` 90 | 91 | !!! Important 92 | 93 | A function `Registry` object (timvt.layer.FunctionRegistry) should be initialized and stored within the application **state**. TiMVT assumes `app.state.timvt_function_catalog` is where the registry is. 94 | 95 | ## Function Options 96 | 97 | When registering a `Function`, the user can set different options: 98 | 99 | - **id** (required): name of the Layer which will then be used in the endpoint routes. 100 | - **sql** (required): SQL code 101 | - **function_name**: name of the SQL function within the SQL code. Defaults to `id`. 102 | - **bounds**: Bounding Box for the area of usage (this is for `documentation` only). 103 | - **minzoom**: minimum zoom level (this is for `documentation` only). 104 | - **maxzoom**: maximum zoom level (this is for `documentation` only). 105 | - **options**: List of options available per function (this is for `documentation` only). 106 | 107 | ```python 108 | from timvt.layer import Function 109 | 110 | 111 | # Function with Options 112 | Function( 113 | id="squares2", 114 | sql=""" 115 | CREATE FUNCTION squares_but_not_squares( 116 | xmin float, 117 | ymin float, 118 | xmax float, 119 | ymax float, 120 | epsg integer, 121 | query_params json 122 | ) 123 | RETURNS bytea AS $$ 124 | ... 125 | """, 126 | function_name="squares_but_not_squares", # This allows to call a specific function within the SQL code 127 | bounds=[0.0, 0.0, 180.0, 90.0], # overwrite default bounds 128 | minzoom=9, # overwrite default minzoom 129 | maxzoom=24, # overwrite default maxzoom 130 | options={ # Provide arguments information for documentation 131 | {"name": "depth", "default": 2} 132 | } 133 | ) 134 | 135 | # Using `from_file` class method 136 | Function.from_file( 137 | id="squares2", 138 | infile="directory/my_sql_file.sql", # PATH TO SQL FILE 139 | function_name="squares_but_not_squares", # This allows to call a specific function within the SQL code 140 | bounds=[0.0, 0.0, 180.0, 90.0], # overwrite default bounds 141 | minzoom=9, # overwrite default minzoom 142 | maxzoom=24, # overwrite default maxzoom 143 | options={ # Provide arguments information for documentation 144 | {"name": "depth", "default": 2} 145 | } 146 | ) 147 | ``` 148 | 149 | ## Function Layer Examples 150 | 151 | ### Dynamic Geometry Example 152 | 153 | Goal: Sub-divide input BBOX in smaller squares. 154 | 155 | ```sql 156 | CREATE OR REPLACE FUNCTION squares( 157 | -- mandatory parameters 158 | xmin float, 159 | ymin float, 160 | xmax float, 161 | ymax float, 162 | epsg integer, 163 | -- additional parameters 164 | query_params json 165 | ) 166 | RETURNS bytea AS $$ 167 | DECLARE 168 | result bytea; 169 | sq_width float; 170 | bbox_xmin float; 171 | bbox_ymin float; 172 | bounds geometry; 173 | depth integer; 174 | BEGIN 175 | -- Find the bbox bounds 176 | bounds := ST_MakeEnvelope(xmin, ymin, xmax, ymax, epsg); 177 | 178 | -- Find the bottom corner of the bounds 179 | bbox_xmin := ST_XMin(bounds); 180 | bbox_ymin := ST_YMin(bounds); 181 | 182 | -- Get Depth from the query_params object 183 | depth := coalesce((query_params ->> 'depth')::int, 2); 184 | 185 | -- We want bbox divided up into depth*depth squares per bbox, 186 | -- so what is the width of a square? 187 | sq_width := (ST_XMax(bounds) - ST_XMin(bounds)) / depth; 188 | 189 | WITH mvtgeom AS ( 190 | SELECT 191 | -- Fill in the bbox with all the squares 192 | ST_AsMVTGeom( 193 | ST_SetSRID( 194 | ST_MakeEnvelope( 195 | bbox_xmin + sq_width * (a - 1), 196 | bbox_ymin + sq_width * (b - 1), 197 | bbox_xmin + sq_width * a, 198 | bbox_ymin + sq_width * b 199 | ), 200 | epsg 201 | ), 202 | bounds 203 | ) 204 | 205 | -- Drive the square generator with a two-dimensional 206 | -- generate_series setup 207 | FROM generate_series(1, depth) a, generate_series(1, depth) b 208 | ) 209 | SELECT ST_AsMVT(mvtgeom.*, 'default') 210 | 211 | -- Put the query result into the result variale. 212 | INTO result FROM mvtgeom; 213 | 214 | -- Return the answer 215 | RETURN result; 216 | END; 217 | $$ 218 | LANGUAGE 'plpgsql' 219 | IMMUTABLE -- Same inputs always give same outputs 220 | STRICT -- Null input gets null output 221 | PARALLEL SAFE; 222 | ``` 223 | 224 | ## Extending the Function layer 225 | 226 | As mentioned early, `Function` takes bounding box and EPSG number as input to support multiple TileMatrixSet. If you only want to support one `pre-defined` TMS (e.g `WebMercator`) you could have functions taking `X,Y,Z` inputs: 227 | 228 | Example of XYZ function: 229 | ```sql 230 | CREATE OR REPLACE FUNCTION xyz( 231 | z integer, 232 | x integer, 233 | y integer, 234 | query_params json 235 | ) 236 | RETURNS bytea 237 | AS $$ 238 | DECLARE 239 | table_name text; 240 | result bytea; 241 | BEGIN 242 | table_name := query_params ->> 'table'; 243 | 244 | WITH 245 | bounds AS ( 246 | SELECT ST_TileEnvelope(z, x, y) AS geom 247 | ), 248 | mvtgeom AS ( 249 | SELECT ST_AsMVTGeom(ST_Transform(t.geom, 3857), bounds.geom) AS geom, t.name 250 | FROM table_name t, bounds 251 | WHERE ST_Intersects(t.geom, ST_Transform(bounds.geom, 4326)) 252 | ) 253 | SELECT ST_AsMVT(mvtgeom, table_name) 254 | INTO result 255 | FROM mvtgeom; 256 | 257 | RETURN result; 258 | END; 259 | $$ 260 | LANGUAGE 'plpgsql' 261 | STABLE 262 | PARALLEL SAFE; 263 | ``` 264 | 265 | In order to support those function, you'll need to `extend` the `Funcion` class: 266 | 267 | ```python 268 | # custom.py 269 | from typing import Any 270 | import morecantile 271 | from buildpg import asyncpg 272 | 273 | from timvt import layer 274 | 275 | class Function(layer.Function): 276 | "Custom Function Layer: SQL function takes xyz input.""" 277 | 278 | async def get_tile( 279 | self, 280 | pool: asyncpg.BuildPgPool, 281 | tile: morecantile.Tile, 282 | tms: morecantile.TileMatrixSet, # tms won't be used here 283 | **kwargs: Any, 284 | ): 285 | """Custom Get Tile method.""" 286 | 287 | async with pool.acquire() as conn: 288 | transaction = conn.transaction() 289 | await transaction.start() 290 | await conn.execute(self.sql) 291 | 292 | sql_query = clauses.Select( 293 | Func( 294 | self.function_name, 295 | ":x", 296 | ":y", 297 | ":z", 298 | ":query_params", 299 | ), 300 | ) 301 | q, p = render( 302 | str(sql_query), 303 | x=tile.x, 304 | y=tile.y, 305 | z=tile.z, 306 | query_params=json.dumps(kwargs), 307 | ) 308 | 309 | # execute the query 310 | content = await conn.fetchval(q, *p) 311 | 312 | # rollback 313 | await transaction.rollback() 314 | 315 | return content 316 | ``` 317 | -------------------------------------------------------------------------------- /docs/src/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/timvt/b1c6f6158a05708365fb68f037b53481cffe9383/docs/src/img/favicon.ico -------------------------------------------------------------------------------- /docs/src/index.md: -------------------------------------------------------------------------------- 1 | ../../README.md -------------------------------------------------------------------------------- /docs/src/overrides/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | {% if page.nb_url %} 5 | 6 | {% include ".icons/material/download.svg" %} 7 | 8 | {% endif %} 9 | 10 | {{ super() }} 11 | {% endblock content %} 12 | -------------------------------------------------------------------------------- /docs/src/release-notes.md: -------------------------------------------------------------------------------- 1 | ../../CHANGES.md -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "timvt" 3 | description = "A lightweight PostGIS based dynamic vector tile server." 4 | readme = "README.md" 5 | requires-python = ">=3.8" 6 | license = {file = "LICENSE"} 7 | authors = [ 8 | {name = "Vincent Sarago", email = "vincent@developmentseed.org"}, 9 | {name = "David Bitner", email = "david@developmentseed.org"}, 10 | ] 11 | keywords = ["FastAPI", "MVT", "POSTGIS"] 12 | classifiers = [ 13 | "Intended Audience :: Information Technology", 14 | "Intended Audience :: Science/Research", 15 | "License :: OSI Approved :: BSD License", 16 | "Programming Language :: Python :: 3.8", 17 | "Programming Language :: Python :: 3.9", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Topic :: Scientific/Engineering :: GIS", 21 | ] 22 | dynamic = ["version"] 23 | dependencies = [ 24 | "orjson", 25 | "asyncpg>=0.23.0", 26 | "buildpg>=0.3", 27 | "fastapi>=0.87", 28 | "jinja2>=2.11.2,<4.0.0", 29 | "morecantile>=3.1,<4.0", 30 | "starlette-cramjam>=0.3,<0.4", 31 | "importlib_resources>=1.1.0; python_version < '3.9'", 32 | "typing_extensions; python_version < '3.9.2'", 33 | ] 34 | 35 | [project.optional-dependencies] 36 | test = [ 37 | "pytest", 38 | "pytest-cov", 39 | "pytest-asyncio", 40 | "pytest-benchmark", 41 | "httpx", 42 | "psycopg2", 43 | "pytest-pgsql", 44 | "mapbox-vector-tile", 45 | "protobuf>=3.0,<4.0", 46 | "numpy", 47 | "sqlalchemy>=1.1,<1.4", 48 | ] 49 | dev = [ 50 | "pre-commit", 51 | ] 52 | server = [ 53 | "uvicorn[standard]>=0.12.0,<0.19.0", 54 | ] 55 | docs = [ 56 | "nbconvert", 57 | "mkdocs", 58 | "mkdocs-material", 59 | "mkdocs-jupyter", 60 | "pygments", 61 | "pdocs", 62 | ] 63 | 64 | [project.urls] 65 | Homepage = "https://developmentseed.org/timvt/" 66 | Source = "https://github.com/developmentseed/timvt" 67 | Documentation = "https://developmentseed.org/timvt/" 68 | 69 | [tool.hatch.version] 70 | path = "timvt/__init__.py" 71 | 72 | [tool.hatch.build.targets.sdist] 73 | exclude = [ 74 | "/tests", 75 | "/dockerfiles", 76 | "/docs", 77 | "/demo", 78 | "/data", 79 | "docker-compose.yml", 80 | "CONTRIBUTING.md", 81 | "CHANGES.md", 82 | ".pytest_cache", 83 | ".history", 84 | ".github", 85 | ".env.example", 86 | ".bumpversion.cfg", 87 | ".flake8", 88 | ".gitignore", 89 | ".pre-commit-config.yaml", 90 | ] 91 | 92 | [build-system] 93 | requires = ["hatchling"] 94 | build-backend = "hatchling.build" 95 | 96 | [tool.isort] 97 | profile = "black" 98 | known_first_party = ["timvt"] 99 | known_third_party = [ 100 | "morecantile", 101 | ] 102 | forced_separate = [ 103 | "fastapi", 104 | "starlette", 105 | ] 106 | default_section = "THIRDPARTY" 107 | 108 | [tool.mypy] 109 | no_strict_optional = "True" 110 | 111 | [tool.pydocstyle] 112 | select = "D1" 113 | match = "(?!test).*.py" 114 | -------------------------------------------------------------------------------- /tests/benchmarks.py: -------------------------------------------------------------------------------- 1 | """Benchmark tile.""" 2 | 3 | import pytest 4 | 5 | 6 | @pytest.mark.parametrize("tms", ["WGS1984Quad", "WebMercatorQuad"]) 7 | @pytest.mark.parametrize("tile", ["0/0/0", "4/8/5", "6/33/25"]) 8 | def test_benchmark_tile(benchmark, tile, tms, app): 9 | """Benchmark items endpoint.""" 10 | 11 | def f(input_tms, input_tile): 12 | return app.get(f"/tiles/{input_tms}/public.landsat_wrs/{input_tile}") 13 | 14 | benchmark.group = f"table-{tms}" 15 | 16 | response = benchmark(f, tms, tile) 17 | assert response.status_code == 200 18 | 19 | 20 | @pytest.mark.parametrize("tms", ["WGS1984Quad", "WebMercatorQuad"]) 21 | @pytest.mark.parametrize("tile", ["0/0/0", "4/8/5", "6/33/25"]) 22 | def test_benchmark_tile_functions(benchmark, tile, tms, app): 23 | """Benchmark items endpoint.""" 24 | 25 | def f(input_tms, input_tile): 26 | return app.get(f"/tiles/{input_tms}/squares/{input_tile}") 27 | 28 | benchmark.group = f"function-{tms}" 29 | 30 | response = benchmark(f, tms, tile) 31 | assert response.status_code == 200 32 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """``pytest`` configuration.""" 2 | 3 | import os 4 | 5 | import pytest 6 | import pytest_pgsql 7 | 8 | from starlette.testclient import TestClient 9 | 10 | DATA_DIR = os.path.join(os.path.dirname(__file__), "fixtures") 11 | 12 | 13 | test_db = pytest_pgsql.TransactedPostgreSQLTestDB.create_fixture( 14 | "test_db", scope="session", use_restore_state=False 15 | ) 16 | 17 | 18 | @pytest.fixture(scope="session") 19 | def database_url(test_db): 20 | """ 21 | Session scoped fixture to launch a postgresql database in a separate process. We use psycopg2 to ingest test data 22 | because pytest-asyncio event loop is a function scoped fixture and cannot be called within the current scope. Yields 23 | a database url which we pass to our application through a monkeypatched environment variable. 24 | """ 25 | assert test_db.install_extension("postgis") 26 | test_db.run_sql_file(os.path.join(DATA_DIR, "data", "landsat_wrs.sql")) 27 | assert test_db.has_table("landsat_wrs") 28 | return test_db.connection.engine.url 29 | 30 | 31 | @pytest.fixture(autouse=True) 32 | def app(database_url, monkeypatch): 33 | """Create app with connection to the pytest database.""" 34 | monkeypatch.setenv("DATABASE_URL", str(database_url)) 35 | monkeypatch.setenv("TIMVT_DEFAULT_MINZOOM", str(5)) 36 | monkeypatch.setenv("TIMVT_DEFAULT_MAXZOOM", str(12)) 37 | monkeypatch.setenv("TIMVT_FUNCTIONS_DIRECTORY", DATA_DIR) 38 | 39 | from timvt.layer import Function 40 | from timvt.main import app 41 | 42 | # Register the same function but we different options 43 | app.state.timvt_function_catalog.register( 44 | Function.from_file( 45 | id="squares2", 46 | infile=os.path.join(DATA_DIR, "squares.sql"), 47 | function_name="squares", 48 | minzoom=0, 49 | maxzoom=9, 50 | bounds=[0.0, 0.0, 180.0, 90.0], 51 | options=[{"name": "depth", "default": 2}], 52 | ) 53 | ) 54 | 55 | with TestClient(app) as app: 56 | yield app 57 | -------------------------------------------------------------------------------- /tests/fixtures/landsat_poly_centroid.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION landsat_poly_centroid( 2 | -- mandatory parameters 3 | xmin float, 4 | ymin float, 5 | xmax float, 6 | ymax float, 7 | epsg integer, 8 | -- additional parameters 9 | query_params json 10 | ) 11 | RETURNS bytea 12 | AS $$ 13 | DECLARE 14 | bounds geometry; 15 | tablename text; 16 | result bytea; 17 | BEGIN 18 | WITH 19 | -- Create bbox enveloppe in given EPSG 20 | bounds AS ( 21 | SELECT ST_MakeEnvelope(xmin, ymin, xmax, ymax, epsg) AS geom 22 | ), 23 | selected_geom AS ( 24 | SELECT t.* 25 | FROM public.landsat_wrs t, bounds 26 | WHERE ST_Intersects(t.geom, ST_Transform(bounds.geom, 4326)) 27 | ), 28 | mvtgeom AS ( 29 | SELECT 30 | ST_AsMVTGeom(ST_Transform(ST_Centroid(t.geom), epsg), bounds.geom) AS geom, t.path, t.row 31 | FROM selected_geom t, bounds 32 | UNION 33 | SELECT ST_AsMVTGeom(ST_Transform(t.geom, epsg), bounds.geom) AS geom, t.path, t.row 34 | FROM selected_geom t, bounds 35 | ) 36 | SELECT ST_AsMVT(mvtgeom.*, 'default') 37 | 38 | -- Put the query result into the result variale. 39 | INTO result FROM mvtgeom; 40 | 41 | -- Return the answer 42 | RETURN result; 43 | END; 44 | $$ 45 | LANGUAGE 'plpgsql' 46 | IMMUTABLE -- Same inputs always give same outputs 47 | STRICT -- Null input gets null output 48 | PARALLEL SAFE; 49 | 50 | COMMENT ON FUNCTION landsat_poly_centroid IS 'Return Combined Polygon/Centroid geometries from landsat table.'; 51 | -------------------------------------------------------------------------------- /tests/fixtures/squares.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION squares( 2 | -- mandatory parameters 3 | xmin float, 4 | ymin float, 5 | xmax float, 6 | ymax float, 7 | epsg integer, 8 | -- additional parameters 9 | query_params json 10 | ) 11 | RETURNS bytea AS $$ 12 | DECLARE 13 | result bytea; 14 | sq_width float; 15 | bbox_xmin float; 16 | bbox_ymin float; 17 | bounds geometry; 18 | depth integer; 19 | BEGIN 20 | -- Find the bbox bounds 21 | bounds := ST_MakeEnvelope(xmin, ymin, xmax, ymax, epsg); 22 | 23 | -- Find the bottom corner of the bounds 24 | bbox_xmin := ST_XMin(bounds); 25 | bbox_ymin := ST_YMin(bounds); 26 | 27 | -- We want bbox divided up into depth*depth squares per bbox, 28 | -- so what is the width of a square? 29 | depth := coalesce((query_params ->> 'depth')::int, 2); 30 | 31 | sq_width := (ST_XMax(bounds) - ST_XMin(bounds)) / depth; 32 | 33 | WITH mvtgeom AS ( 34 | SELECT 35 | -- Fill in the bbox with all the squares 36 | ST_AsMVTGeom( 37 | ST_SetSRID( 38 | ST_MakeEnvelope( 39 | bbox_xmin + sq_width * (a - 1), 40 | bbox_ymin + sq_width * (b - 1), 41 | bbox_xmin + sq_width * a, 42 | bbox_ymin + sq_width * b 43 | ), 44 | epsg 45 | ), 46 | bounds 47 | ) 48 | 49 | -- Drive the square generator with a two-dimensional 50 | -- generate_series setup 51 | FROM generate_series(1, depth) a, generate_series(1, depth) b 52 | ) 53 | SELECT ST_AsMVT(mvtgeom.*, 'default') 54 | 55 | -- Put the query result into the result variale. 56 | INTO result FROM mvtgeom; 57 | 58 | -- Return the answer 59 | RETURN result; 60 | END; 61 | $$ 62 | LANGUAGE 'plpgsql' 63 | IMMUTABLE -- Same inputs always give same outputs 64 | STRICT -- Null input gets null output 65 | PARALLEL SAFE; 66 | -------------------------------------------------------------------------------- /tests/routes/__init__.py: -------------------------------------------------------------------------------- 1 | """timvt route tests.""" 2 | -------------------------------------------------------------------------------- /tests/routes/test_metadata.py: -------------------------------------------------------------------------------- 1 | """test demo endpoints.""" 2 | 3 | import numpy as np 4 | 5 | 6 | def test_table_index(app): 7 | """test /tables.json endpoint.""" 8 | response = app.get("/tables.json") 9 | assert response.status_code == 200 10 | body = response.json() 11 | assert len(body) == 1 12 | assert body[0]["id"] == "public.landsat_wrs" 13 | assert body[0]["bounds"] 14 | assert body[0]["tileurl"] 15 | 16 | 17 | def test_table_info(app): 18 | """Test metadata endpoint.""" 19 | response = app.get("/table/public.landsat_wrs.json") 20 | assert response.status_code == 200 21 | resp_json = response.json() 22 | assert resp_json["id"] == "public.landsat_wrs" 23 | assert resp_json["minzoom"] == 5 24 | assert resp_json["maxzoom"] == 12 25 | assert resp_json["tileurl"] 26 | 27 | np.testing.assert_almost_equal( 28 | resp_json["bounds"], [-180.0, -82.6401062011719, 180.0, 82.6401062011719] 29 | ) 30 | 31 | 32 | def test_function_index(app): 33 | """test /functions.json endpoint.""" 34 | response = app.get("/functions.json") 35 | assert response.status_code == 200 36 | body = response.json() 37 | assert len(body) == 3 38 | 39 | func = list(filter(lambda x: x["id"] == "landsat_poly_centroid", body))[0] 40 | assert func["id"] == "landsat_poly_centroid" 41 | assert func["function_name"] == "landsat_poly_centroid" 42 | assert func["bounds"] 43 | assert func["tileurl"] 44 | assert "options" not in func 45 | 46 | func = list(filter(lambda x: x["id"] == "squares", body))[0] 47 | assert func["id"] == "squares" 48 | assert func["function_name"] == "squares" 49 | assert func["bounds"] 50 | assert func["tileurl"] 51 | assert "options" not in func 52 | 53 | func = list(filter(lambda x: x["id"] == "squares2", body))[0] 54 | assert func["id"] == "squares2" 55 | assert func["function_name"] == "squares" 56 | assert func["bounds"] == [0.0, 0.0, 180.0, 90.0] 57 | assert func["tileurl"] 58 | assert func["options"] == [{"name": "depth", "default": 2}] 59 | 60 | 61 | def test_function_info(app): 62 | """Test metadata endpoint.""" 63 | response = app.get("/function/squares.json") 64 | assert response.status_code == 200 65 | resp_json = response.json() 66 | assert resp_json["id"] == "squares" 67 | assert resp_json["function_name"] == "squares" 68 | assert resp_json["minzoom"] == 5 69 | assert resp_json["maxzoom"] == 12 70 | assert resp_json["tileurl"] 71 | assert "options" not in resp_json 72 | np.testing.assert_almost_equal(resp_json["bounds"], [-180, -90, 180, 90]) 73 | 74 | response = app.get("/function/squares2.json") 75 | assert response.status_code == 200 76 | resp_json = response.json() 77 | assert resp_json["id"] == "squares2" 78 | assert resp_json["function_name"] == "squares" 79 | assert resp_json["minzoom"] == 0 80 | assert resp_json["maxzoom"] == 9 81 | assert resp_json["tileurl"] 82 | assert resp_json["options"] == [{"name": "depth", "default": 2}] 83 | np.testing.assert_almost_equal(resp_json["bounds"], [0.0, 0.0, 180.0, 90.0]) 84 | -------------------------------------------------------------------------------- /tests/routes/test_tiles.py: -------------------------------------------------------------------------------- 1 | """Test Tiles endpoints.""" 2 | 3 | import mapbox_vector_tile 4 | import numpy as np 5 | 6 | 7 | def test_tilejson(app): 8 | """Test TileJSON endpoint.""" 9 | response = app.get("/public.landsat_wrs/tilejson.json") 10 | assert response.status_code == 200 11 | 12 | resp_json = response.json() 13 | assert resp_json["name"] == "public.landsat_wrs" 14 | assert resp_json["minzoom"] == 5 15 | assert resp_json["maxzoom"] == 12 16 | 17 | np.testing.assert_almost_equal( 18 | resp_json["bounds"], [-180.0, -82.6401062011719, 180.0, 82.6401062011719] 19 | ) 20 | 21 | response = app.get("/public.landsat_wrs/tilejson.json?minzoom=1&maxzoom=2") 22 | assert response.status_code == 200 23 | 24 | resp_json = response.json() 25 | assert resp_json["name"] == "public.landsat_wrs" 26 | assert resp_json["minzoom"] == 1 27 | assert resp_json["maxzoom"] == 2 28 | 29 | response = app.get( 30 | "/public.landsat_wrs/tilejson.json?minzoom=1&maxzoom=2&limit=1000" 31 | ) 32 | assert response.status_code == 200 33 | 34 | resp_json = response.json() 35 | assert resp_json["name"] == "public.landsat_wrs" 36 | assert resp_json["minzoom"] == 1 37 | assert resp_json["maxzoom"] == 2 38 | assert "?limit=1000" in resp_json["tiles"][0] 39 | 40 | 41 | def test_tile(app): 42 | """request a tile.""" 43 | response = app.get("/tiles/public.landsat_wrs/0/0/0") 44 | assert response.status_code == 200 45 | decoded = mapbox_vector_tile.decode(response.content) 46 | assert len(decoded["default"]["features"]) == 10000 47 | 48 | response = app.get("/tiles/public.landsat_wrs/0/0/0?limit=1000") 49 | assert response.status_code == 200 50 | decoded = mapbox_vector_tile.decode(response.content) 51 | assert len(decoded["default"]["features"]) == 1000 52 | assert sorted(["id", "pr", "row", "path", "ogc_fid"]) == sorted( 53 | list(decoded["default"]["features"][0]["properties"]) 54 | ) 55 | 56 | response = app.get("/tiles/public.landsat_wrs/0/0/0?limit=1&columns=pr,row,path") 57 | assert response.status_code == 200 58 | decoded = mapbox_vector_tile.decode(response.content) 59 | assert sorted(["pr", "row", "path"]) == sorted( 60 | list(decoded["default"]["features"][0]["properties"]) 61 | ) 62 | 63 | response = app.get("/tiles/public.landsat_wrs/0/0/0?geom=geom") 64 | assert response.status_code == 200 65 | decoded = mapbox_vector_tile.decode(response.content) 66 | assert len(decoded["default"]["features"]) == 10000 67 | 68 | # invalid geometry column name 69 | response = app.get("/tiles/public.landsat_wrs/0/0/0?geom=the_geom") 70 | assert response.status_code == 404 71 | 72 | 73 | def test_tile_tms(app): 74 | """request a tile with specific TMS.""" 75 | response = app.get("/tiles/WorldCRS84Quad/public.landsat_wrs/0/0/0") 76 | assert response.status_code == 200 77 | decoded = mapbox_vector_tile.decode(response.content) 78 | assert len(decoded["default"]["features"]) > 1000 79 | 80 | response = app.get("/tiles/WorldCRS84Quad/public.landsat_wrs/0/0/0?limit=1000") 81 | assert response.status_code == 200 82 | decoded = mapbox_vector_tile.decode(response.content) 83 | assert len(decoded["default"]["features"]) <= 1000 84 | assert sorted(["id", "pr", "row", "path", "ogc_fid"]) == sorted( 85 | list(decoded["default"]["features"][0]["properties"]) 86 | ) 87 | 88 | response = app.get( 89 | "/tiles/WorldCRS84Quad/public.landsat_wrs/0/0/0?limit=1&columns=pr,row,path" 90 | ) 91 | assert response.status_code == 200 92 | decoded = mapbox_vector_tile.decode(response.content) 93 | assert sorted(["pr", "row", "path"]) == sorted( 94 | list(decoded["default"]["features"][0]["properties"]) 95 | ) 96 | 97 | 98 | def test_function_tilejson(app): 99 | """Test TileJSON endpoint.""" 100 | response = app.get("/squares/tilejson.json") 101 | assert response.status_code == 200 102 | resp_json = response.json() 103 | assert resp_json["name"] == "squares" 104 | assert resp_json["minzoom"] == 5 105 | assert resp_json["maxzoom"] == 12 106 | np.testing.assert_almost_equal(resp_json["bounds"], [-180.0, -90, 180.0, 90]) 107 | 108 | response = app.get("/squares/tilejson.json?minzoom=1&maxzoom=2") 109 | assert response.status_code == 200 110 | resp_json = response.json() 111 | assert resp_json["name"] == "squares" 112 | assert resp_json["minzoom"] == 1 113 | assert resp_json["maxzoom"] == 2 114 | 115 | response = app.get("/squares/tilejson.json?minzoom=1&maxzoom=2&depth=4") 116 | assert response.status_code == 200 117 | resp_json = response.json() 118 | assert resp_json["name"] == "squares" 119 | assert resp_json["minzoom"] == 1 120 | assert resp_json["maxzoom"] == 2 121 | assert "?depth=4" in resp_json["tiles"][0] 122 | 123 | 124 | def test_function_tile(app): 125 | """request a tile.""" 126 | response = app.get("/tiles/squares/0/0/0") 127 | assert response.status_code == 200 128 | decoded = mapbox_vector_tile.decode(response.content) 129 | assert len(decoded["default"]["features"]) == 4 130 | 131 | response = app.get("/tiles/squares/0/0/0?depth=4") 132 | assert response.status_code == 200 133 | decoded = mapbox_vector_tile.decode(response.content) 134 | assert len(decoded["default"]["features"]) == 16 135 | -------------------------------------------------------------------------------- /tests/routes/test_tms.py: -------------------------------------------------------------------------------- 1 | """test TileMatrixSets endpoints.""" 2 | 3 | from morecantile import tms 4 | 5 | 6 | def test_tilematrix(app): 7 | """test /tileMatrixSet endpoint.""" 8 | response = app.get("/tileMatrixSets") 9 | assert response.status_code == 200 10 | body = response.json() 11 | 12 | assert len(body["tileMatrixSets"]) == len(tms.list()) 13 | tileMatrixSets = list( 14 | filter(lambda m: m["id"] == "WebMercatorQuad", body["tileMatrixSets"]) 15 | )[0] 16 | assert ( 17 | tileMatrixSets["links"][0]["href"] 18 | == "http://testserver/tileMatrixSets/WebMercatorQuad" 19 | ) 20 | 21 | 22 | def test_tilematrixInfo(app): 23 | """test /tileMatrixSet endpoint.""" 24 | response = app.get("/tileMatrixSets/WebMercatorQuad") 25 | assert response.status_code == 200 26 | body = response.json() 27 | assert body["type"] == "TileMatrixSetType" 28 | assert body["identifier"] == "WebMercatorQuad" 29 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | """Test timvt.main.app.""" 2 | 3 | 4 | def test_health(app): 5 | """Test /healthz endpoint.""" 6 | response = app.get("/healthz") 7 | assert response.status_code == 200 8 | assert response.json() == {"ping": "pong!"} 9 | -------------------------------------------------------------------------------- /timvt/__init__.py: -------------------------------------------------------------------------------- 1 | """timvt.""" 2 | 3 | __version__ = "0.8.0a3" 4 | -------------------------------------------------------------------------------- /timvt/db.py: -------------------------------------------------------------------------------- 1 | """timvt.db: database events.""" 2 | 3 | from typing import Any, Optional 4 | 5 | import orjson 6 | from buildpg import asyncpg 7 | 8 | from timvt.dbmodel import get_table_index 9 | from timvt.settings import PostgresSettings 10 | 11 | from fastapi import FastAPI 12 | 13 | 14 | async def con_init(conn): 15 | """Use json for json returns.""" 16 | await conn.set_type_codec( 17 | "json", encoder=orjson.dumps, decoder=orjson.loads, schema="pg_catalog" 18 | ) 19 | await conn.set_type_codec( 20 | "jsonb", encoder=orjson.dumps, decoder=orjson.loads, schema="pg_catalog" 21 | ) 22 | 23 | 24 | async def connect_to_db( 25 | app: FastAPI, 26 | settings: Optional[PostgresSettings] = None, 27 | **kwargs, 28 | ) -> None: 29 | """Connect.""" 30 | if not settings: 31 | settings = PostgresSettings() 32 | 33 | app.state.pool = await asyncpg.create_pool_b( 34 | settings.database_url, 35 | min_size=settings.db_min_conn_size, 36 | max_size=settings.db_max_conn_size, 37 | max_queries=settings.db_max_queries, 38 | max_inactive_connection_lifetime=settings.db_max_inactive_conn_lifetime, 39 | init=con_init, 40 | **kwargs, 41 | ) 42 | 43 | 44 | async def register_table_catalog(app: FastAPI, **kwargs: Any) -> None: 45 | """Register Table catalog.""" 46 | app.state.table_catalog = await get_table_index(app.state.pool, **kwargs) 47 | 48 | 49 | async def close_db_connection(app: FastAPI) -> None: 50 | """Close connection.""" 51 | await app.state.pool.close() 52 | -------------------------------------------------------------------------------- /timvt/dbmodel.py: -------------------------------------------------------------------------------- 1 | """timvt.dbmodel: database events.""" 2 | 3 | from typing import Any, Dict, List, Optional 4 | 5 | from buildpg import asyncpg 6 | from pydantic import BaseModel, Field 7 | 8 | from timvt.settings import TableSettings 9 | 10 | 11 | class Column(BaseModel): 12 | """Model for database Column.""" 13 | 14 | name: str 15 | type: str 16 | description: Optional[str] 17 | 18 | @property 19 | def json_type(self) -> str: 20 | """Return JSON field type.""" 21 | if self.type.endswith("[]"): 22 | return "array" 23 | 24 | if self.type in [ 25 | "smallint", 26 | "integer", 27 | "bigint", 28 | "decimal", 29 | "numeric", 30 | "real", 31 | "double precision", 32 | "smallserial", 33 | "serial", 34 | "bigserial", 35 | # Float8 is not a Postgres type name but is the name we give 36 | # internally do Double Precision type 37 | # ref: https://github.com/developmentseed/tifeatures/pull/60/files#r1011863866 38 | "float8", 39 | ]: 40 | return "number" 41 | 42 | if self.type.startswith("bool"): 43 | return "boolean" 44 | 45 | if any([self.type.startswith("json"), self.type.startswith("geo")]): 46 | return "object" 47 | 48 | return "string" 49 | 50 | 51 | class GeometryColumn(Column): 52 | """Model for PostGIS geometry/geography column.""" 53 | 54 | bounds: List[float] = [-180, -90, 180, 90] 55 | srid: int = 4326 56 | geometry_type: str 57 | 58 | 59 | class DatetimeColumn(Column): 60 | """Model for PostGIS geometry/geography column.""" 61 | 62 | min: Optional[str] 63 | max: Optional[str] 64 | 65 | 66 | class Table(BaseModel): 67 | """Model for DB Table.""" 68 | 69 | id: str 70 | table: str 71 | dbschema: str = Field(..., alias="schema") 72 | description: Optional[str] 73 | properties: List[Column] = [] 74 | id_column: Optional[str] 75 | geometry_columns: List[GeometryColumn] = [] 76 | datetime_columns: List[DatetimeColumn] = [] 77 | geometry_column: Optional[GeometryColumn] 78 | datetime_column: Optional[DatetimeColumn] 79 | 80 | def get_datetime_column(self, name: Optional[str] = None) -> Optional[Column]: 81 | """Return the Column for either the passed in tstz column or the first tstz column.""" 82 | if not self.datetime_columns: 83 | return None 84 | 85 | if name is None: 86 | return self.datetime_column 87 | 88 | for col in self.datetime_columns: 89 | if col.name == name: 90 | return col 91 | 92 | return None 93 | 94 | def get_geometry_column( 95 | self, name: Optional[str] = None 96 | ) -> Optional[GeometryColumn]: 97 | """Return the name of the first geometry column.""" 98 | if (not self.geometry_columns) or (name and name.lower() == "none"): 99 | return None 100 | 101 | if name is None: 102 | return self.geometry_column 103 | 104 | for col in self.geometry_columns: 105 | if col.name == name: 106 | return col 107 | 108 | return None 109 | 110 | @property 111 | def id_column_info(self) -> Column: # type: ignore 112 | """Return Column for a unique identifier.""" 113 | for col in self.properties: 114 | if col.name == self.id_column: 115 | return col 116 | 117 | def columns(self, properties: Optional[List[str]] = None) -> List[str]: 118 | """Return table columns optionally filtered to only include columns from properties.""" 119 | if properties in [[], [""]]: 120 | return [] 121 | 122 | cols = [ 123 | c.name for c in self.properties if c.type not in ["geometry", "geography"] 124 | ] 125 | if properties is None: 126 | return cols 127 | 128 | return [c for c in cols if c in properties] 129 | 130 | def get_column(self, property_name: str) -> Optional[Column]: 131 | """Return column info.""" 132 | for p in self.properties: 133 | if p.name == property_name: 134 | return p 135 | 136 | return None 137 | 138 | 139 | Database = Dict[str, Dict[str, Any]] 140 | 141 | 142 | async def get_table_index( 143 | db_pool: asyncpg.BuildPgPool, 144 | schemas: Optional[List[str]] = ["public"], 145 | tables: Optional[List[str]] = None, 146 | spatial: bool = True, 147 | ) -> Database: 148 | """Fetch Table index.""" 149 | 150 | query = """ 151 | WITH table_columns AS ( 152 | SELECT 153 | nspname, 154 | relname, 155 | format('%I.%I', nspname, relname) as id, 156 | c.oid as t_oid, 157 | obj_description(c.oid, 'pg_class') as description, 158 | attname, 159 | atttypmod, 160 | replace(replace(replace(replace(format_type(atttypid, null),'character varying','text'),'double precision','float8'),'timestamp with time zone','timestamptz'),'timestamp without time zone','timestamp') as "type", 161 | col_description(attrelid, attnum) 162 | FROM 163 | pg_class c 164 | JOIN pg_namespace n on (c.relnamespace=n.oid) 165 | JOIN pg_attribute a on (attnum>0 and attrelid=c.oid and not attisdropped) 166 | WHERE 167 | relkind IN ('r','v', 'm', 'f', 'p') 168 | AND has_table_privilege(c.oid, 'SELECT') 169 | AND has_column_privilege(c.oid,a.attnum, 'SELECT') 170 | AND n.nspname NOT IN ('pg_catalog', 'information_schema') 171 | AND c.relname NOT IN ('spatial_ref_sys','geometry_columns','geography_columns') 172 | AND (:schemas::text[] IS NULL OR n.nspname = ANY (:schemas)) 173 | AND (:tables::text[] IS NULL OR c.relname = ANY (:tables)) 174 | ), 175 | grouped as 176 | (SELECT 177 | nspname, 178 | relname, 179 | id, 180 | t_oid, 181 | description, 182 | ( 183 | SELECT attname 184 | FROM 185 | pg_attribute a 186 | LEFT JOIN 187 | pg_index i 188 | ON ( 189 | a.attrelid = i.indrelid 190 | AND a.attnum = ANY(i.indkey) 191 | ) 192 | WHERE 193 | a.attrelid = t_oid 194 | AND 195 | i.indnatts = 1 196 | ORDER BY 197 | i.indisprimary DESC NULLS LAST, 198 | i.indisunique DESC NULLS LAST 199 | LIMIT 1 200 | ) as id_column, 201 | coalesce(jsonb_agg( 202 | jsonb_build_object( 203 | 'name', attname, 204 | 'type', "type", 205 | 'geometry_type', postgis_typmod_type(atttypmod), 206 | 'srid', postgis_typmod_srid(atttypmod), 207 | 'description', description, 208 | 'bounds', 209 | CASE WHEN postgis_typmod_srid(atttypmod) IS NOT NULL AND postgis_typmod_srid(atttypmod) != 0 THEN 210 | ( 211 | SELECT 212 | ARRAY[ 213 | ST_XMin(extent.geom), 214 | ST_YMin(extent.geom), 215 | ST_XMax(extent.geom), 216 | ST_YMax(extent.geom) 217 | ] 218 | FROM ( 219 | SELECT 220 | coalesce( 221 | ST_Transform( 222 | ST_SetSRID( 223 | ST_EstimatedExtent(nspname, relname, attname), 224 | postgis_typmod_srid(atttypmod) 225 | ), 226 | 4326 227 | ), 228 | ST_MakeEnvelope(-180, -90, 180, 90, 4326) 229 | ) as geom 230 | ) AS extent 231 | ) 232 | ELSE ARRAY[-180,-90,180,90] 233 | END 234 | ) 235 | ) FILTER (WHERE "type" IN ('geometry','geography')), '[]'::jsonb) as geometry_columns, 236 | coalesce(jsonb_agg( 237 | jsonb_build_object( 238 | 'name', attname, 239 | 'type', "type", 240 | 'description', description 241 | ) 242 | ) FILTER (WHERE type LIKE 'timestamp%'), '[]'::jsonb) as datetime_columns, 243 | coalesce(jsonb_agg( 244 | jsonb_build_object( 245 | 'name', attname, 246 | 'type', "type", 247 | 'description', description 248 | ) 249 | ),'[]'::jsonb) as properties 250 | FROM 251 | table_columns 252 | GROUP BY 1,2,3,4,5,6 ORDER BY 1,2 253 | ) 254 | SELECT 255 | id, 256 | relname as table, 257 | nspname as dbschema, 258 | description, 259 | id_column, 260 | geometry_columns, 261 | datetime_columns, 262 | properties 263 | FROM grouped 264 | WHERE :spatial = FALSE OR jsonb_array_length(geometry_columns)>=1 265 | ; 266 | 267 | """ 268 | 269 | async with db_pool.acquire() as conn: 270 | rows = await conn.fetch_b( 271 | query, 272 | schemas=schemas, 273 | tables=tables, 274 | spatial=spatial, 275 | ) 276 | 277 | catalog = {} 278 | table_settings = TableSettings() 279 | table_confs = table_settings.table_config 280 | fallback_key_names = table_settings.fallback_key_names 281 | 282 | for table in rows: 283 | id = table["id"] 284 | confid = id.replace(".", "_") 285 | table_conf = table_confs.get(confid, {}) 286 | 287 | # Make sure that any properties set in conf exist in table 288 | properties = table.get("properties", []) 289 | properties_setting = table_conf.get("properties", []) 290 | if properties_setting: 291 | properties = [p for p in properties if p["name"] in properties_setting] 292 | 293 | property_names = [p["name"] for p in properties] 294 | 295 | # ID Column 296 | id_column = table_conf.get("pk") or table["id_column"] 297 | if not id_column and fallback_key_names: 298 | for p in properties: 299 | if p["name"] in fallback_key_names: 300 | id_column = p["name"] 301 | break 302 | 303 | # Datetime Column 304 | datetime_columns = [ 305 | c 306 | for c in table.get("datetime_columns", []) 307 | if c["name"] in property_names 308 | ] 309 | 310 | datetime_column = None 311 | for col in datetime_columns: 312 | if table_conf.get("datetimecol") == col["name"]: 313 | datetime_column = col 314 | 315 | if not datetime_column and datetime_columns: 316 | datetime_column = datetime_columns[0] 317 | 318 | # Geometry Column 319 | geometry_columns = [ 320 | c 321 | for c in table.get("geometry_columns", []) 322 | if c["name"] in property_names 323 | ] 324 | geometry_column = None 325 | for col in geometry_columns: 326 | if table_conf.get("geomcol") == col["name"]: 327 | geometry_column = col 328 | if not geometry_column and geometry_columns: 329 | geometry_column = geometry_columns[0] 330 | 331 | catalog[id] = { 332 | "id": id, 333 | "table": table["table"], 334 | "schema": table["dbschema"], 335 | "description": table["description"], 336 | "id_column": id_column, 337 | "geometry_columns": geometry_columns, 338 | "datetime_columns": datetime_columns, 339 | "properties": properties, 340 | "datetime_column": datetime_column, 341 | "geometry_column": geometry_column, 342 | } 343 | 344 | return catalog 345 | -------------------------------------------------------------------------------- /timvt/dependencies.py: -------------------------------------------------------------------------------- 1 | """TiVTiler.dependencies: endpoint's dependencies.""" 2 | 3 | import re 4 | 5 | from morecantile import Tile 6 | 7 | from timvt.layer import Layer, Table 8 | 9 | from fastapi import HTTPException, Path 10 | 11 | from starlette.requests import Request 12 | 13 | 14 | def TileParams( 15 | z: int = Path(..., ge=0, le=30, description="Tiles's zoom level"), 16 | x: int = Path(..., description="Tiles's column"), 17 | y: int = Path(..., description="Tiles's row"), 18 | ) -> Tile: 19 | """Tile parameters.""" 20 | return Tile(x, y, z) 21 | 22 | 23 | def LayerParams( 24 | request: Request, 25 | layer: str = Path(..., description="Layer Name"), 26 | ) -> Layer: 27 | """Return Layer Object.""" 28 | # Check timvt_function_catalog 29 | function_catalog = getattr(request.app.state, "timvt_function_catalog", {}) 30 | func = function_catalog.get(layer) 31 | if func: 32 | return func 33 | 34 | # Check table_catalog 35 | else: 36 | table_pattern = re.match( # type: ignore 37 | r"^(?P.+)\.(?P.+)$", layer 38 | ) 39 | if not table_pattern: 40 | raise HTTPException( 41 | status_code=404, detail=f"Invalid Table format '{layer}'." 42 | ) 43 | 44 | assert table_pattern.groupdict()["schema"] 45 | assert table_pattern.groupdict()["table"] 46 | 47 | table_catalog = getattr(request.app.state, "table_catalog", {}) 48 | if layer in table_catalog: 49 | return Table(**table_catalog[layer]) 50 | 51 | raise HTTPException(status_code=404, detail=f"Table/Function '{layer}' not found.") 52 | -------------------------------------------------------------------------------- /timvt/errors.py: -------------------------------------------------------------------------------- 1 | """timvt.errors: Error classes.""" 2 | 3 | import logging 4 | from typing import Callable, Dict, Type 5 | 6 | from fastapi import FastAPI 7 | 8 | from starlette import status 9 | from starlette.requests import Request 10 | from starlette.responses import JSONResponse 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class TiMVTError(Exception): 16 | """Base exception class.""" 17 | 18 | 19 | class TableNotFound(TiMVTError): 20 | """Invalid table name.""" 21 | 22 | 23 | class MissingEPSGCode(TiMVTError): 24 | """No EPSG code available for TMS's CRS.""" 25 | 26 | 27 | class MissingGeometryColumn(TiMVTError): 28 | """Table has no geometry column.""" 29 | 30 | 31 | class InvalidGeometryColumnName(TiMVTError): 32 | """Invalid geometry column name.""" 33 | 34 | 35 | DEFAULT_STATUS_CODES = { 36 | TableNotFound: status.HTTP_404_NOT_FOUND, 37 | MissingEPSGCode: status.HTTP_500_INTERNAL_SERVER_ERROR, 38 | MissingGeometryColumn: status.HTTP_500_INTERNAL_SERVER_ERROR, 39 | InvalidGeometryColumnName: status.HTTP_404_NOT_FOUND, 40 | Exception: status.HTTP_500_INTERNAL_SERVER_ERROR, 41 | } 42 | 43 | 44 | def exception_handler_factory(status_code: int) -> Callable: 45 | """ 46 | Create a FastAPI exception handler from a status code. 47 | """ 48 | 49 | def handler(request: Request, exc: Exception): 50 | logger.error(exc, exc_info=True) 51 | return JSONResponse(content={"detail": str(exc)}, status_code=status_code) 52 | 53 | return handler 54 | 55 | 56 | def add_exception_handlers( 57 | app: FastAPI, status_codes: Dict[Type[Exception], int] 58 | ) -> None: 59 | """ 60 | Add exception handlers to the FastAPI app. 61 | """ 62 | for (exc, code) in status_codes.items(): 63 | app.add_exception_handler(exc, exception_handler_factory(code)) 64 | -------------------------------------------------------------------------------- /timvt/factory.py: -------------------------------------------------------------------------------- 1 | """timvt.endpoints.factory: router factories.""" 2 | 3 | from dataclasses import dataclass, field 4 | from typing import Any, Callable, Dict, List, Literal, Optional 5 | from urllib.parse import urlencode 6 | 7 | from morecantile import Tile, TileMatrixSet 8 | from morecantile import tms as morecantile_tms 9 | from morecantile.defaults import TileMatrixSets 10 | 11 | from timvt.dependencies import LayerParams, TileParams 12 | from timvt.layer import Function, Layer, Table 13 | from timvt.models.mapbox import TileJSON 14 | from timvt.models.OGC import TileMatrixSetList 15 | from timvt.resources.enums import MimeTypes 16 | 17 | from fastapi import APIRouter, Depends, Path, Query 18 | 19 | from starlette.datastructures import QueryParams 20 | from starlette.requests import Request 21 | from starlette.responses import HTMLResponse, Response 22 | from starlette.routing import NoMatchFound 23 | from starlette.templating import Jinja2Templates 24 | 25 | try: 26 | from importlib.resources import files as resources_files # type: ignore 27 | except ImportError: 28 | # Try backported to PY<39 `importlib_resources`. 29 | from importlib_resources import files as resources_files # type: ignore 30 | 31 | 32 | templates = Jinja2Templates(directory=str(resources_files(__package__) / "templates")) # type: ignore 33 | 34 | TILE_RESPONSE_PARAMS: Dict[str, Any] = { 35 | "responses": {200: {"content": {"application/x-protobuf": {}}}}, 36 | "response_class": Response, 37 | } 38 | 39 | 40 | def queryparams_to_kwargs(q: QueryParams, ignore_keys: List = []) -> Dict: 41 | """Convert query params to dict.""" 42 | keys = list(q.keys()) 43 | values = {} 44 | for k in keys: 45 | if k in ignore_keys: 46 | continue 47 | 48 | v = q.getlist(k) 49 | values[k] = v if len(v) > 1 else v[0] 50 | 51 | return values 52 | 53 | 54 | def _first_value(values: List[Any], default: Any = None): 55 | """Return the first not None value.""" 56 | return next(filter(lambda x: x is not None, values), default) 57 | 58 | 59 | @dataclass 60 | class VectorTilerFactory: 61 | """VectorTiler Factory.""" 62 | 63 | # FastAPI router 64 | router: APIRouter = field(default_factory=APIRouter) 65 | 66 | # TileMatrixSet dependency 67 | supported_tms: TileMatrixSets = morecantile_tms 68 | default_tms: str = "WebMercatorQuad" 69 | 70 | # Table/Function dependency 71 | layer_dependency: Callable[..., Layer] = LayerParams 72 | 73 | with_tables_metadata: bool = False 74 | with_functions_metadata: bool = False 75 | with_viewer: bool = False 76 | 77 | # Router Prefix is needed to find the path for routes when prefixed 78 | # e.g if you mount the route with `/foo` prefix, set router_prefix to foo 79 | router_prefix: str = "" 80 | 81 | def __post_init__(self): 82 | """Post Init: register route and configure specific options.""" 83 | self.register_routes() 84 | 85 | def register_routes(self): 86 | """Register Routes.""" 87 | if self.with_tables_metadata: 88 | self.register_tables_metadata() 89 | 90 | if self.with_functions_metadata: 91 | self.register_functions_metadata() 92 | 93 | if self.with_viewer: 94 | self.register_viewer() 95 | 96 | self.register_tiles() 97 | 98 | def url_for(self, request: Request, name: str, **path_params: Any) -> str: 99 | """Return full url (with prefix) for a specific endpoint.""" 100 | url_path = self.router.url_path_for(name, **path_params) 101 | base_url = str(request.base_url) 102 | if self.router_prefix: 103 | base_url += self.router_prefix.lstrip("/") 104 | 105 | return str(url_path.make_absolute_url(base_url=base_url)) 106 | 107 | def register_tiles(self): 108 | """Register /tiles endpoints.""" 109 | 110 | @self.router.get( 111 | "/tiles/{TileMatrixSetId}/{layer}/{z}/{x}/{y}", **TILE_RESPONSE_PARAMS 112 | ) 113 | @self.router.get("/tiles/{layer}/{z}/{x}/{y}", **TILE_RESPONSE_PARAMS) 114 | async def tile( 115 | request: Request, 116 | tile: Tile = Depends(TileParams), 117 | TileMatrixSetId: Literal[tuple(self.supported_tms.list())] = Query( 118 | self.default_tms, 119 | description=f"TileMatrixSet Name (default: '{self.default_tms}')", 120 | ), 121 | layer=Depends(self.layer_dependency), 122 | ): 123 | """Return vector tile.""" 124 | pool = request.app.state.pool 125 | tms = self.supported_tms.get(TileMatrixSetId) 126 | 127 | kwargs = queryparams_to_kwargs( 128 | request.query_params, ignore_keys=["tilematrixsetid"] 129 | ) 130 | content = await layer.get_tile(pool, tile, tms, **kwargs) 131 | 132 | return Response(bytes(content), media_type=MimeTypes.pbf.value) 133 | 134 | @self.router.get( 135 | "/{TileMatrixSetId}/{layer}/tilejson.json", 136 | response_model=TileJSON, 137 | responses={200: {"description": "Return a tilejson"}}, 138 | response_model_exclude_none=True, 139 | ) 140 | @self.router.get( 141 | "/{layer}/tilejson.json", 142 | response_model=TileJSON, 143 | responses={200: {"description": "Return a tilejson"}}, 144 | response_model_exclude_none=True, 145 | ) 146 | async def tilejson( 147 | request: Request, 148 | layer=Depends(self.layer_dependency), 149 | TileMatrixSetId: Literal[tuple(self.supported_tms.list())] = Query( 150 | self.default_tms, 151 | description=f"TileMatrixSet Name (default: '{self.default_tms}')", 152 | ), 153 | minzoom: Optional[int] = Query( 154 | None, description="Overwrite default minzoom." 155 | ), 156 | maxzoom: Optional[int] = Query( 157 | None, description="Overwrite default maxzoom." 158 | ), 159 | ): 160 | """Return TileJSON document.""" 161 | tms = self.supported_tms.get(TileMatrixSetId) 162 | 163 | path_params: Dict[str, Any] = { 164 | "TileMatrixSetId": tms.identifier, 165 | "layer": layer.id, 166 | "z": "{z}", 167 | "x": "{x}", 168 | "y": "{y}", 169 | } 170 | tile_endpoint = self.url_for(request, "tile", **path_params) 171 | 172 | qs_key_to_remove = ["tilematrixsetid", "minzoom", "maxzoom"] 173 | query_params = [ 174 | (key, value) 175 | for (key, value) in request.query_params._list 176 | if key.lower() not in qs_key_to_remove 177 | ] 178 | 179 | if query_params: 180 | tile_endpoint += f"?{urlencode(query_params)}" 181 | 182 | # Get Min/Max zoom from layer settings if tms is the default tms 183 | if tms.identifier == layer.default_tms: 184 | minzoom = _first_value([minzoom, layer.minzoom]) 185 | maxzoom = _first_value([maxzoom, layer.maxzoom]) 186 | 187 | minzoom = minzoom if minzoom is not None else tms.minzoom 188 | maxzoom = maxzoom if maxzoom is not None else tms.maxzoom 189 | 190 | return { 191 | "minzoom": minzoom, 192 | "maxzoom": maxzoom, 193 | "name": layer.id, 194 | "bounds": layer.bounds, 195 | "tiles": [tile_endpoint], 196 | } 197 | 198 | def register_tables_metadata(self): 199 | """Register metadata endpoints.""" 200 | 201 | @self.router.get( 202 | "/tables.json", 203 | response_model=List[Table], 204 | response_model_exclude_none=True, 205 | ) 206 | async def tables_index(request: Request): 207 | """Index of tables.""" 208 | 209 | def _get_tiles_url(id) -> Optional[str]: 210 | try: 211 | return self.url_for( 212 | request, "tile", layer=id, z="{z}", x="{x}", y="{y}" 213 | ) 214 | except NoMatchFound: 215 | return None 216 | 217 | table_catalog = getattr(request.app.state, "table_catalog", {}) 218 | return [ 219 | Table(**table_info, tileurl=_get_tiles_url(table_id)) 220 | for table_id, table_info in table_catalog.items() 221 | ] 222 | 223 | @self.router.get( 224 | "/table/{layer}.json", 225 | response_model=Table, 226 | responses={200: {"description": "Return table metadata"}}, 227 | response_model_exclude_none=True, 228 | ) 229 | async def table_metadata( 230 | request: Request, 231 | layer=Depends(self.layer_dependency), 232 | ): 233 | """Return table metadata.""" 234 | 235 | def _get_tiles_url(id) -> Optional[str]: 236 | try: 237 | return self.url_for( 238 | request, "tile", layer=id, z="{z}", x="{x}", y="{y}" 239 | ) 240 | except NoMatchFound: 241 | return None 242 | 243 | layer.tileurl = _get_tiles_url(layer.id) 244 | return layer 245 | 246 | def register_functions_metadata(self): # noqa 247 | """Register function metadata endpoints.""" 248 | 249 | @self.router.get( 250 | "/functions.json", 251 | response_model=List[Function], 252 | response_model_exclude_none=True, 253 | response_model_exclude={"sql"}, 254 | ) 255 | async def functions_index(request: Request): 256 | """Index of functions.""" 257 | function_catalog = getattr(request.app.state, "timvt_function_catalog", {}) 258 | 259 | def _get_tiles_url(id) -> Optional[str]: 260 | try: 261 | return self.url_for( 262 | request, "tile", layer=id, z="{z}", x="{x}", y="{y}" 263 | ) 264 | except NoMatchFound: 265 | return None 266 | 267 | return [ 268 | Function( 269 | **func.dict(exclude_none=True), tileurl=_get_tiles_url(func.id) 270 | ) 271 | for func in function_catalog.values() 272 | ] 273 | 274 | @self.router.get( 275 | "/function/{layer}.json", 276 | response_model=Function, 277 | responses={200: {"description": "Return Function metadata"}}, 278 | response_model_exclude_none=True, 279 | response_model_exclude={"sql"}, 280 | ) 281 | async def function_metadata( 282 | request: Request, 283 | layer=Depends(self.layer_dependency), 284 | ): 285 | """Return table metadata.""" 286 | 287 | def _get_tiles_url(id) -> Optional[str]: 288 | try: 289 | return self.url_for( 290 | request, "tile", layer=id, z="{z}", x="{x}", y="{y}" 291 | ) 292 | except NoMatchFound: 293 | return None 294 | 295 | layer.tileurl = _get_tiles_url(layer.id) 296 | return layer 297 | 298 | def register_viewer(self): 299 | """Register viewer.""" 300 | 301 | @self.router.get("/{layer}/viewer", response_class=HTMLResponse) 302 | async def demo(request: Request, layer=Depends(LayerParams)): 303 | """Demo for each table.""" 304 | tile_url = self.url_for(request, "tilejson", layer=layer.id) 305 | if request.query_params: 306 | tile_url += f"?{request.query_params}" 307 | 308 | return templates.TemplateResponse( 309 | name="viewer.html", 310 | context={"endpoint": tile_url, "request": request}, 311 | media_type="text/html", 312 | ) 313 | 314 | 315 | @dataclass 316 | class TMSFactory: 317 | """TileMatrixSet endpoints Factory.""" 318 | 319 | supported_tms: TileMatrixSets = morecantile_tms 320 | 321 | # FastAPI router 322 | router: APIRouter = field(default_factory=APIRouter) 323 | 324 | # Router Prefix is needed to find the path for /tile if the TilerFactory.router is mounted 325 | # with other router (multiple `.../tile` routes). 326 | # e.g if you mount the route with `/cog` prefix, set router_prefix to cog and 327 | router_prefix: str = "" 328 | 329 | def __post_init__(self): 330 | """Post Init: register route and configure specific options.""" 331 | self.register_routes() 332 | 333 | def url_for(self, request: Request, name: str, **path_params: Any) -> str: 334 | """Return full url (with prefix) for a specific endpoint.""" 335 | url_path = self.router.url_path_for(name, **path_params) 336 | base_url = str(request.base_url) 337 | if self.router_prefix: 338 | base_url += self.router_prefix.lstrip("/") 339 | 340 | return str(url_path.make_absolute_url(base_url=base_url)) 341 | 342 | def register_routes(self): 343 | """Register TMS endpoint routes.""" 344 | 345 | @self.router.get( 346 | r"/tileMatrixSets", 347 | response_model=TileMatrixSetList, 348 | response_model_exclude_none=True, 349 | ) 350 | async def TileMatrixSet_list(request: Request): 351 | """ 352 | Return list of supported TileMatrixSets. 353 | 354 | Specs: http://docs.opengeospatial.org/per/19-069.html#_tilematrixsets 355 | """ 356 | return { 357 | "tileMatrixSets": [ 358 | { 359 | "id": tms, 360 | "title": tms, 361 | "links": [ 362 | { 363 | "href": self.url_for( 364 | request, 365 | "TileMatrixSet_info", 366 | TileMatrixSetId=tms, 367 | ), 368 | "rel": "item", 369 | "type": "application/json", 370 | } 371 | ], 372 | } 373 | for tms in self.supported_tms.list() 374 | ] 375 | } 376 | 377 | @self.router.get( 378 | r"/tileMatrixSets/{TileMatrixSetId}", 379 | response_model=TileMatrixSet, 380 | response_model_exclude_none=True, 381 | ) 382 | async def TileMatrixSet_info( 383 | TileMatrixSetId: Literal[tuple(self.supported_tms.list())] = Path( 384 | ..., description="TileMatrixSet Name." 385 | ) 386 | ): 387 | """ 388 | OGC Specification: http://docs.opengeospatial.org/per/19-069.html#_tilematrixset 389 | """ 390 | return self.supported_tms.get(TileMatrixSetId) 391 | -------------------------------------------------------------------------------- /timvt/layer.py: -------------------------------------------------------------------------------- 1 | """timvt models.""" 2 | 3 | import abc 4 | import json 5 | from dataclasses import dataclass 6 | from typing import Any, ClassVar, Dict, List, Optional 7 | 8 | import morecantile 9 | from buildpg import Func 10 | from buildpg import Var as pg_variable 11 | from buildpg import asyncpg, clauses, funcs, render, select_fields 12 | from pydantic import BaseModel, root_validator 13 | 14 | from timvt.dbmodel import Table as DBTable 15 | from timvt.errors import ( 16 | InvalidGeometryColumnName, 17 | MissingEPSGCode, 18 | MissingGeometryColumn, 19 | ) 20 | from timvt.settings import TileSettings 21 | 22 | tile_settings = TileSettings() 23 | 24 | 25 | class Layer(BaseModel, metaclass=abc.ABCMeta): 26 | """Layer's Abstract BaseClass. 27 | 28 | Attributes: 29 | id (str): Layer's name. 30 | bounds (list): Layer's bounds (left, bottom, right, top). 31 | minzoom (int): Layer's min zoom level. 32 | maxzoom (int): Layer's max zoom level. 33 | default_tms (str): TileMatrixSet name for the min/max zoom. 34 | tileurl (str, optional): Layer's tiles url. 35 | 36 | """ 37 | 38 | id: str 39 | bounds: List[float] = [-180, -90, 180, 90] 40 | crs: str = "http://www.opengis.net/def/crs/EPSG/0/4326" 41 | title: Optional[str] 42 | description: Optional[str] 43 | minzoom: int = tile_settings.default_minzoom 44 | maxzoom: int = tile_settings.default_maxzoom 45 | default_tms: str = tile_settings.default_tms 46 | tileurl: Optional[str] 47 | 48 | @abc.abstractmethod 49 | async def get_tile( 50 | self, 51 | pool: asyncpg.BuildPgPool, 52 | tile: morecantile.Tile, 53 | tms: morecantile.TileMatrixSet, 54 | **kwargs: Any, 55 | ) -> bytes: 56 | """Return Tile Data. 57 | 58 | Args: 59 | pool (asyncpg.BuildPgPool): AsyncPG database connection pool. 60 | tile (morecantile.Tile): Tile object with X,Y,Z indices. 61 | tms (morecantile.TileMatrixSet): Tile Matrix Set. 62 | kwargs (any, optiona): Optional parameters to forward to the SQL function. 63 | 64 | Returns: 65 | bytes: Mapbox Vector Tiles. 66 | 67 | """ 68 | ... 69 | 70 | 71 | class Table(Layer, DBTable): 72 | """Table Reader. 73 | 74 | Attributes: 75 | id (str): Layer's name. 76 | bounds (list): Layer's bounds (left, bottom, right, top). 77 | minzoom (int): Layer's min zoom level. 78 | maxzoom (int): Layer's max zoom level. 79 | tileurl (str, optional): Layer's tiles url. 80 | type (str): Layer's type. 81 | table (str): Table's name. 82 | schema (str): Table's database schema (e.g public). 83 | description (str): Table's description. 84 | id_column (str): name of id column 85 | geometry_columns (list): List of geometry columns. 86 | properties (list): List of property columns. 87 | 88 | """ 89 | 90 | type: str = "Table" 91 | 92 | @root_validator 93 | def bounds_default(cls, values): 94 | """Get default bounds from the first geometry columns.""" 95 | geoms = values.get("geometry_columns") 96 | if geoms: 97 | # Get the Extent of all the bounds 98 | minx, miny, maxx, maxy = zip(*[geom.bounds for geom in geoms]) 99 | values["bounds"] = [min(minx), min(miny), max(maxx), max(maxy)] 100 | values["crs"] = f"http://www.opengis.net/def/crs/EPSG/0/{geoms[0].srid}" 101 | 102 | return values 103 | 104 | async def get_tile( 105 | self, 106 | pool: asyncpg.BuildPgPool, 107 | tile: morecantile.Tile, 108 | tms: morecantile.TileMatrixSet, 109 | **kwargs: Any, 110 | ): 111 | """Get Tile Data.""" 112 | bbox = tms.xy_bounds(tile) 113 | 114 | limit = kwargs.get( 115 | "limit", str(tile_settings.max_features_per_tile) 116 | ) # Number of features to write to a tile. 117 | limit = min(int(limit), tile_settings.max_features_per_tile) 118 | if limit == -1: 119 | limit = tile_settings.max_features_per_tile 120 | 121 | columns = kwargs.get( 122 | "columns", 123 | ) # Comma-seprated list of properties (column's name) to include in the tile 124 | resolution = kwargs.get( 125 | "resolution", str(tile_settings.tile_resolution) 126 | ) # Tile's resolution 127 | buffer = kwargs.get( 128 | "buffer", str(tile_settings.tile_buffer) 129 | ) # Size of extra data to add for a tile. 130 | 131 | if not self.geometry_columns: 132 | raise MissingGeometryColumn( 133 | f"Could not find any geometry column for Table {self.id}" 134 | ) 135 | 136 | geom = kwargs.get("geom", None) 137 | geometry_column = self.get_geometry_column(geom) 138 | if not geometry_column: 139 | raise InvalidGeometryColumnName(f"Invalid Geometry Column: {geom}.") 140 | 141 | geometry_srid = geometry_column.srid 142 | 143 | # create list of columns to return 144 | cols = [p.name for p in self.properties if p.name != geometry_column.name] 145 | if columns is not None: 146 | include_cols = [c.strip() for c in columns.split(",")] 147 | cols = [c for c in cols if c in include_cols] 148 | 149 | segSize = bbox.right - bbox.left 150 | 151 | tms_srid = tms.crs.to_epsg() 152 | tms_proj = tms.crs.to_proj4() 153 | 154 | async with pool.acquire() as conn: 155 | sql_query = """ 156 | WITH 157 | -- bounds (the tile envelope) in TMS's CRS (SRID) 158 | bounds_tmscrs AS ( 159 | SELECT 160 | ST_Segmentize( 161 | ST_MakeEnvelope( 162 | :xmin, 163 | :ymin, 164 | :xmax, 165 | :ymax, 166 | -- If EPSG is null we set it to 0 167 | coalesce(:tms_srid, 0) 168 | ), 169 | :seg_size 170 | ) AS geom 171 | ), 172 | bounds_geomcrs AS ( 173 | SELECT 174 | CASE WHEN coalesce(:tms_srid, 0) != 0 THEN 175 | ST_Transform(bounds_tmscrs.geom, :geometry_srid) 176 | ELSE 177 | ST_Transform(bounds_tmscrs.geom, :tms_proj, :geometry_srid) 178 | END as geom 179 | FROM bounds_tmscrs 180 | ), 181 | mvtgeom AS ( 182 | SELECT ST_AsMVTGeom( 183 | CASE WHEN :tms_srid IS NOT NULL THEN 184 | ST_Transform(t.:geometry_column, :tms_srid) 185 | ELSE 186 | ST_Transform(t.:geometry_column, :tms_proj) 187 | END, 188 | bounds_tmscrs.geom, 189 | :tile_resolution, 190 | :tile_buffer 191 | ) AS geom, :fields 192 | FROM :tablename t, bounds_tmscrs, bounds_geomcrs 193 | -- Find where geometries intersect with input Tile 194 | -- Intersects test is made in table geometry's CRS (e.g WGS84) 195 | WHERE ST_Intersects( 196 | t.:geometry_column, bounds_geomcrs.geom 197 | ) LIMIT :limit 198 | ) 199 | SELECT ST_AsMVT(mvtgeom.*) FROM mvtgeom 200 | """ 201 | 202 | q, p = render( 203 | sql_query, 204 | tablename=pg_variable(self.id), 205 | geometry_column=pg_variable(geometry_column.name), 206 | fields=select_fields(*cols), 207 | xmin=bbox.left, 208 | ymin=bbox.bottom, 209 | xmax=bbox.right, 210 | ymax=bbox.top, 211 | geometry_srid=funcs.cast(geometry_srid, "int"), 212 | tms_proj=tms_proj, 213 | tms_srid=tms_srid, 214 | seg_size=segSize, 215 | tile_resolution=int(resolution), 216 | tile_buffer=int(buffer), 217 | limit=limit, 218 | ) 219 | 220 | return await conn.fetchval(q, *p) 221 | 222 | 223 | class Function(Layer): 224 | """Function Reader. 225 | 226 | Attributes: 227 | id (str): Layer's name. 228 | bounds (list): Layer's bounds (left, bottom, right, top). 229 | minzoom (int): Layer's min zoom level. 230 | maxzoom (int): Layer's max zoom level. 231 | tileurl (str, optional): Layer's tiles url. 232 | type (str): Layer's type. 233 | function_name (str): Nane of the SQL function to call. Defaults to `id`. 234 | sql (str): Valid SQL function which returns Tile data. 235 | options (list, optional): options available for the SQL function. 236 | 237 | """ 238 | 239 | type: str = "Function" 240 | sql: str 241 | function_name: Optional[str] 242 | options: Optional[List[Dict[str, Any]]] 243 | 244 | @root_validator 245 | def function_name_default(cls, values): 246 | """Define default function's name to be same as id.""" 247 | function_name = values.get("function_name") 248 | if function_name is None: 249 | values["function_name"] = values.get("id") 250 | return values 251 | 252 | @classmethod 253 | def from_file(cls, id: str, infile: str, **kwargs: Any): 254 | """load sql from file""" 255 | with open(infile) as f: 256 | sql = f.read() 257 | 258 | return cls(id=id, sql=sql, **kwargs) 259 | 260 | async def get_tile( 261 | self, 262 | pool: asyncpg.BuildPgPool, 263 | tile: morecantile.Tile, 264 | tms: morecantile.TileMatrixSet, 265 | **kwargs: Any, 266 | ): 267 | """Get Tile Data.""" 268 | # We only support TMS with valid EPSG code 269 | if not tms.crs.to_epsg(): 270 | raise MissingEPSGCode( 271 | f"{tms.identifier}'s CRS does not have a valid EPSG code." 272 | ) 273 | 274 | bbox = tms.xy_bounds(tile) 275 | 276 | async with pool.acquire() as conn: 277 | transaction = conn.transaction() 278 | await transaction.start() 279 | # Register the custom function 280 | await conn.execute(self.sql) 281 | 282 | # Build the query 283 | sql_query = clauses.Select( 284 | Func( 285 | self.function_name, 286 | ":xmin", 287 | ":ymin", 288 | ":xmax", 289 | ":ymax", 290 | ":epsg", 291 | ":query_params::text::json", 292 | ), 293 | ) 294 | q, p = render( 295 | str(sql_query), 296 | xmin=bbox.left, 297 | ymin=bbox.bottom, 298 | xmax=bbox.right, 299 | ymax=bbox.top, 300 | epsg=tms.crs.to_epsg(), 301 | query_params=json.dumps(kwargs), 302 | ) 303 | 304 | # execute the query 305 | content = await conn.fetchval(q, *p) 306 | 307 | # rollback 308 | await transaction.rollback() 309 | 310 | return content 311 | 312 | 313 | @dataclass 314 | class FunctionRegistry: 315 | """function registry""" 316 | 317 | funcs: ClassVar[Dict[str, Function]] = {} 318 | 319 | @classmethod 320 | def get(cls, key: str): 321 | """lookup function by name""" 322 | return cls.funcs.get(key) 323 | 324 | @classmethod 325 | def register(cls, *args: Function): 326 | """register function(s)""" 327 | for func in args: 328 | cls.funcs[func.id] = func 329 | 330 | @classmethod 331 | def values(cls): 332 | """get all values.""" 333 | return cls.funcs.values() 334 | -------------------------------------------------------------------------------- /timvt/main.py: -------------------------------------------------------------------------------- 1 | """TiMVT application.""" 2 | 3 | import pathlib 4 | 5 | from timvt import __version__ as timvt_version 6 | from timvt.db import close_db_connection, connect_to_db, register_table_catalog 7 | from timvt.errors import DEFAULT_STATUS_CODES, add_exception_handlers 8 | from timvt.factory import TMSFactory, VectorTilerFactory 9 | from timvt.layer import Function, FunctionRegistry 10 | from timvt.middleware import CacheControlMiddleware 11 | from timvt.settings import ApiSettings, PostgresSettings, TileSettings 12 | 13 | from fastapi import FastAPI, Request 14 | 15 | from starlette.middleware.cors import CORSMiddleware 16 | from starlette.responses import HTMLResponse 17 | from starlette.templating import Jinja2Templates 18 | from starlette_cramjam.middleware import CompressionMiddleware 19 | 20 | try: 21 | from importlib.resources import files as resources_files # type: ignore 22 | except ImportError: 23 | from importlib_resources import files as resources_files # type: ignore 24 | 25 | 26 | templates = Jinja2Templates(directory=str(resources_files(__package__) / "templates")) # type: ignore 27 | settings = ApiSettings() 28 | postgres_settings = PostgresSettings() 29 | tile_settings = TileSettings() 30 | 31 | # Create TiVTiler Application. 32 | app = FastAPI( 33 | title=settings.name, 34 | description="A lightweight PostGIS vector tile server.", 35 | version=timvt_version, 36 | debug=settings.debug, 37 | ) 38 | 39 | # Setup CORS. 40 | if settings.cors_origins: 41 | app.add_middleware( 42 | CORSMiddleware, 43 | allow_origins=settings.cors_origins, 44 | allow_credentials=True, 45 | allow_methods=["GET"], 46 | allow_headers=["*"], 47 | ) 48 | 49 | 50 | app.add_middleware(CacheControlMiddleware, cachecontrol=settings.cachecontrol) 51 | app.add_middleware(CompressionMiddleware, minimum_size=0) 52 | add_exception_handlers(app, DEFAULT_STATUS_CODES) 53 | 54 | # We add the function registry to the application state 55 | app.state.timvt_function_catalog = FunctionRegistry() 56 | if settings.functions_directory: 57 | functions = pathlib.Path(settings.functions_directory).glob("*.sql") 58 | for func in functions: 59 | name = func.name 60 | if name.endswith(".sql"): 61 | name = name[:-4] 62 | app.state.timvt_function_catalog.register( 63 | Function.from_file(id=name, infile=str(func)) 64 | ) 65 | 66 | 67 | # Register Start/Stop application event handler to setup/stop the database connection 68 | @app.on_event("startup") 69 | async def startup_event(): 70 | """Application startup: register the database connection and create table list.""" 71 | await connect_to_db(app, settings=postgres_settings) 72 | await register_table_catalog( 73 | app, 74 | schemas=postgres_settings.db_schemas, 75 | tables=postgres_settings.db_tables, 76 | ) 77 | 78 | 79 | @app.on_event("shutdown") 80 | async def shutdown_event(): 81 | """Application shutdown: de-register the database connection.""" 82 | await close_db_connection(app) 83 | 84 | 85 | # Register endpoints. 86 | mvt_tiler = VectorTilerFactory( 87 | default_tms=tile_settings.default_tms, 88 | with_tables_metadata=True, 89 | with_functions_metadata=True, 90 | with_viewer=True, 91 | ) 92 | app.include_router(mvt_tiler.router) 93 | 94 | tms = TMSFactory() 95 | app.include_router(tms.router, tags=["TileMatrixSets"]) 96 | 97 | 98 | @app.get("/", response_class=HTMLResponse, include_in_schema=False) 99 | async def index(request: Request): 100 | """DEMO.""" 101 | table_catalog = getattr(request.app.state, "table_catalog", {}) 102 | return templates.TemplateResponse( 103 | name="index.html", 104 | context={"index": table_catalog.values(), "request": request}, 105 | media_type="text/html", 106 | ) 107 | 108 | 109 | @app.get("/healthz", description="Health Check", tags=["Health Check"]) 110 | def ping(): 111 | """Health check.""" 112 | return {"ping": "pong!"} 113 | -------------------------------------------------------------------------------- /timvt/middleware.py: -------------------------------------------------------------------------------- 1 | """timvt middlewares.""" 2 | 3 | import re 4 | from typing import Optional, Set 5 | 6 | from starlette.middleware.base import BaseHTTPMiddleware 7 | from starlette.requests import Request 8 | from starlette.types import ASGIApp 9 | 10 | 11 | class CacheControlMiddleware(BaseHTTPMiddleware): 12 | """MiddleWare to add CacheControl in response headers.""" 13 | 14 | def __init__( 15 | self, 16 | app: ASGIApp, 17 | cachecontrol: Optional[str] = None, 18 | exclude_path: Optional[Set[str]] = None, 19 | ) -> None: 20 | """Init Middleware. 21 | 22 | Args: 23 | app (ASGIApp): starlette/FastAPI application. 24 | cachecontrol (str): Cache-Control string to add to the response. 25 | exclude_path (set): Set of regex expression to use to filter the path. 26 | 27 | """ 28 | super().__init__(app) 29 | self.cachecontrol = cachecontrol 30 | self.exclude_path = exclude_path or set() 31 | 32 | async def dispatch(self, request: Request, call_next): 33 | """Add cache-control.""" 34 | response = await call_next(request) 35 | if self.cachecontrol and not response.headers.get("Cache-Control"): 36 | for path in self.exclude_path: 37 | if re.match(path, request.url.path): 38 | return response 39 | 40 | if request.method in ["HEAD", "GET"] and response.status_code < 500: 41 | response.headers["Cache-Control"] = self.cachecontrol 42 | 43 | return response 44 | -------------------------------------------------------------------------------- /timvt/models/OGC.py: -------------------------------------------------------------------------------- 1 | """timvt.models.OGC: Open GeoSpatial Consortium models.""" 2 | 3 | 4 | from typing import List 5 | 6 | from pydantic import AnyHttpUrl, BaseModel 7 | 8 | from timvt.resources.enums import MimeTypes 9 | 10 | 11 | class TileMatrixSetLink(BaseModel): 12 | """ 13 | TileMatrixSetLink model. 14 | 15 | Based on http://docs.opengeospatial.org/per/19-069.html#_tilematrixsets 16 | 17 | """ 18 | 19 | href: AnyHttpUrl 20 | rel: str = "item" 21 | type: MimeTypes = MimeTypes.json 22 | 23 | class Config: 24 | """Config for model.""" 25 | 26 | use_enum_values = True 27 | 28 | 29 | class TileMatrixSetRef(BaseModel): 30 | """ 31 | TileMatrixSetRef model. 32 | 33 | Based on http://docs.opengeospatial.org/per/19-069.html#_tilematrixsets 34 | 35 | """ 36 | 37 | id: str 38 | title: str 39 | links: List[TileMatrixSetLink] 40 | 41 | 42 | class TileMatrixSetList(BaseModel): 43 | """ 44 | TileMatrixSetList model. 45 | 46 | Based on http://docs.opengeospatial.org/per/19-069.html#_tilematrixsets 47 | 48 | """ 49 | 50 | tileMatrixSets: List[TileMatrixSetRef] 51 | -------------------------------------------------------------------------------- /timvt/models/__init__.py: -------------------------------------------------------------------------------- 1 | """timvt.models""" 2 | -------------------------------------------------------------------------------- /timvt/models/mapbox.py: -------------------------------------------------------------------------------- 1 | """Tilejson response models.""" 2 | 3 | from enum import Enum 4 | from typing import List, Optional, Tuple 5 | 6 | from pydantic import BaseModel, Field, root_validator 7 | 8 | 9 | class SchemeEnum(str, Enum): 10 | """TileJSON scheme choice.""" 11 | 12 | xyz = "xyz" 13 | tms = "tms" 14 | 15 | 16 | class TileJSON(BaseModel): 17 | """ 18 | TileJSON model. 19 | 20 | Based on https://github.com/mapbox/tilejson-spec/tree/master/2.2.0 21 | 22 | """ 23 | 24 | tilejson: str = "2.2.0" 25 | name: Optional[str] 26 | description: Optional[str] 27 | version: str = "1.0.0" 28 | attribution: Optional[str] 29 | template: Optional[str] 30 | legend: Optional[str] 31 | scheme: SchemeEnum = SchemeEnum.xyz 32 | tiles: List[str] 33 | grids: Optional[List[str]] 34 | data: Optional[List[str]] 35 | minzoom: int = Field(0, ge=0, le=30) 36 | maxzoom: int = Field(30, ge=0, le=30) 37 | bounds: List[float] = [-180, -90, 180, 90] 38 | center: Optional[Tuple[float, float, int]] 39 | 40 | @root_validator 41 | def compute_center(cls, values): 42 | """Compute center if it does not exist.""" 43 | bounds = values["bounds"] 44 | if not values.get("center"): 45 | values["center"] = ( 46 | (bounds[0] + bounds[2]) / 2, 47 | (bounds[1] + bounds[3]) / 2, 48 | values["minzoom"], 49 | ) 50 | return values 51 | 52 | class Config: 53 | """TileJSON model configuration.""" 54 | 55 | use_enum_values = True 56 | -------------------------------------------------------------------------------- /timvt/resources/__init__.py: -------------------------------------------------------------------------------- 1 | """timvt.resources.""" 2 | -------------------------------------------------------------------------------- /timvt/resources/enums.py: -------------------------------------------------------------------------------- 1 | """timvt.resources.enums.""" 2 | 3 | from enum import Enum 4 | 5 | 6 | class VectorType(str, Enum): 7 | """Vector Type Enums.""" 8 | 9 | pbf = "pbf" 10 | mvt = "mvt" 11 | 12 | 13 | class MimeTypes(str, Enum): 14 | """Responses MineTypes.""" 15 | 16 | xml = "application/xml" 17 | json = "application/json" 18 | geojson = "application/geo+json" 19 | html = "text/html" 20 | text = "text/plain" 21 | pbf = "application/x-protobuf" 22 | mvt = "application/x-protobuf" 23 | -------------------------------------------------------------------------------- /timvt/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | TiMVT config. 3 | 4 | TiMVT uses pydantic.BaseSettings to either get settings from `.env` or environment variables 5 | see: https://pydantic-docs.helpmanual.io/usage/settings/ 6 | 7 | """ 8 | import sys 9 | from functools import lru_cache 10 | from typing import Any, Dict, List, Optional 11 | 12 | import pydantic 13 | 14 | # Pydantic does not support older versions of typing.TypedDict 15 | # https://github.com/pydantic/pydantic/pull/3374 16 | if sys.version_info < (3, 9, 2): 17 | from typing_extensions import TypedDict 18 | else: 19 | from typing import TypedDict 20 | 21 | 22 | class TableConfig(TypedDict, total=False): 23 | """Configuration to add table options with env variables.""" 24 | 25 | geomcol: Optional[str] 26 | datetimecol: Optional[str] 27 | pk: Optional[str] 28 | properties: Optional[List[str]] 29 | 30 | 31 | class TableSettings(pydantic.BaseSettings): 32 | """Table configuration settings""" 33 | 34 | fallback_key_names: List[str] = ["ogc_fid", "id", "pkey", "gid"] 35 | table_config: Dict[str, TableConfig] = {} 36 | 37 | class Config: 38 | """model config""" 39 | 40 | env_prefix = "TIMVT_" 41 | env_file = ".env" 42 | env_nested_delimiter = "__" 43 | 44 | 45 | class _ApiSettings(pydantic.BaseSettings): 46 | """API settings""" 47 | 48 | name: str = "TiMVT" 49 | cors_origins: str = "*" 50 | cachecontrol: str = "public, max-age=3600" 51 | debug: bool = False 52 | functions_directory: Optional[str] 53 | 54 | @pydantic.validator("cors_origins") 55 | def parse_cors_origin(cls, v): 56 | """Parse CORS origins.""" 57 | return [origin.strip() for origin in v.split(",")] 58 | 59 | class Config: 60 | """model config""" 61 | 62 | env_prefix = "TIMVT_" 63 | env_file = ".env" 64 | 65 | 66 | @lru_cache() 67 | def ApiSettings() -> _ApiSettings: 68 | """ 69 | This function returns a cached instance of the APISettings object. 70 | Caching is used to prevent re-reading the environment every time the API settings are used in an endpoint. 71 | If you want to change an environment variable and reset the cache (e.g., during testing), this can be done 72 | using the `lru_cache` instance method `get_api_settings.cache_clear()`. 73 | 74 | From https://github.com/dmontagu/fastapi-utils/blob/af95ff4a8195caaa9edaa3dbd5b6eeb09691d9c7/fastapi_utils/api_settings.py#L60-L69 75 | """ 76 | return _ApiSettings() 77 | 78 | 79 | class _TileSettings(pydantic.BaseSettings): 80 | """MVT settings""" 81 | 82 | tile_resolution: int = 4096 83 | tile_buffer: int = 256 84 | max_features_per_tile: int = 10000 85 | default_tms: str = "WebMercatorQuad" 86 | default_minzoom: int = 0 87 | default_maxzoom: int = 22 88 | 89 | class Config: 90 | """model config""" 91 | 92 | env_prefix = "TIMVT_" 93 | env_file = ".env" 94 | 95 | 96 | @lru_cache() 97 | def TileSettings() -> _TileSettings: 98 | """Cache settings.""" 99 | return _TileSettings() 100 | 101 | 102 | class PostgresSettings(pydantic.BaseSettings): 103 | """Postgres-specific API settings. 104 | 105 | Attributes: 106 | postgres_user: postgres username. 107 | postgres_pass: postgres password. 108 | postgres_host: hostname for the connection. 109 | postgres_port: database port. 110 | postgres_dbname: database name. 111 | """ 112 | 113 | postgres_user: Optional[str] 114 | postgres_pass: Optional[str] 115 | postgres_host: Optional[str] 116 | postgres_port: Optional[str] 117 | postgres_dbname: Optional[str] 118 | 119 | database_url: Optional[pydantic.PostgresDsn] = None 120 | 121 | db_min_conn_size: int = 1 122 | db_max_conn_size: int = 10 123 | db_max_queries: int = 50000 124 | db_max_inactive_conn_lifetime: float = 300 125 | 126 | db_schemas: List[str] = ["public"] 127 | db_tables: Optional[List[str]] 128 | 129 | class Config: 130 | """model config""" 131 | 132 | env_file = ".env" 133 | 134 | # https://github.com/tiangolo/full-stack-fastapi-postgresql/blob/master/%7B%7Bcookiecutter.project_slug%7D%7D/backend/app/app/core/config.py#L42 135 | @pydantic.validator("database_url", pre=True) 136 | def assemble_db_connection(cls, v: Optional[str], values: Dict[str, Any]) -> Any: 137 | """Validate database config.""" 138 | if isinstance(v, str): 139 | return v 140 | 141 | return pydantic.PostgresDsn.build( 142 | scheme="postgresql", 143 | user=values.get("postgres_user"), 144 | password=values.get("postgres_pass"), 145 | host=values.get("postgres_host", ""), 146 | port=values.get("postgres_port", 5432), 147 | path=f"/{values.get('postgres_dbname') or ''}", 148 | ) 149 | -------------------------------------------------------------------------------- /timvt/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | TiMVT 6 | 7 | 8 | 29 | 30 | 31 |
32 |
33 |        _____   _     __  __  __     __  _____
34 |       |_   _| (_)   |  \/  | \ \   / / |_   _|
35 |         | |   | |   | |\/| |  \ \ / /    | |
36 |         | |   | |   | |  | |   \ V /     | |
37 |         |_|   |_|   |_|  |_|    \_/      |_|
38 |       
39 |
40 | 46 |

47 | Created by 48 | 49 | Development Seed 52 | 53 |

54 | 55 | 56 | -------------------------------------------------------------------------------- /timvt/templates/viewer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | TiMVT 7 | 8 | 9 | 10 | 11 | 12 | 27 | 28 | 29 | 30 |
31 | 145 | 146 | 147 | 148 | 149 | --------------------------------------------------------------------------------