Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- Added `--strict` flag to CLI and `strict_download` parameter to `PaperMap` class to control tile download failure behavior. By default (strict=False), PaperMap now allows graceful degradation when some tiles fail to download, rendering failed tiles as background color and issuing a warning. When strict=True, the previous behavior is maintained where any tile download failure raises an exception.
- Added new `tile_providers` subpackage with provider-based organization for improved maintainability
- Added 100+ new tile providers from various providers including:
- OpenStreetMap regional variants (DE, CH, France, HOT, BZH)
Expand Down
2 changes: 2 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ Options:
--grid Add a coordinate grid overlay to the paper
map.
--grid-size METERS Size of the grid squares (if applicable).
--strict Fail if any tiles cannot be downloaded.
-h, --help Show this message and exit.
```

Expand Down Expand Up @@ -123,6 +124,7 @@ Options:
--grid Add a coordinate grid overlay to the paper
map.
--grid-size METERS Size of the grid squares (if applicable).
--strict Fail if any tiles cannot be downloaded.
-h, --help Show this message and exit.
```

Expand Down
28 changes: 22 additions & 6 deletions src/papermap/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,20 @@ def _create_and_save_map( # noqa: PLR0913
lat: float,
lon: float,
file: Path,
*,
tile_provider_key: str = DEFAULT_TILE_PROVIDER_KEY,
api_key: str | None = None,
paper_size: str = DEFAULT_PAPER_SIZE,
use_landscape: bool = False, # noqa: FBT001, FBT002
use_landscape: bool = False,
margin_top: int = DEFAULT_MARGIN,
margin_right: int = DEFAULT_MARGIN,
margin_bottom: int = DEFAULT_MARGIN,
margin_left: int = DEFAULT_MARGIN,
scale: int = DEFAULT_SCALE,
dpi: int = DEFAULT_DPI,
add_grid: bool = False, # noqa: FBT001, FBT002
add_grid: bool = False,
grid_size: int = DEFAULT_GRID_SIZE,
strict_download: bool = False,
) -> None:
"""Shared map creation logic for all coordinate input methods."""
# initialize PaperMap object
Expand All @@ -56,6 +58,7 @@ def _create_and_save_map( # noqa: PLR0913
dpi=dpi,
add_grid=add_grid,
grid_size=grid_size,
strict_download=strict_download,
)

# render it
Expand Down Expand Up @@ -139,6 +142,13 @@ def common_parameters(func: Callable[..., Any]) -> Callable[..., Any]:
metavar="METERS",
help="Size of the grid squares (if applicable).",
)
@click.option(
"--strict",
"strict_download",
default=False,
is_flag=True,
help="Fail if any tiles cannot be downloaded.",
)
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
return func(*args, **kwargs)
Expand Down Expand Up @@ -168,18 +178,20 @@ def latlon( # noqa: PLR0913
lat: float,
lon: float,
file: Path,
*,
tile_provider_key: str = DEFAULT_TILE_PROVIDER_KEY,
api_key: str | None = None,
paper_size: str = DEFAULT_PAPER_SIZE,
use_landscape: bool = False, # noqa: FBT001, FBT002
use_landscape: bool = False,
margin_top: int = DEFAULT_MARGIN,
margin_right: int = DEFAULT_MARGIN,
margin_bottom: int = DEFAULT_MARGIN,
margin_left: int = DEFAULT_MARGIN,
scale: int = DEFAULT_SCALE,
dpi: int = DEFAULT_DPI,
add_grid: bool = False, # noqa: FBT001, FBT002
add_grid: bool = False,
grid_size: int = DEFAULT_GRID_SIZE,
strict_download: bool = False,
) -> None:
"""Generates a paper map for the given spherical coordinate (i.e. lat, lon) and outputs it to file."""
_create_and_save_map(
Expand All @@ -198,6 +210,7 @@ def latlon( # noqa: PLR0913
dpi=dpi,
add_grid=add_grid,
grid_size=grid_size,
strict_download=strict_download,
)


Expand All @@ -213,18 +226,20 @@ def utm( # noqa: PLR0913
zone: int,
hemisphere: str,
file: Path,
*,
tile_provider_key: str = DEFAULT_TILE_PROVIDER_KEY,
api_key: str | None = None,
paper_size: str = DEFAULT_PAPER_SIZE,
use_landscape: bool = False, # noqa: FBT001, FBT002
use_landscape: bool = False,
margin_top: int = DEFAULT_MARGIN,
margin_right: int = DEFAULT_MARGIN,
margin_bottom: int = DEFAULT_MARGIN,
margin_left: int = DEFAULT_MARGIN,
scale: int = DEFAULT_SCALE,
dpi: int = DEFAULT_DPI,
add_grid: bool = False, # noqa: FBT001, FBT002
add_grid: bool = False,
grid_size: int = DEFAULT_GRID_SIZE,
strict_download: bool = False,
) -> None:
"""Generates a paper map for the given UTM coordinate and outputs it to file."""
# convert UTM coordinate to spherical (i.e. lat, lon)
Expand All @@ -245,4 +260,5 @@ def utm( # noqa: PLR0913
dpi=dpi,
add_grid=add_grid,
grid_size=grid_size,
strict_download=strict_download,
)
16 changes: 13 additions & 3 deletions src/papermap/papermap.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import time
import warnings
from concurrent.futures import ThreadPoolExecutor
from decimal import Decimal
from importlib import metadata
Expand Down Expand Up @@ -91,6 +92,7 @@ class PaperMap:
background_color: Background color of the paper map. Defaults to `#fff`.
add_grid: Add a coordinate grid overlay to the paper map. Defaults to `False`.
grid_size: Size of the grid squares (if applicable, in meters). Defaults to `1000`.
strict_download: Fail if any tiles cannot be downloaded. Defaults to `False`.

Raises:
ValueError: If the tile provider is invalid.
Expand All @@ -117,6 +119,7 @@ def __init__( # noqa: PLR0913, PLR0915
background_color: str = DEFAULT_BACKGROUND_COLOR,
add_grid: bool = False,
grid_size: int = DEFAULT_GRID_SIZE,
strict_download: bool = False,
) -> None:
# validate coordinates
if not -90 <= lat <= 90: # noqa: PLR2004
Expand All @@ -138,6 +141,7 @@ def __init__( # noqa: PLR0913, PLR0915
self.background_color = background_color
self.add_grid = add_grid
self.grid_size = grid_size
self.strict_download = strict_download

# get the tile provider
if tile_provider_key in KEY_TO_TILE_PROVIDER:
Expand Down Expand Up @@ -363,7 +367,10 @@ def render_attribution_and_scale(self) -> None:
self.pdf.cell(w=0, text=text, align="R", fill=True)

def download_tiles(
self, num_retries: int = 3, sleep_between_retries: int | None = None
self,
num_retries: int = 3,
sleep_between_retries: int | None = None,
strict: bool = False, # noqa: FBT001, FBT002
) -> None:
# download the tile images
with (
Expand Down Expand Up @@ -395,7 +402,10 @@ def download_tiles(
# break if max number of retries exceeded
if num_retry >= num_retries:
msg = f"Could not download {len(tiles)}/{len(self.tiles)} tiles after {num_retries} retries."
raise RuntimeError(msg)
if strict:
raise RuntimeError(msg)
warnings.warn(msg, stacklevel=2)
break

responses = executor.map(
client.get,
Expand All @@ -413,7 +423,7 @@ def download_tiles(

def render_base_layer(self) -> None:
# download all the required tiles
self.download_tiles()
self.download_tiles(strict=self.strict_download)

# initialize scaled map image
self.map_image_scaled = Image.new(
Expand Down
4 changes: 2 additions & 2 deletions tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ def test_tile_download_retry_on_failure(
assert tile.success

def test_tile_download_max_retries_exceeded(self, httpx_mock: HTTPXMock) -> None:
"""Test that an error is raised when max retries are exceeded."""
"""Test that an error is raised when max retries are exceeded with strict=True."""
# Disable unused response assertion
httpx_mock._options.assert_all_responses_were_requested = False # noqa: SLF001

Expand All @@ -268,7 +268,7 @@ def test_tile_download_max_retries_exceeded(self, httpx_mock: HTTPXMock) -> None
httpx_mock.add_response(status_code=500)

with pytest.raises(RuntimeError, match="Could not download"):
pm.download_tiles(num_retries=2)
pm.download_tiles(num_retries=2, strict=True)


class TestRenderMethods:
Expand Down
92 changes: 88 additions & 4 deletions tests/test_papermap.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ def test_init_with_grid(self) -> None:
assert pm.add_grid
assert pm.grid_size == 500

def test_init_with_strict_download(self) -> None:
pm = PaperMap(lat=40.7128, lon=-74.0060, strict_download=True)
assert pm.strict_download

def test_init_stores_api_key(self) -> None:
pm = PaperMap(
lat=40.7128,
Expand Down Expand Up @@ -562,7 +566,7 @@ def test_download_tiles_raises_after_max_retries(
httpx_mock.add_response(status_code=500)

with pytest.raises(RuntimeError, match="Could not download"):
pm.download_tiles(num_retries=num_retries)
pm.download_tiles(num_retries=num_retries, strict=True)

def test_download_tiles_retry_with_sleep(
self,
Expand Down Expand Up @@ -597,6 +601,42 @@ def mock_sleep(duration: float) -> None:
assert len(sleep_calls) >= 1
assert all(duration == 1 for duration in sleep_calls)

def test_download_tiles_graceful_failure(self, httpx_mock: HTTPXMock) -> None:
"""Test that partial tile failure with strict=False logs warning but completes."""
# Disable unused response assertion
httpx_mock._options.assert_all_responses_were_requested = False # noqa: SLF001

pm = PaperMap(lat=40.7128, lon=-74.0060)

num_tiles = len(pm.tiles)
num_retries = 2

# All attempts fail
for _ in range(num_tiles * (num_retries + 1)):
httpx_mock.add_response(status_code=500)

# Should not raise, but should warn
with pytest.warns(UserWarning, match="Could not download"):
pm.download_tiles(num_retries=num_retries, strict=False)

def test_download_tiles_strict_failure(self, httpx_mock: HTTPXMock) -> None:
"""Test that partial tile failure with strict=True raises RuntimeError."""
# Disable unused response assertion
httpx_mock._options.assert_all_responses_were_requested = False # noqa: SLF001

pm = PaperMap(lat=40.7128, lon=-74.0060)

num_tiles = len(pm.tiles)
num_retries = 2

# All attempts fail
for _ in range(num_tiles * (num_retries + 1)):
httpx_mock.add_response(status_code=500)

# Should raise RuntimeError when strict=True
with pytest.raises(RuntimeError, match="Could not download"):
pm.download_tiles(num_retries=num_retries, strict=True)


class TestPaperMapHttpErrors:
"""Tests for HTTP error handling in tile downloads."""
Expand All @@ -615,7 +655,7 @@ def test_download_tiles_404_error(self, httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(status_code=404)

with pytest.raises(RuntimeError, match="Could not download"):
pm.download_tiles(num_retries=1)
pm.download_tiles(num_retries=1, strict=True)

def test_download_tiles_500_error(self, httpx_mock: HTTPXMock) -> None:
"""Test handling of HTTP 500 Provider Error."""
Expand All @@ -631,7 +671,7 @@ def test_download_tiles_500_error(self, httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(status_code=500)

with pytest.raises(RuntimeError, match="Could not download"):
pm.download_tiles(num_retries=1)
pm.download_tiles(num_retries=1, strict=True)

def test_download_tiles_403_forbidden(self, httpx_mock: HTTPXMock) -> None:
"""Test handling of HTTP 403 Forbidden errors (invalid API key)."""
Expand All @@ -647,7 +687,7 @@ def test_download_tiles_403_forbidden(self, httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(status_code=403)

with pytest.raises(RuntimeError, match="Could not download"):
pm.download_tiles(num_retries=1)
pm.download_tiles(num_retries=1, strict=True)

def test_download_tiles_empty_response(self, httpx_mock: HTTPXMock) -> None:
"""Test handling of empty response body."""
Expand Down Expand Up @@ -728,6 +768,50 @@ def test_render_base_layer_creates_map_image(
assert hasattr(pm, "map_image")
assert pm.map_image.size == (pm.image_width_px, pm.image_height_px)

def test_render_base_layer_with_strict_download_failure(
self,
httpx_mock: HTTPXMock,
) -> None:
"""Test that render_base_layer respects strict_download flag."""
# Disable unused response assertion
httpx_mock._options.assert_all_responses_were_requested = False # noqa: SLF001

pm = PaperMap(lat=40.7128, lon=-74.0060, strict_download=True)

num_tiles = len(pm.tiles)

# All attempts fail
for _ in range(num_tiles * 4):
httpx_mock.add_response(status_code=500)

# Should raise RuntimeError when strict_download=True
with pytest.raises(RuntimeError, match="Could not download"):
pm.render_base_layer()

def test_render_base_layer_with_graceful_download_failure(
self,
httpx_mock: HTTPXMock,
) -> None:
"""Test that render_base_layer allows graceful degradation with strict_download=False."""
# Disable unused response assertion
httpx_mock._options.assert_all_responses_were_requested = False # noqa: SLF001

pm = PaperMap(lat=40.7128, lon=-74.0060, strict_download=False)

num_tiles = len(pm.tiles)

# All attempts fail
for _ in range(num_tiles * 4):
httpx_mock.add_response(status_code=500)

# Should warn but not raise
with pytest.warns(UserWarning, match="Could not download"):
pm.render_base_layer()

# Map image should still be created (with background color for failed tiles)
assert hasattr(pm, "map_image")
assert pm.map_image.size == (pm.image_width_px, pm.image_height_px)


class TestPaperMapSave:
"""Tests for save method."""
Expand Down