├── docs ├── docs │ ├── index.md │ ├── release-notes.md │ ├── contributing.md │ └── usage.md └── mkdocs.yml ├── tests ├── __init__.py ├── fixtures │ ├── protomaps(vector)ODbL_firenze.pmtiles │ ├── usgs-mt-whitney-8-15-webp-512.pmtiles │ └── stamen_toner(raster)CC-BY+ODbL_z3.pmtiles ├── test_io.py └── test_reader.py ├── CHANGES.md ├── aiopmtiles ├── __init__.py ├── aiopmtiles.py └── io.py ├── .github ├── codecov.yml └── workflows │ ├── deploy_mkdocs.yml │ └── ci.yml ├── .bumpversion.cfg ├── CONTRIBUTING.md ├── .pre-commit-config.yaml ├── LICENSE ├── .gitignore ├── pyproject.toml └── README.md /docs/docs/index.md: -------------------------------------------------------------------------------- 1 | ../../README.md -------------------------------------------------------------------------------- /docs/docs/release-notes.md: -------------------------------------------------------------------------------- 1 | ../../CHANGES.md -------------------------------------------------------------------------------- /docs/docs/contributing.md: -------------------------------------------------------------------------------- 1 | ../../CONTRIBUTING.md -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """aiopmtiles tests suite.""" 2 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | 2 | ## 0.1.0 (TMB) 3 | 4 | Initial release. 5 | -------------------------------------------------------------------------------- /aiopmtiles/__init__.py: -------------------------------------------------------------------------------- 1 | """pmtiles""" 2 | 3 | __version__ = "0.1.0" 4 | 5 | from .aiopmtiles import Reader # noqa 6 | -------------------------------------------------------------------------------- /tests/fixtures/protomaps(vector)ODbL_firenze.pmtiles: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/aiopmtiles/HEAD/tests/fixtures/protomaps(vector)ODbL_firenze.pmtiles -------------------------------------------------------------------------------- /tests/fixtures/usgs-mt-whitney-8-15-webp-512.pmtiles: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/aiopmtiles/HEAD/tests/fixtures/usgs-mt-whitney-8-15-webp-512.pmtiles -------------------------------------------------------------------------------- /tests/fixtures/stamen_toner(raster)CC-BY+ODbL_z3.pmtiles: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/aiopmtiles/HEAD/tests/fixtures/stamen_toner(raster)CC-BY+ODbL_z3.pmtiles -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | comment: off 2 | 3 | coverage: 4 | status: 5 | project: 6 | default: 7 | target: auto 8 | threshold: 5 9 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.1.0 3 | commit = True 4 | tag = True 5 | tag_name = {new_version} 6 | 7 | [bumpversion:file:aiopmtiles/__init__.py] 8 | search = __version__ = "{current_version}" 9 | replace = __version__ = "{new_version}" 10 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Issues and pull requests are more than welcome. 4 | 5 | **dev install** 6 | 7 | ```bash 8 | $ git clone https://github.com/developmentseed/aiopmtiles.git 9 | $ cd aiopmtiles 10 | $ python -m pip install -e .["test","dev","aws","gcp"] 11 | ``` 12 | 13 | You can then run the tests with the following command: 14 | 15 | ```sh 16 | python -m pytest --cov aiopmtiles --cov-report term-missing 17 | ``` 18 | 19 | **pre-commit** 20 | 21 | This repo is set to use `pre-commit` to run *isort*, *flake8*, *pydocstring*, *black* ("uncompromising Python code formatter") and mypy when committing new code. 22 | 23 | ```bash 24 | $ pre-commit install 25 | ``` 26 | -------------------------------------------------------------------------------- /.github/workflows/deploy_mkdocs.yml: -------------------------------------------------------------------------------- 1 | name: Publish docs via GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | # Only rebuild website when docs have changed 9 | - 'README.md' 10 | - 'docs/**' 11 | - 'mkdocs.yml' 12 | - 'rio_stac/**.py' 13 | - .github/workflows/deploy_mkdocs.yml 14 | 15 | jobs: 16 | build: 17 | name: Deploy docs 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout main 21 | uses: actions/checkout@v5 22 | 23 | - name: Set up Python 3.12 24 | uses: actions/setup-python@v6 25 | with: 26 | python-version: 3.12 27 | 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | python -m pip install -e .["doc"] 32 | 33 | - name: Deploy docs 34 | run: mkdocs gh-deploy -f docs/mkdocs.yml --force 35 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/test_io.py: -------------------------------------------------------------------------------- 1 | """test IO FileSystem.""" 2 | 3 | import os 4 | 5 | import pytest 6 | 7 | from aiopmtiles.io import ( 8 | FileSystem, 9 | GcsFileSystem, 10 | HttpFileSystem, 11 | LocalFileSystem, 12 | S3FileSystem, 13 | ) 14 | 15 | FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures") 16 | VECTOR_PMTILES = os.path.join(FIXTURES_DIR, "protomaps(vector)ODbL_firenze.pmtiles") 17 | 18 | 19 | @pytest.mark.parametrize( 20 | "url,fs", 21 | [ 22 | ("myfile.pmtiles", LocalFileSystem), 23 | ("file:///myfile.pmtiles", LocalFileSystem), 24 | ("s3://bucket/myfile.pmtiles", S3FileSystem), 25 | ("gs://bucket/myfile.pmtiles", GcsFileSystem), 26 | ("http://url.io/myfile.pmtiles", HttpFileSystem), 27 | ("https://url.io/myfile.pmtiles", HttpFileSystem), 28 | ], 29 | ) 30 | def test_create_from_filepath(url, fs): 31 | """Test Filesystem creation from url.""" 32 | assert isinstance(FileSystem.create_from_filepath(url), fs) 33 | 34 | 35 | def test_bad_schema(): 36 | """Should raise ValueError.""" 37 | with pytest.raises(ValueError): 38 | FileSystem.create_from_filepath("something://myfile.pmtiles") 39 | 40 | 41 | @pytest.mark.asyncio 42 | async def test_local_fs(): 43 | """Test LocalFilesSytem.""" 44 | async with LocalFileSystem(VECTOR_PMTILES) as fs: 45 | assert not fs.file.closed 46 | magic_bytes = await fs.get(0, 6) 47 | assert magic_bytes.decode() == "PMTiles" 48 | assert fs.file.closed 49 | -------------------------------------------------------------------------------- /docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | # Project Information 2 | site_name: 'aiopmtiles' 3 | site_description: 'Async Version of Python PMTiles Reader.' 4 | 5 | # Repository 6 | repo_name: 'developmentseed/aiopmtiles' 7 | repo_url: 'http://github.com/developmentseed/aiopmtiles' 8 | edit_uri: 'blob/main/docs/src/' 9 | site_url: 'https://developmentseed.org/aiopmtiles/' 10 | 11 | # Social links 12 | extra: 13 | social: 14 | - icon: 'fontawesome/brands/github' 15 | link: 'https://github.com/developmentseed' 16 | - icon: 'fontawesome/brands/twitter' 17 | link: 'https://twitter.com/developmentseed' 18 | 19 | # Layout 20 | nav: 21 | - Home: 'index.md' 22 | - Usage: 'usage.md' 23 | - Development - Contributing: 'contributing.md' 24 | - Release Notes: 'release-notes.md' 25 | 26 | plugins: 27 | - search 28 | 29 | # Theme 30 | theme: 31 | icon: 32 | logo: 'material/home' 33 | repo: 'fontawesome/brands/github' 34 | name: 'material' 35 | language: 'en' 36 | palette: 37 | primary: 'red' 38 | accent: 'light red' 39 | font: 40 | text: 'Nunito Sans' 41 | code: 'Fira Code' 42 | 43 | # These extensions are chosen to be a superset of Pandoc's Markdown. 44 | # This way, I can write in Pandoc's Markdown and have it be supported here. 45 | # https://pandoc.org/MANUAL.html 46 | markdown_extensions: 47 | - admonition 48 | - attr_list 49 | - codehilite: 50 | guess_lang: false 51 | - def_list 52 | - footnotes 53 | - pymdownx.arithmatex 54 | - pymdownx.betterem 55 | - pymdownx.caret: 56 | insert: false 57 | - pymdownx.details 58 | - pymdownx.emoji 59 | - pymdownx.escapeall: 60 | hardbreak: true 61 | nbsp: true 62 | - pymdownx.magiclink: 63 | hide_protocol: true 64 | repo_url_shortener: true 65 | - pymdownx.smartsymbols 66 | - pymdownx.superfences 67 | - pymdownx.tasklist: 68 | custom_checkbox: true 69 | - pymdownx.tilde 70 | - toc: 71 | permalink: true 72 | -------------------------------------------------------------------------------- /tests/test_reader.py: -------------------------------------------------------------------------------- 1 | """Test Reader.""" 2 | 3 | import os 4 | 5 | import pytest 6 | 7 | from aiopmtiles import Reader 8 | from aiopmtiles.io import LocalFileSystem 9 | 10 | FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures") 11 | VECTOR_PMTILES = os.path.join(FIXTURES_DIR, "protomaps(vector)ODbL_firenze.pmtiles") 12 | RASTER_PMTILES = os.path.join(FIXTURES_DIR, "usgs-mt-whitney-8-15-webp-512.pmtiles") 13 | V2_PMTILES = os.path.join(FIXTURES_DIR, "stamen_toner(raster)CC-BY+ODbL_z3.pmtiles") 14 | 15 | 16 | @pytest.mark.asyncio 17 | async def test_reader_vector(): 18 | """Test Reader with Vector PMTiles.""" 19 | async with Reader(VECTOR_PMTILES) as src: 20 | assert isinstance(src.fs, LocalFileSystem) 21 | assert src._header 22 | assert src._header_offset == 0 23 | assert src._header_length == 127 24 | assert src.bounds 25 | assert src.minzoom == 0 26 | assert src.maxzoom == 14 27 | assert src.center[2] == 0 28 | assert src.is_vector 29 | assert src.tile_compression.name == "GZIP" 30 | assert not src.fs.file.closed 31 | assert src.tile_type.name == "MVT" 32 | 33 | metadata = await src.metadata() 34 | assert "attribution" in metadata 35 | assert "tilestats" in metadata 36 | 37 | assert src.fs.file.closed 38 | 39 | 40 | @pytest.mark.asyncio 41 | async def test_reader_raster(): 42 | """Test Reader with raster PMTiles.""" 43 | async with Reader(RASTER_PMTILES) as src: 44 | assert isinstance(src.fs, LocalFileSystem) 45 | assert src._header 46 | assert src.bounds 47 | assert src.minzoom == 8 48 | assert src.maxzoom == 15 49 | assert src.center[2] == 12 50 | assert not src.is_vector 51 | assert src.tile_compression.name == "NONE" 52 | assert not src.fs.file.closed 53 | assert src.tile_type.name == "WEBP" 54 | 55 | metadata = await src.metadata() 56 | assert "attribution" in metadata 57 | assert "type" in metadata 58 | 59 | assert src.fs.file.closed 60 | 61 | 62 | @pytest.mark.asyncio 63 | async def test_reader_bad_spec(): 64 | """Should raise an error if not spec == 3.""" 65 | with pytest.raises(AssertionError): 66 | async with Reader(V2_PMTILES) as src: 67 | pass 68 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "aiopmtiles" 3 | description = "Async version of protomaps/PMTiles." 4 | readme = "README.md" 5 | requires-python = ">=3.11" 6 | license = {file = "LICENSE"} 7 | authors = [ 8 | {name = "Vincent Sarago", email = "vincent@developmentseed.com"}, 9 | ] 10 | classifiers = [ 11 | "Intended Audience :: Information Technology", 12 | "Intended Audience :: Science/Research", 13 | "License :: OSI Approved :: BSD License", 14 | "Programming Language :: Python :: 3.11", 15 | "Programming Language :: Python :: 3.12", 16 | "Programming Language :: Python :: 3.13", 17 | "Topic :: Scientific/Engineering :: GIS", 18 | ] 19 | dynamic = ["version"] 20 | dependencies = [ 21 | "pmtiles", 22 | "httpx", 23 | "aiofiles", 24 | "aiocache", 25 | ] 26 | 27 | [project.optional-dependencies] 28 | aws = [ 29 | "aioboto3" 30 | ] 31 | gcp = [ 32 | "gcloud-aio-auth", 33 | "gcloud-aio-storage" 34 | ] 35 | test = [ 36 | "pytest", 37 | "pytest-cov", 38 | "pytest-asyncio", 39 | ] 40 | dev = [ 41 | "pre-commit", 42 | ] 43 | doc = [ 44 | "mkdocs", 45 | "mkdocs-material", 46 | "pygments", 47 | "pdocs", 48 | ] 49 | 50 | [project.urls] 51 | Source = "https://github.com/developmentseed/aiopmtiles" 52 | Documentation = "https://developmentseed.org/aiopmtiles/" 53 | 54 | [build-system] 55 | requires = ["flit>=3.2,<4"] 56 | build-backend = "flit_core.buildapi" 57 | 58 | [tool.flit.module] 59 | name = "aiopmtiles" 60 | 61 | [tool.flit.sdist] 62 | exclude = [ 63 | "tests/", 64 | "docs/", 65 | ".github/", 66 | "CHANGES.md", 67 | "CONTRIBUTING.md", 68 | ] 69 | 70 | [tool.coverage.run] 71 | branch = true 72 | parallel = true 73 | 74 | [tool.coverage.report] 75 | exclude_lines = [ 76 | "no cov", 77 | "if __name__ == .__main__.:", 78 | "if TYPE_CHECKING:", 79 | ] 80 | 81 | [tool.isort] 82 | profile = "black" 83 | known_first_party = ["aiopmtiles"] 84 | known_third_party = ["pmtiles"] 85 | default_section = "THIRDPARTY" 86 | 87 | [tool.mypy] 88 | no_strict_optional = true 89 | 90 | [tool.ruff] 91 | select = [ 92 | "D1", # pydocstyle errors 93 | "E", # pycodestyle errors 94 | "W", # pycodestyle warnings 95 | "C", # flake8-comprehensions 96 | "B", # flake8-bugbear 97 | ] 98 | ignore = [ 99 | "E501", # line too long, handled by black 100 | "B008", # do not perform function calls in argument defaults 101 | "B905", # ignore zip() without an explicit strict= parameter, only support with python >3.10 102 | ] 103 | -------------------------------------------------------------------------------- /.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.13' 13 | 14 | jobs: 15 | tests: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | python-version: 20 | - '3.11' 21 | - '3.12' 22 | - '3.13' 23 | 24 | steps: 25 | - uses: actions/checkout@v5 26 | - name: Set up Python ${{ matrix.python-version }} 27 | uses: actions/setup-python@v6 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | 31 | - name: Install aiopmtiles 32 | run: | 33 | python -m pip install --upgrade pip 34 | python -m pip install .["test","aws","gcp"] 35 | 36 | - name: run pre-commit 37 | if: ${{ matrix.python-version == env.LATEST_PY_VERSION }} 38 | run: | 39 | python -m pip install pre-commit 40 | pre-commit run --all-files 41 | 42 | - name: Run test 43 | run: python -m pytest --cov aiopmtiles --cov-report xml --cov-report term-missing 44 | 45 | - name: Upload Results 46 | if: ${{ matrix.python-version == env.LATEST_PY_VERSION }} 47 | uses: codecov/codecov-action@v1 48 | with: 49 | file: ./coverage.xml 50 | flags: unittests 51 | name: ${{ matrix.python-version }} 52 | fail_ci_if_error: false 53 | 54 | publish: 55 | needs: [tests] 56 | runs-on: ubuntu-latest 57 | if: startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release' 58 | steps: 59 | - uses: actions/checkout@v5 60 | - name: Set up Python 61 | uses: actions/setup-python@v6 62 | with: 63 | python-version: ${{ env.LATEST_PY_VERSION }} 64 | 65 | - name: Install dependencies 66 | run: | 67 | python -m pip install --upgrade pip 68 | python -m pip install flit 69 | python -m pip install . 70 | 71 | - name: Set tag version 72 | id: tag 73 | # https://stackoverflow.com/questions/58177786/get-the-current-pushed-tag-in-github-actions 74 | run: echo ::set-output name=tag::${GITHUB_REF#refs/*/} 75 | 76 | - name: Set module version 77 | id: module 78 | # https://stackoverflow.com/questions/58177786/get-the-current-pushed-tag-in-github-actions 79 | run: echo ::set-output name=version::$(python -c 'from importlib.metadata import version; print(version("aiopmtiles"))') 80 | 81 | - name: Build and publish 82 | if: steps.tag.outputs.tag == steps.module.outputs.version 83 | env: 84 | FLIT_USERNAME: ${{ secrets.PYPI_USERNAME }} 85 | FLIT_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 86 | run: flit publish 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aiopmtiles 2 | 3 |
4 | Async Version of Python PMTiles Reader. 5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |