├── .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 | ![](https://user-images.githubusercontent.com/10407788/231717328-6d0608fa-145b-480e-ba48-629b0e4e3e97.png) 44 | 45 | 46 | `http://127.0.0.1:8080/map?url=https://protomaps.github.io/PMTiles/protomaps(vector)ODbL_firenze.pmtiles` 47 | 48 | ![](https://user-images.githubusercontent.com/10407788/231720737-87cf14a9-35b3-4451-a183-37b80dc16e93.png) 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 | --------------------------------------------------------------------------------