├── .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 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
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 | 
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 |
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 |
--------------------------------------------------------------------------------