├── .github
├── codecov.yml
└── workflows
│ └── ci.yml
├── .gitignore
├── .pre-commit-config.yaml
├── CHANGES.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── pyproject.toml
├── tests
├── __init__.py
├── conftest.py
├── fixtures
│ ├── protomaps(vector)ODbL_firenze.pmtiles
│ ├── stamen_toner(raster)CC-BY_ODbL_z3.pmtiles
│ └── usgs-mt-whitney-8-15-webp-512.pmtiles
├── test_endoints.py
└── test_main.py
└── thatchertiler
├── __init__.py
├── data
└── WebMercatorQuad.json
├── factory.py
├── main.py
├── model.py
├── py.typed
├── routing.py
├── settings.py
└── templates
├── index.html
└── wmts.xml
/.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 main
4 | on:
5 | push:
6 | branches:
7 | - main
8 | tags:
9 | - '*'
10 | pull_request:
11 | env:
12 | LATEST_PY_VERSION: '3.10'
13 |
14 | jobs:
15 | tests:
16 | runs-on: ubuntu-20.04
17 | strategy:
18 | matrix:
19 | python-version:
20 | - '3.8'
21 | - '3.9'
22 | - '3.10'
23 | - '3.11'
24 |
25 | steps:
26 | - uses: actions/checkout@v3
27 | - name: Set up Python ${{ matrix.python-version }}
28 | uses: actions/setup-python@v4
29 | with:
30 | python-version: ${{ matrix.python-version }}
31 |
32 | - name: Install dependencies
33 | run: |
34 | python -m pip install --upgrade pip
35 | python -m pip install -e .["test"]
36 |
37 | - name: Run pre-commit
38 | if: ${{ matrix.python-version == env.LATEST_PY_VERSION }}
39 | run: |
40 | python -m pip install pre-commit
41 | pre-commit run --all-files
42 |
43 | - name: Run tests
44 | run: python -m pytest --cov thatchertiler --cov-report xml --cov-report term-missing --asyncio-mode=strict -s -vv
45 |
46 | - name: Upload Results
47 | if: ${{ matrix.python-version == env.LATEST_PY_VERSION }}
48 | uses: codecov/codecov-action@v1
49 | with:
50 | file: ./coverage.xml
51 | flags: unittests
52 | name: ${{ matrix.python-version }}
53 | fail_ci_if_error: false
54 |
55 |
56 | # publish:
57 | # needs: [tests]
58 | # runs-on: ubuntu-latest
59 | # if: startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release'
60 | # steps:
61 | # - uses: actions/checkout@v3
62 | # - name: Set up Python
63 | # uses: actions/setup-python@v4
64 | # with:
65 | # python-version: ${{ env.LATEST_PY_VERSION }}
66 |
67 | # - name: Install dependencies
68 | # run: |
69 | # python -m pip install --upgrade pip
70 | # python -m pip install wheel twine build
71 | # python -m pip install .
72 |
73 | # - name: Set tag version
74 | # id: tag
75 | # run: |
76 | # echo "version=${GITHUB_REF#refs/*/}"
77 | # echo "version=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT
78 |
79 | # - name: Set module version
80 | # id: module
81 | # run: |
82 | # echo version=$(python -c'import thatchertiler; print(thatchertiler.__version__)') >> $GITHUB_OUTPUT
83 |
84 | # - name: Build and publish
85 | # if: ${{ steps.tag.outputs.version }} == ${{ steps.module.outputs.version}}
86 | # env:
87 | # TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
88 | # TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
89 | # run: |
90 | # rm -rf dist
91 | # python -m build
92 | # twine upload dist/*
93 |
--------------------------------------------------------------------------------
/.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 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | pip-wheel-metadata/
24 | share/python-wheels/
25 | *.egg-info/
26 | .installed.cfg
27 | *.egg
28 | MANIFEST
29 |
30 | # PyInstaller
31 | # Usually these files are written by a python script from a template
32 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
33 | *.manifest
34 | *.spec
35 |
36 | # Installer logs
37 | pip-log.txt
38 | pip-delete-this-directory.txt
39 |
40 | # Unit test / coverage reports
41 | htmlcov/
42 | .tox/
43 | .nox/
44 | .coverage
45 | .coverage.*
46 | .cache
47 | nosetests.xml
48 | coverage.xml
49 | *.cover
50 | *.py,cover
51 | .hypothesis/
52 | .pytest_cache/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | target/
76 |
77 | # Jupyter Notebook
78 | .ipynb_checkpoints
79 |
80 | # IPython
81 | profile_default/
82 | ipython_config.py
83 |
84 | # pyenv
85 | .python-version
86 |
87 | # pipenv
88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
91 | # install all needed dependencies.
92 | #Pipfile.lock
93 |
94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
95 | __pypackages__/
96 |
97 | # Celery stuff
98 | celerybeat-schedule
99 | celerybeat.pid
100 |
101 | # SageMath parsed files
102 | *.sage.py
103 |
104 | # Environments
105 | .env
106 | .venv
107 | env/
108 | venv/
109 | ENV/
110 | env.bak/
111 | venv.bak/
112 |
113 | # Spyder project settings
114 | .spyderproject
115 | .spyproject
116 |
117 | # Rope project settings
118 | .ropeproject
119 |
120 | # mkdocs documentation
121 | /site
122 |
123 | # mypy
124 | .mypy_cache/
125 | .dmypy.json
126 | dmypy.json
127 |
128 | # Pyre type checker
129 | .pyre/
130 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/abravalheri/validate-pyproject
3 | rev: v0.12.1
4 | hooks:
5 | - id: validate-pyproject
6 |
7 | - repo: https://github.com/psf/black
8 | rev: 22.12.0
9 | hooks:
10 | - id: black
11 | language_version: python
12 |
13 | - repo: https://github.com/PyCQA/isort
14 | rev: 5.12.0
15 | hooks:
16 | - id: isort
17 | language_version: python
18 |
19 | - repo: https://github.com/charliermarsh/ruff-pre-commit
20 | rev: v0.0.238
21 | hooks:
22 | - id: ruff
23 | args: ["--fix"]
24 |
25 | - repo: https://github.com/pre-commit/mirrors-mypy
26 | rev: v0.991
27 | hooks:
28 | - id: mypy
29 | language_version: python
30 | # No reason to run if only tests have changed. They intentionally break typing.
31 | exclude: tests/.*
32 | additional_dependencies:
33 | - types-aiofiles
34 |
--------------------------------------------------------------------------------
/CHANGES.md:
--------------------------------------------------------------------------------
1 |
2 | ## 0.1.0 (TBD)
3 |
4 | * Initial release.
5 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | Issues and pull requests are more than welcome.
4 |
5 | ### dev install
6 |
7 | ```bash
8 | python -m pip install pip -U
9 | git clone https://github.com/developmentseed/thatchertiler.git
10 | cd thatchertiler
11 | python -m pip install -e ".[test,dev]"
12 | ```
13 |
14 | You can then run the tests with the following command:
15 |
16 | ```sh
17 | python -m pytest --cov thatchertiler --cov-report term-missing -s -vv
18 | ```
19 |
20 | ### pre-commit
21 |
22 | This repo is set to use `pre-commit` to run *isort*, *flake8*, *pydocstring*, *black* ("uncompromising Python code formatter") and mypy when committing new code.
23 |
24 | ```bash
25 | $ pre-commit install
26 | ```
27 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 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 | ## ThatcherTiler
2 |
3 |
4 |
5 |
ThatcherTiler: expect some features to be dropped.
6 |
7 |
8 |
9 | ---
10 |
11 | **Documentation**:
12 |
13 | **Source Code**: https://github.com/developmentseed/thatchertiler
14 |
15 | ---
16 |
17 | `ThatcherTiler`* is a lightweight Raster/Vector tiles server based on the great [**PM**Tiles](https://github.com/protomaps/PMTiles) *Cloud-optimized + compressed single-file tile archives for vector and raster maps*.
18 |
19 | While the original idea behind *PMTiles* is to create a single file which can be accessed directly from map client (e.g https://protomaps.com/docs/frontends/maplibre) some map client yet do not support it (e.g `Mapbox`) while a need for a `tile server` between the map client and the archive.
20 |
21 | `ThatcherTiler` is a **DEMO** project to showcase the use of [aiopmtiles](https://github.com/developmentseed/aiopmtiles).
22 |
23 |
24 | ## Install
25 |
26 | ```bash
27 | python -m pip install pip -U
28 | git clone https://github.com/developmentseed/thatchertiler.git
29 | cd thatchertiler
30 | python -m pip install -e ".[server]"
31 | ```
32 |
33 | ## Launch
34 |
35 | ```bash
36 | uvicorn thatchertiler.main:app --port 8080 --reload
37 |
38 | >> INFO: Uvicorn running on http://127.0.0.1:8080 (Press CTRL+C to quit)
39 | ```
40 |
41 | `http://127.0.0.1:8080/api.html`
42 |
43 | 
44 |
45 |
46 | `http://127.0.0.1:8080/map?url=https://protomaps.github.io/PMTiles/protomaps(vector)ODbL_firenze.pmtiles`
47 |
48 | 
49 |
50 | ## License
51 |
52 | See [LICENSE](https://github.com/developmentseed/thatchertiler/blob/main/LICENSE)
53 |
54 | ## Authors
55 |
56 | Created by [Development Seed]()
57 |
58 | *`ThatcherTiler` name comes from a long a intense debate among @nerik, @kamicut, @batpad and @vincentsarago to find the better of the worse name. `Thatcher` is a reference to [`Margaret Thatcher`](https://fr.wikipedia.org/wiki/Margaret_Thatcher) which was once **P**rime **M**inister of the United Kingdom.
59 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "thatchertiler"
3 | description = "ThatcherTiler: expect some features to be dropped. A lightweight tiles server based on PMTiles format."
4 | readme = "README.md"
5 | requires-python = ">=3.8"
6 | license = {file = "LICENSE"}
7 | authors = [
8 | {name = "Vincent Sarago", email = "vincent@developmentseed.com"},
9 | ]
10 | dynamic = ["version"]
11 | dependencies = [
12 | "aiopmtiles @ git+https://github.com/developmentseed/aiopmtiles.git",
13 | "uvicorn",
14 | "fastapi>=0.100.0",
15 | "jinja2>=2.11.2,<4.0.0",
16 | "pydantic>=2.4,<3.0",
17 | "pydantic-settings~=2.0",
18 | ]
19 |
20 | [project.optional-dependencies]
21 | test = [
22 | "pytest",
23 | "pytest-cov",
24 | "pytest-asyncio",
25 | "httpx",
26 | ]
27 | dev = [
28 | "pre-commit",
29 | ]
30 | server = [
31 | "uvicorn[standard]>=0.12.0,<0.19.0",
32 | ]
33 |
34 | [build-system]
35 | requires = ["flit>=3.2,<4"]
36 | build-backend = "flit_core.buildapi"
37 |
38 | [tool.flit.module]
39 | name = "thatchertiler"
40 |
41 | [tool.flit.sdist]
42 | exclude = [
43 | "tests/",
44 | "docs/",
45 | ".github/",
46 | "CHANGES.md",
47 | "CONTRIBUTING.md",
48 | ]
49 |
50 | [tool.coverage.run]
51 | branch = true
52 | parallel = true
53 |
54 | [tool.coverage.report]
55 | exclude_lines = [
56 | "no cov",
57 | "if __name__ == .__main__.:",
58 | "if TYPE_CHECKING:",
59 | ]
60 |
61 | [tool.isort]
62 | profile = "black"
63 | known_first_party = ["thatchertiler"]
64 | known_third_party = ["aiopmtiles"]
65 | default_section = "THIRDPARTY"
66 |
67 | [tool.mypy]
68 | no_strict_optional = true
69 |
70 | [tool.ruff]
71 | select = [
72 | "D1", # pydocstyle errors
73 | "E", # pycodestyle errors
74 | "W", # pycodestyle warnings
75 | "C", # flake8-comprehensions
76 | "B", # flake8-bugbear
77 | ]
78 | ignore = [
79 | "E501", # line too long, handled by black
80 | "B008", # do not perform function calls in argument defaults
81 | "B905", # ignore zip() without an explicit strict= parameter, only support with python >3.10
82 | ]
83 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 | """thatchertiler."""
2 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | """``pytest`` configuration."""
2 |
3 | import os
4 |
5 | import pytest
6 | from starlette.testclient import TestClient
7 |
8 |
9 | @pytest.fixture
10 | def set_env(monkeypatch):
11 | """Set Env variables."""
12 | monkeypatch.setenv("AWS_ACCESS_KEY_ID", "jqt")
13 | monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "rde")
14 | monkeypatch.setenv("AWS_DEFAULT_REGION", "us-west-2")
15 | monkeypatch.setenv("AWS_REGION", "us-west-2")
16 | monkeypatch.delenv("AWS_PROFILE", raising=False)
17 | monkeypatch.setenv("AWS_CONFIG_FILE", "/tmp/noconfigheere")
18 | monkeypatch.setenv("THATCHERTILER_API_CACHECONTROL", "private, max-age=3600")
19 |
20 |
21 | @pytest.fixture(autouse=True)
22 | def app(set_env) -> TestClient:
23 | """Create App."""
24 | from thatchertiler.main import app
25 |
26 | return TestClient(app)
27 |
28 |
29 | @pytest.fixture(autouse=True)
30 | def data_dir() -> str:
31 | """fixture directory."""
32 | return os.path.join(os.path.dirname(__file__), "fixtures")
33 |
--------------------------------------------------------------------------------
/tests/fixtures/protomaps(vector)ODbL_firenze.pmtiles:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/thatchertiler/afc8b72e933209542277f1d784f905da68353eab/tests/fixtures/protomaps(vector)ODbL_firenze.pmtiles
--------------------------------------------------------------------------------
/tests/fixtures/stamen_toner(raster)CC-BY_ODbL_z3.pmtiles:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/thatchertiler/afc8b72e933209542277f1d784f905da68353eab/tests/fixtures/stamen_toner(raster)CC-BY_ODbL_z3.pmtiles
--------------------------------------------------------------------------------
/tests/fixtures/usgs-mt-whitney-8-15-webp-512.pmtiles:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/thatchertiler/afc8b72e933209542277f1d784f905da68353eab/tests/fixtures/usgs-mt-whitney-8-15-webp-512.pmtiles
--------------------------------------------------------------------------------
/tests/test_endoints.py:
--------------------------------------------------------------------------------
1 | """test endpoints."""
2 |
3 | import os
4 |
5 | import pytest
6 |
7 | datasets = [
8 | "protomaps(vector)ODbL_firenze.pmtiles",
9 | "stamen_toner(raster)CC-BY_ODbL_z3.pmtiles",
10 | "usgs-mt-whitney-8-15-webp-512.pmtiles",
11 | ]
12 |
13 |
14 | @pytest.mark.parametrize("dataset", datasets)
15 | def test_metadata(app, data_dir, dataset):
16 | """test /metadata endpoint"""
17 | response = app.get(f"/metadata?url={os.path.join(data_dir, dataset)}")
18 | assert response.status_code == 200
19 | assert response.headers["content-type"] == "application/json"
20 |
21 |
22 | @pytest.mark.parametrize("dataset", datasets)
23 | def test_tilejson(app, data_dir, dataset):
24 | """test /tilejson endpoint"""
25 | response = app.get(f"/tilejson.json?url={os.path.join(data_dir, dataset)}")
26 | assert response.status_code == 200
27 | assert response.headers["content-type"] == "application/json"
28 |
29 |
30 | @pytest.mark.parametrize("dataset", datasets)
31 | def test_stylejson(app, data_dir, dataset):
32 | """test /style.json endpoint"""
33 | response = app.get(f"/style.json?url={os.path.join(data_dir, dataset)}")
34 | assert response.status_code == 200
35 | assert response.headers["content-type"] == "application/json"
36 |
37 |
38 | @pytest.mark.parametrize("dataset", datasets)
39 | def test_map(app, data_dir, dataset):
40 | """test /map endpoint"""
41 | response = app.get(f"/map?url={os.path.join(data_dir, dataset)}")
42 | assert response.status_code == 200
43 | assert "text/html" in response.headers["content-type"]
44 |
45 |
46 | @pytest.mark.parametrize("dataset", datasets)
47 | def test_wmts(app, data_dir, dataset):
48 | """test /wmts endpoint"""
49 | response = app.get(f"/WMTSCapabilities.xml?url={os.path.join(data_dir, dataset)}")
50 | assert response.status_code == 200
51 | assert response.headers["content-type"] == "application/xml"
52 |
--------------------------------------------------------------------------------
/tests/test_main.py:
--------------------------------------------------------------------------------
1 | """Test thatchertiler.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 |
10 | response = app.get("/api")
11 | assert response.status_code == 200
12 |
13 | response = app.get("/api.html")
14 | assert response.status_code == 200
15 |
--------------------------------------------------------------------------------
/thatchertiler/__init__.py:
--------------------------------------------------------------------------------
1 | """tipmtiles."""
2 |
3 | __version__ = "0.1.0"
4 |
--------------------------------------------------------------------------------
/thatchertiler/data/WebMercatorQuad.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "WebMercatorQuad",
3 | "title": "Google Maps Compatible for the World",
4 | "uri": "http://www.opengis.net/def/tilematrixset/OGC/1.0/WebMercatorQuad",
5 | "crs": "http://www.opengis.net/def/crs/EPSG/0/3857",
6 | "orderedAxes": ["X", "Y"],
7 | "wellKnownScaleSet": "http://www.opengis.net/def/wkss/OGC/1.0/GoogleMapsCompatible",
8 | "tileMatrices":
9 | [
10 | {
11 | "id": "0",
12 | "scaleDenominator": 559082264.028717,
13 | "cellSize": 156543.033928041,
14 | "pointOfOrigin": [-20037508.342789244,20037508.342789244],
15 | "tileWidth": 256,
16 | "tileHeight": 256,
17 | "matrixWidth": 1,
18 | "matrixHeight": 1
19 | },
20 | {
21 | "id": "1",
22 | "scaleDenominator": 279541132.014358,
23 | "cellSize": 78271.5169640204,
24 | "pointOfOrigin": [-20037508.342789244,20037508.342789244],
25 | "tileWidth": 256,
26 | "tileHeight": 256,
27 | "matrixWidth": 2,
28 | "matrixHeight": 2
29 | },
30 | {
31 | "id": "2",
32 | "scaleDenominator": 139770566.007179,
33 | "cellSize": 39135.7584820102,
34 | "pointOfOrigin": [-20037508.342789244,20037508.342789244],
35 | "tileWidth": 256,
36 | "tileHeight": 256,
37 | "matrixWidth": 4,
38 | "matrixHeight": 4
39 | },
40 | {
41 | "id": "3",
42 | "scaleDenominator": 69885283.0035897,
43 | "cellSize": 19567.8792410051,
44 | "pointOfOrigin": [-20037508.342789244,20037508.342789244],
45 | "tileWidth": 256,
46 | "tileHeight": 256,
47 | "matrixWidth": 8,
48 | "matrixHeight": 8
49 | },
50 | {
51 | "id": "4",
52 | "scaleDenominator": 34942641.5017948,
53 | "cellSize": 9783.93962050256,
54 | "pointOfOrigin": [-20037508.342789244,20037508.342789244],
55 | "tileWidth": 256,
56 | "tileHeight": 256,
57 | "matrixWidth": 16,
58 | "matrixHeight": 16
59 | },
60 | {
61 | "id": "5",
62 | "scaleDenominator": 17471320.7508974,
63 | "cellSize": 4891.96981025128,
64 | "pointOfOrigin": [-20037508.342789244,20037508.342789244],
65 | "tileWidth": 256,
66 | "tileHeight": 256,
67 | "matrixWidth": 32,
68 | "matrixHeight": 32
69 | },
70 | {
71 | "id": "6",
72 | "scaleDenominator": 8735660.37544871,
73 | "cellSize": 2445.98490512564,
74 | "pointOfOrigin": [-20037508.342789244,20037508.342789244],
75 | "tileWidth": 256,
76 | "tileHeight": 256,
77 | "matrixWidth": 64,
78 | "matrixHeight": 64
79 | },
80 | {
81 | "id": "7",
82 | "scaleDenominator": 4367830.18772435,
83 | "cellSize": 1222.99245256282,
84 | "pointOfOrigin": [-20037508.342789244,20037508.342789244],
85 | "tileWidth": 256,
86 | "tileHeight": 256,
87 | "matrixWidth": 128,
88 | "matrixHeight": 128
89 | },
90 | {
91 | "id": "8",
92 | "scaleDenominator": 2183915.09386217,
93 | "cellSize": 611.49622628141,
94 | "pointOfOrigin": [-20037508.342789244,20037508.342789244],
95 | "tileWidth": 256,
96 | "tileHeight": 256,
97 | "matrixWidth": 256,
98 | "matrixHeight": 256
99 | },
100 | {
101 | "id": "9",
102 | "scaleDenominator": 1091957.54693108,
103 | "cellSize": 305.748113140704,
104 | "pointOfOrigin": [-20037508.342789244,20037508.342789244],
105 | "tileWidth": 256,
106 | "tileHeight": 256,
107 | "matrixWidth": 512,
108 | "matrixHeight": 512
109 | },
110 | {
111 | "id": "10",
112 | "scaleDenominator": 545978.773465544,
113 | "cellSize": 152.874056570352,
114 | "pointOfOrigin": [-20037508.342789244,20037508.342789244],
115 | "tileWidth": 256,
116 | "tileHeight": 256,
117 | "matrixWidth": 1024,
118 | "matrixHeight": 1024
119 | },
120 | {
121 | "id": "11",
122 | "scaleDenominator": 272989.386732772,
123 | "cellSize": 76.4370282851762,
124 | "pointOfOrigin": [-20037508.342789244,20037508.342789244],
125 | "tileWidth": 256,
126 | "tileHeight": 256,
127 | "matrixWidth": 2048,
128 | "matrixHeight": 2048
129 | },
130 | {
131 | "id": "12",
132 | "scaleDenominator": 136494.693366386,
133 | "cellSize": 38.2185141425881,
134 | "pointOfOrigin": [-20037508.342789244,20037508.342789244],
135 | "tileWidth": 256,
136 | "tileHeight": 256,
137 | "matrixWidth": 4096,
138 | "matrixHeight": 4096
139 | },
140 | {
141 | "id": "13",
142 | "scaleDenominator": 68247.346683193,
143 | "cellSize": 19.109257071294,
144 | "pointOfOrigin": [-20037508.342789244,20037508.342789244],
145 | "tileWidth": 256,
146 | "tileHeight": 256,
147 | "matrixWidth": 8192,
148 | "matrixHeight": 8192
149 | },
150 | {
151 | "id": "14",
152 | "scaleDenominator": 34123.6733415964,
153 | "cellSize": 9.55462853564703,
154 | "pointOfOrigin": [-20037508.342789244,20037508.342789244],
155 | "tileWidth": 256,
156 | "tileHeight": 256,
157 | "matrixWidth": 16384,
158 | "matrixHeight": 16384
159 | },
160 | {
161 | "id": "15",
162 | "scaleDenominator": 17061.8366707982,
163 | "cellSize": 4.77731426782351,
164 | "pointOfOrigin": [-20037508.342789244,20037508.342789244],
165 | "tileWidth": 256,
166 | "tileHeight": 256,
167 | "matrixWidth": 32768,
168 | "matrixHeight": 32768
169 | },
170 | {
171 | "id": "16",
172 | "scaleDenominator": 8530.91833539913,
173 | "cellSize": 2.38865713391175,
174 | "pointOfOrigin": [-20037508.342789244,20037508.342789244],
175 | "tileWidth": 256,
176 | "tileHeight": 256,
177 | "matrixWidth": 65536,
178 | "matrixHeight": 65536
179 | },
180 | {
181 | "id": "17",
182 | "scaleDenominator": 4265.45916769956,
183 | "cellSize": 1.19432856695587,
184 | "pointOfOrigin": [-20037508.342789244,20037508.342789244],
185 | "tileWidth": 256,
186 | "tileHeight": 256,
187 | "matrixWidth": 131072,
188 | "matrixHeight": 131072
189 | },
190 | {
191 | "id": "18",
192 | "scaleDenominator": 2132.72958384978,
193 | "cellSize": 0.597164283477939,
194 | "pointOfOrigin": [-20037508.342789244,20037508.342789244],
195 | "tileWidth": 256,
196 | "tileHeight": 256,
197 | "matrixWidth": 262144,
198 | "matrixHeight": 262144
199 | },
200 | {
201 | "id": "19",
202 | "scaleDenominator": 1066.36479192489,
203 | "cellSize": 0.29858214173897,
204 | "pointOfOrigin": [-20037508.342789244,20037508.342789244],
205 | "tileWidth": 256,
206 | "tileHeight": 256,
207 | "matrixWidth": 524288,
208 | "matrixHeight": 524288
209 | },
210 | {
211 | "id": "20",
212 | "scaleDenominator": 533.182395962445,
213 | "cellSize": 0.149291070869485,
214 | "pointOfOrigin": [-20037508.342789244,20037508.342789244],
215 | "tileWidth": 256,
216 | "tileHeight": 256,
217 | "matrixWidth": 1048576,
218 | "matrixHeight": 1048576
219 | },
220 | {
221 | "id": "21",
222 | "scaleDenominator": 266.591197981222,
223 | "cellSize": 0.0746455354347424,
224 | "pointOfOrigin": [-20037508.342789244,20037508.342789244],
225 | "tileWidth": 256,
226 | "tileHeight": 256,
227 | "matrixWidth": 2097152,
228 | "matrixHeight": 2097152
229 | },
230 | {
231 | "id": "22",
232 | "scaleDenominator": 133.295598990611,
233 | "cellSize": 0.0373227677173712,
234 | "pointOfOrigin": [-20037508.342789244,20037508.342789244],
235 | "tileWidth": 256,
236 | "tileHeight": 256,
237 | "matrixWidth": 4194304,
238 | "matrixHeight": 4194304
239 | },
240 | {
241 | "id": "23",
242 | "scaleDenominator": 66.6477994953056,
243 | "cellSize": 0.0186613838586856,
244 | "pointOfOrigin": [-20037508.342789244,20037508.342789244],
245 | "tileWidth": 256,
246 | "tileHeight": 256,
247 | "matrixWidth": 8388608,
248 | "matrixHeight": 8388608
249 | },
250 | {
251 | "id": "24",
252 | "scaleDenominator": 33.3238997476528,
253 | "cellSize": 0.0093306919293428,
254 | "pointOfOrigin": [-20037508.342789244,20037508.342789244],
255 | "tileWidth": 256,
256 | "tileHeight": 256,
257 | "matrixWidth": 16777216,
258 | "matrixHeight": 16777216
259 | }
260 | ]
261 | }
262 |
--------------------------------------------------------------------------------
/thatchertiler/factory.py:
--------------------------------------------------------------------------------
1 | """thatchertiler.factory."""
2 |
3 | import abc
4 | import json
5 | import pathlib
6 | from dataclasses import dataclass, field
7 | from enum import Enum
8 | from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, Type, Union
9 | from urllib.parse import urlencode
10 |
11 | import jinja2
12 | from aiopmtiles import Reader
13 | from fastapi import APIRouter, Body, Depends, Path, Query
14 | from fastapi.dependencies.utils import get_parameterless_sub_dependant
15 | from fastapi.params import Depends as DependsFunc
16 | from pmtiles.tile import Compression
17 | from starlette.requests import Request
18 | from starlette.responses import HTMLResponse, Response
19 | from starlette.routing import Match, compile_path, replace_params
20 | from starlette.templating import Jinja2Templates
21 | from typing_extensions import Annotated
22 |
23 | from thatchertiler.model import StyleJSON, TileJSON
24 | from thatchertiler.routing import EndpointScope
25 |
26 | DEFAULT_TEMPLATES = Jinja2Templates(
27 | directory="",
28 | loader=jinja2.ChoiceLoader([jinja2.PackageLoader(__package__, "templates")]),
29 | )
30 |
31 | tms_data = pathlib.Path(__file__).parent.joinpath("data", "WebMercatorQuad.json")
32 |
33 |
34 | class MediaType(str, Enum):
35 | """Responses Media types formerly known as MIME types."""
36 |
37 | unknown = "application/x-binary"
38 | avif = "image/avif"
39 | tif = "image/tiff; application=geotiff"
40 | jp2 = "image/jp2"
41 | png = "image/png"
42 | pngraw = "image/png"
43 | jpeg = "image/jpeg"
44 | jpg = "image/jpg"
45 | webp = "image/webp"
46 | npy = "application/x-binary"
47 | xml = "application/xml"
48 | json = "application/json"
49 | geojson = "application/geo+json"
50 | html = "text/html"
51 | text = "text/plain"
52 | pbf = "application/x-protobuf"
53 | mvt = "application/x-protobuf"
54 |
55 |
56 | class XMLResponse(Response):
57 | """XML Response"""
58 |
59 | media_type = "application/xml"
60 |
61 |
62 | @dataclass # type: ignore
63 | class FactoryExtension(metaclass=abc.ABCMeta):
64 | """Factory Extension."""
65 |
66 | @abc.abstractmethod
67 | def register(self, factory: "TilerFactory"):
68 | """Register extension to the factory."""
69 | ...
70 |
71 |
72 | def PathParams(url: Annotated[str, Query(description="PMTiles archive URL.")]) -> str:
73 | """Dataset Parameter"""
74 | return url
75 |
76 |
77 | @dataclass
78 | class TilerFactory:
79 | """thatchertiler endpoint factory."""
80 |
81 | path_dependency: Callable[..., str] = PathParams
82 |
83 | # FastAPI router
84 | router: APIRouter = field(default_factory=APIRouter)
85 |
86 | # Router Prefix is needed to find the path for /tile if the TilerFactory.router is mounted
87 | # with other router (multiple `.../tile` routes).
88 | # e.g if you mount the route with `/cog` prefix, set router_prefix to cog and
89 | router_prefix: str = ""
90 |
91 | # add dependencies to specific routes
92 | route_dependencies: List[Tuple[List[EndpointScope], List[DependsFunc]]] = field(
93 | default_factory=list
94 | )
95 |
96 | extensions: List[FactoryExtension] = field(default_factory=list)
97 |
98 | templates: Jinja2Templates = DEFAULT_TEMPLATES
99 |
100 | with_wmts: bool = False
101 | with_map: bool = False
102 |
103 | def __post_init__(self):
104 | """Post Init: register route and configure specific options."""
105 | # Register endpoints
106 | self.register_routes()
107 |
108 | # Register Extensions
109 | for ext in self.extensions:
110 | ext.register(self)
111 |
112 | # Update endpoints dependencies
113 | for scopes, dependencies in self.route_dependencies:
114 | self.add_route_dependencies(scopes=scopes, dependencies=dependencies)
115 |
116 | def url_for(self, request: Request, name: str, **path_params: Any) -> str:
117 | """Return full url (with prefix) for a specific endpoint."""
118 | url_path = self.router.url_path_for(name, **path_params)
119 | base_url = str(request.base_url)
120 | if self.router_prefix:
121 | prefix = self.router_prefix.lstrip("/")
122 | # If we have prefix with custom path param we check and replace them with
123 | # the path params provided
124 | if "{" in prefix:
125 | _, path_format, param_convertors = compile_path(prefix)
126 | prefix, _ = replace_params(
127 | path_format, param_convertors, request.path_params.copy()
128 | )
129 | base_url += prefix
130 |
131 | return str(url_path.make_absolute_url(base_url=base_url))
132 |
133 | def add_route_dependencies(
134 | self,
135 | *,
136 | scopes: List[EndpointScope],
137 | dependencies=List[DependsFunc],
138 | ):
139 | """Add dependencies to routes.
140 |
141 | Allows a developer to add dependencies to a route after the route has been defined.
142 |
143 | """
144 | for route in self.router.routes:
145 | for scope in scopes:
146 | match, _ = route.matches({"type": "http", **scope}) # type: ignore
147 | if match != Match.FULL:
148 | continue
149 |
150 | # Mimicking how APIRoute handles dependencies:
151 | # https://github.com/tiangolo/fastapi/blob/1760da0efa55585c19835d81afa8ca386036c325/fastapi/routing.py#L408-L412
152 | for depends in dependencies[::-1]:
153 | route.dependant.dependencies.insert( # type: ignore
154 | 0,
155 | get_parameterless_sub_dependant(
156 | depends=depends, path=route.path_format # type: ignore
157 | ),
158 | )
159 |
160 | # Register dependencies directly on route so that they aren't ignored if
161 | # the routes are later associated with an app (e.g. app.include_router(router))
162 | # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/applications.py#L337-L360
163 | # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/routing.py#L677-L678
164 | route.dependencies.extend(dependencies) # type: ignore
165 |
166 | def register_routes(self):
167 | """Register Tiler Routes."""
168 |
169 | self._register_metadata()
170 | self._register_tiles()
171 | self._register_style()
172 |
173 | if self.with_map:
174 | self._register_map()
175 |
176 | if self.with_wmts:
177 | self._register_wmts()
178 |
179 | def _register_metadata(self):
180 | """register /metadata endpoint."""
181 |
182 | @self.router.get("/metadata")
183 | async def metadata(dataset=Depends(self.path_dependency)):
184 | """get PMTiles Metadata."""
185 | async with Reader(dataset) as src:
186 | return await src.metadata()
187 |
188 | def _register_tiles(self):
189 | """register /tiles and /tilejson.json endpoints."""
190 |
191 | @self.router.get("/tiles/{z}/{x}/{y}", response_class=Response)
192 | async def tiles_endpoint(
193 | z: Annotated[int, Path(ge=0, le=30, description="TMS tiles's zoom level")],
194 | x: Annotated[int, Path(description="TMS tiles's column")],
195 | y: Annotated[int, Path(description="TMS tiles's row")],
196 | dataset=Depends(self.path_dependency),
197 | ):
198 | """get Tile."""
199 | headers: Dict[str, str] = {}
200 |
201 | async with Reader(dataset) as src:
202 | data = await src.get_tile(z, x, y)
203 | if src.tile_compression != Compression.NONE:
204 | headers["Content-Encoding"] = src.tile_compression.name.lower()
205 |
206 | return Response(
207 | data,
208 | media_type=MediaType[src.tile_type.name.lower()].value,
209 | headers=headers,
210 | )
211 |
212 | @self.router.get(
213 | "/tilejson.json",
214 | response_model=TileJSON,
215 | response_model_exclude_none=True,
216 | )
217 | async def tilejson_endpoint(
218 | request: Request, dataset=Depends(self.path_dependency)
219 | ):
220 | """get TileJSON."""
221 | async with Reader(dataset) as src:
222 | meta = await src.metadata()
223 |
224 | route_params = {
225 | "z": "{z}",
226 | "x": "{x}",
227 | "y": "{y}",
228 | }
229 | tiles_url = self.url_for(request, "tiles_endpoint", **route_params)
230 |
231 | if request.query_params._list:
232 | tiles_url += f"?{urlencode(request.query_params._list)}"
233 |
234 | tilejson = {
235 | "name": "pmtiles",
236 | "version": "1.0.0",
237 | "scheme": "xyz",
238 | "tiles": [tiles_url],
239 | "minzoom": src.minzoom,
240 | "maxzoom": src.maxzoom,
241 | "bounds": src.bounds,
242 | "center": src.center,
243 | }
244 |
245 | if vector_layers := meta.get("vector_layers"):
246 | tilejson["vector_layers"] = vector_layers
247 |
248 | return tilejson
249 |
250 | def _register_style(self):
251 | """register /style.json endpoint."""
252 |
253 | @self.router.get(
254 | "/style.json",
255 | response_model=StyleJSON,
256 | response_model_exclude_none=True,
257 | )
258 | async def stylejson_endpoint(
259 | request: Request, dataset=Depends(self.path_dependency)
260 | ):
261 | """get StyleJSON."""
262 | route_params = {
263 | "z": "{z}",
264 | "x": "{x}",
265 | "y": "{y}",
266 | }
267 | tiles_url = self.url_for(request, "tiles_endpoint", **route_params)
268 |
269 | if request.query_params._list:
270 | tiles_url += f"?{urlencode(request.query_params._list)}"
271 |
272 | style_json: Dict[str, Any]
273 | async with Reader(dataset) as src:
274 | if src.is_vector:
275 | style_json = {
276 | "sources": {
277 | "pmtiles": {
278 | "type": "vector",
279 | "scheme": "xyz",
280 | "tiles": [tiles_url],
281 | "minzoom": src.minzoom,
282 | "maxzoom": src.maxzoom,
283 | "bounds": src.bounds,
284 | },
285 | "basemap": {
286 | "type": "raster",
287 | "tiles": [
288 | "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
289 | ],
290 | "tileSize": 256,
291 | "attribution": '© OpenStreetMap contributors',
292 | },
293 | },
294 | }
295 |
296 | layers: List[Dict[str, Any]] = [
297 | {
298 | "id": "basemap",
299 | "type": "raster",
300 | "source": "basemap",
301 | "minzoom": 0,
302 | "maxzoom": 20,
303 | }
304 | ]
305 |
306 | meta = await src.metadata()
307 | if vector_layers := meta.get("vector_layers"):
308 | for layer in vector_layers:
309 | layer_id = layer["id"]
310 | if layer_id == "mask":
311 | layers.append(
312 | {
313 | "id": f"{layer_id}_fill",
314 | "type": "fill",
315 | "source": "pmtiles",
316 | "source-layer": layer_id,
317 | "filter": ["==", ["geometry-type"], "Polygon"],
318 | "paint": {
319 | "fill-color": "black",
320 | "fill-opacity": 0.8,
321 | },
322 | }
323 | )
324 |
325 | else:
326 | layers.append(
327 | {
328 | "id": f"{layer_id}_fill",
329 | "type": "fill",
330 | "source": "pmtiles",
331 | "source-layer": layer_id,
332 | "filter": ["==", ["geometry-type"], "Polygon"],
333 | "paint": {
334 | "fill-color": "rgba(200, 100, 240, 0.4)",
335 | "fill-outline-color": "#000",
336 | },
337 | }
338 | )
339 |
340 | layers.append(
341 | {
342 | "id": f"{layer_id}_stroke",
343 | "source": "pmtiles",
344 | "source-layer": layer_id,
345 | "type": "line",
346 | "filter": ["==", ["geometry-type"], "LineString"],
347 | "paint": {
348 | "line-color": "#000",
349 | "line-width": 1,
350 | "line-opacity": 0.75,
351 | },
352 | }
353 | )
354 | layers.append(
355 | {
356 | "id": f"{layer_id}_point",
357 | "source": "pmtiles",
358 | "source-layer": layer_id,
359 | "type": "circle",
360 | "filter": ["==", ["geometry-type"], "Point"],
361 | "paint": {
362 | "circle-color": "#000",
363 | "circle-radius": 2.5,
364 | "circle-opacity": 0.75,
365 | },
366 | }
367 | )
368 |
369 | style_json["layers"] = layers
370 |
371 | else:
372 | style_json = {
373 | "sources": {
374 | "pmtiles": {
375 | "type": "raster",
376 | "scheme": "xyz",
377 | "tiles": [tiles_url],
378 | "minzoom": src.minzoom,
379 | "maxzoom": src.maxzoom,
380 | "bounds": src.bounds,
381 | },
382 | "basemap": {
383 | "type": "raster",
384 | "tiles": [
385 | "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
386 | ],
387 | "tileSize": 256,
388 | "attribution": '© OpenStreetMap contributors',
389 | },
390 | },
391 | "layers": [
392 | {
393 | "id": "basemap",
394 | "type": "raster",
395 | "source": "basemap",
396 | "minzoom": 0,
397 | "maxzoom": 20,
398 | },
399 | {
400 | "id": "raster",
401 | "type": "raster",
402 | "source": "pmtiles",
403 | },
404 | ],
405 | }
406 | style_json["center"] = [src.center[0], src.center[1]]
407 | style_json["zoom"] = src.center[2]
408 |
409 | return style_json
410 |
411 | def _register_map(self):
412 | """add /map endpoint."""
413 |
414 | @self.router.get("/map", response_class=HTMLResponse)
415 | async def map_endpoint(request: Request, dataset=Depends(self.path_dependency)):
416 | """Handle /index.html."""
417 | stylejson_url = self.url_for(request, "stylejson_endpoint")
418 | if request.query_params._list:
419 | stylejson_url += f"?{urlencode(request.query_params._list)}"
420 |
421 | return self.templates.TemplateResponse(
422 | name="index.html",
423 | context={
424 | "request": request,
425 | "style_endpoint": stylejson_url,
426 | },
427 | media_type="text/html",
428 | )
429 |
430 | def _register_wmts(self):
431 | """add /wmts endpoint."""
432 |
433 | @self.router.get("/WMTSCapabilities.xml", response_class=XMLResponse)
434 | async def wmts(request: Request, dataset=Depends(self.path_dependency)):
435 | """OGC WMTS endpoint."""
436 | async with Reader(dataset) as src:
437 | route_params = {
438 | "z": "{TileMatrix}",
439 | "x": "{TileCol}",
440 | "y": "{TileRow}",
441 | }
442 | tiles_url = self.url_for(request, "tiles_endpoint", **route_params)
443 |
444 | qs_key_to_remove = [
445 | "service",
446 | "request",
447 | ]
448 | qs = [
449 | (key, value)
450 | for (key, value) in request.query_params._list
451 | if key.lower() not in qs_key_to_remove
452 | ]
453 | if qs:
454 | tiles_url += f"?{urlencode(qs)}"
455 |
456 | with open(tms_data, "r") as tms:
457 | tile_matrix_set = json.loads(tms.read())
458 |
459 | zooms = list(map(str, range(src.minzoom, src.maxzoom + 1)))
460 | matrices = filter(
461 | lambda matrix: matrix["id"] in zooms,
462 | tile_matrix_set["tileMatrices"],
463 | )
464 |
465 | tileMatrix = []
466 | for matrix in list(matrices):
467 | tm = f"""
468 | {matrix['id']}
469 | {matrix['scaleDenominator']}
470 | {matrix['pointOfOrigin'][0]} {matrix['pointOfOrigin'][1]}
471 | {matrix['tileWidth']}
472 | {matrix['tileHeight']}
473 | {matrix['matrixWidth']}
474 | {matrix['matrixHeight']}
475 | """
476 | tileMatrix.append(tm)
477 |
478 | media_type = MediaType[src.tile_type.name.lower()].value
479 |
480 | return self.templates.TemplateResponse(
481 | "wmts.xml",
482 | {
483 | "request": request,
484 | "tiles_endpoint": tiles_url,
485 | "bounds": src.bounds,
486 | "tileMatrix": tileMatrix,
487 | "title": "PMTiles Archive",
488 | "layer_name": "pmtiles",
489 | "media_type": media_type,
490 | },
491 | media_type="application/xml",
492 | )
493 |
--------------------------------------------------------------------------------
/thatchertiler/main.py:
--------------------------------------------------------------------------------
1 | """ThatcherTiler application."""
2 |
3 | from fastapi import FastAPI
4 | from starlette.middleware.cors import CORSMiddleware
5 |
6 | from thatchertiler.factory import TilerFactory
7 | from thatchertiler.settings import ApiSettings
8 |
9 | settings = ApiSettings()
10 |
11 |
12 | app = FastAPI(
13 | title="ThatcherTiler",
14 | openapi_url="/api",
15 | docs_url="/api.html",
16 | root_path=settings.root_path,
17 | description="""
18 |
19 |
20 |
21 |
ThatcherTiler: expect some features to be dropped.
22 |
23 |
24 |
25 | **Source Code**: https://github.com/developmentseed/thatchertiler
26 |
27 | """,
28 | )
29 |
30 | # Set all CORS enabled origins
31 | if settings.cors_origins:
32 | app.add_middleware(
33 | CORSMiddleware,
34 | allow_origins=settings.cors_origins,
35 | allow_credentials=True,
36 | allow_methods=["GET", "OPTIONS"],
37 | allow_headers=["*"],
38 | )
39 |
40 | tiler = TilerFactory(with_map=True, with_wmts=True)
41 | app.include_router(tiler.router, tags=["PMTiles"])
42 |
43 |
44 | @app.get(
45 | "/healthz",
46 | description="Health Check.",
47 | summary="Health Check.",
48 | operation_id="healthCheck",
49 | tags=["Health Check"],
50 | )
51 | def ping():
52 | """Health check."""
53 | return {"ping": "pong!"}
54 |
--------------------------------------------------------------------------------
/thatchertiler/model.py:
--------------------------------------------------------------------------------
1 | """response models."""
2 |
3 | from typing import Dict, List, Literal, Optional, Tuple
4 |
5 | from pydantic import BaseModel, Field, model_validator
6 |
7 |
8 | class LayerJSON(BaseModel):
9 | """
10 | https://github.com/mapbox/tilejson-spec/tree/master/3.0.0#33-vector_layers
11 | """
12 |
13 | id: str
14 | fields: Dict = Field(default_factory=dict)
15 | description: Optional[str] = None
16 | minzoom: Optional[int] = None
17 | maxzoom: Optional[int] = None
18 |
19 |
20 | class TileJSON(BaseModel):
21 | """TileJSON model."""
22 |
23 | tilejson: str = "3.0.0"
24 | name: Optional[str] = None
25 | description: Optional[str] = None
26 | version: str = "1.0.0"
27 | attribution: Optional[str] = None
28 | template: Optional[str] = None
29 | legend: Optional[str] = None
30 | scheme: Literal["xyz", "tms"] = "xyz"
31 | tiles: List[str] = None
32 | vector_layers: Optional[List[LayerJSON]] = None
33 | grids: Optional[List[str]] = None
34 | data: Optional[List[str]] = None
35 | minzoom: int = Field(0, ge=0, le=30)
36 | maxzoom: int = Field(30, ge=0, le=30)
37 | fillzoom: Optional[int] = None
38 | bounds: List[float] = [180, -85.05112877980659, 180, 85.0511287798066]
39 | center: Optional[Tuple[float, float, int]] = None
40 |
41 | @model_validator(mode="after")
42 | def compute_center(self):
43 | """Compute center if it does not exist."""
44 | bounds = self.bounds
45 | if not self.center:
46 | self.center = (
47 | (bounds[0] + bounds[2]) / 2,
48 | (bounds[1] + bounds[3]) / 2,
49 | self.minzoom,
50 | )
51 | return self
52 |
53 |
54 | class StyleJSON(BaseModel):
55 | """
56 | Simple Mapbox/Maplibre Style JSON model.
57 |
58 | Based on https://docs.mapbox.com/help/glossary/style/
59 |
60 | """
61 |
62 | version: int = 8
63 | name: Optional[str] = None
64 | metadata: Optional[Dict] = None
65 | layers: List[Dict]
66 | sources: Dict
67 | center: List[float] = None
68 | zoom: int
69 |
--------------------------------------------------------------------------------
/thatchertiler/py.typed:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/developmentseed/thatchertiler/afc8b72e933209542277f1d784f905da68353eab/thatchertiler/py.typed
--------------------------------------------------------------------------------
/thatchertiler/routing.py:
--------------------------------------------------------------------------------
1 | """Custom routing classes."""
2 |
3 | from typing import List, Optional
4 |
5 | from fastapi import params
6 | from fastapi.dependencies.utils import get_parameterless_sub_dependant
7 | from starlette.routing import BaseRoute, Match
8 | from typing_extensions import TypedDict
9 |
10 |
11 | class EndpointScope(TypedDict, total=False):
12 | """Define endpoint."""
13 |
14 | # More strict version of Starlette's Scope
15 | # https://github.com/encode/starlette/blob/6af5c515e0a896cbf3f86ee043b88f6c24200bcf/starlette/types.py#L3
16 | path: str
17 | method: str
18 | type: Optional[str] # http or websocket
19 |
20 |
21 | def add_route_dependencies(
22 | routes: List[BaseRoute],
23 | *,
24 | scopes: List[EndpointScope],
25 | dependencies=List[params.Depends],
26 | ):
27 | """Add dependencies to routes.
28 |
29 | Allows a developer to add dependencies to a route after the route has been defined.
30 |
31 | """
32 | for route in routes:
33 | for scope in scopes:
34 | match, _ = route.matches({"type": "http", **scope}) # type: ignore
35 | if match != Match.FULL:
36 | continue
37 |
38 | # Mimicking how APIRoute handles dependencies:
39 | # https://github.com/tiangolo/fastapi/blob/1760da0efa55585c19835d81afa8ca386036c325/fastapi/routing.py#L408-L412
40 | for depends in dependencies[::-1]:
41 | route.dependant.dependencies.insert( # type: ignore
42 | 0,
43 | get_parameterless_sub_dependant(
44 | depends=depends, path=route.path_format # type: ignore
45 | ),
46 | )
47 |
48 | # Register dependencies directly on route so that they aren't ignored if
49 | # the routes are later associated with an app (e.g. app.include_router(router))
50 | # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/applications.py#L337-L360
51 | # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/routing.py#L677-L678
52 | route.dependencies.extend(dependencies) # type: ignore
53 |
--------------------------------------------------------------------------------
/thatchertiler/settings.py:
--------------------------------------------------------------------------------
1 | """API settings."""
2 |
3 | from pydantic import field_validator
4 | from pydantic_settings import BaseSettings
5 |
6 |
7 | class ApiSettings(BaseSettings):
8 | """API settings"""
9 |
10 | name: str = "thatchertiler"
11 | cors_origins: str = "*"
12 | cachecontrol: str = "public, max-age=3600"
13 | root_path: str = ""
14 | debug: bool = False
15 |
16 | model_config = {
17 | "env_prefix": "THATCHERTILER_API_",
18 | "env_file": ".env",
19 | "extra": "ignore",
20 | }
21 |
22 | @field_validator("cors_origins")
23 | def parse_cors_origin(cls, v):
24 | """Parse CORS origins."""
25 | return [origin.strip() for origin in v.split(",")]
26 |
--------------------------------------------------------------------------------
/thatchertiler/templates/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | ThatcherTiler Viewer
7 |
8 |
9 |
10 |
11 |
12 |
27 |
28 |
29 |
30 |
31 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/thatchertiler/templates/wmts.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | "{{ title }}"
4 | OGC WMTS
5 | 1.0.0
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | RESTful
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | RESTful
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | {{ title }}
38 | {{ layer_name }}
39 | {{ title }}
40 |
41 | {{ bounds[0] }} {{ bounds[1] }}
42 | {{ bounds[2] }} {{ bounds[3] }}
43 |
44 |
47 | {{ media_type }}
48 |
49 | WebMercatorQuad
50 |
51 |
52 |
53 |
54 | WebMercatorQuad
55 | http://www.opengis.net/def/crs/EPSG/0/3857
56 | {% for item in tileMatrix %}
57 | {{ item | safe }}
58 | {% endfor %}
59 |
60 |
61 |
62 |
63 |
--------------------------------------------------------------------------------