Skip to content

OGC Module — WMS and WMTS Basemaps#

The cleopatra.basemap.ogc module lets an OGC WMS or WMTS service be drawn by the same helper that draws XYZ tiles. WMTSProvider and WMSProvider are small frozen dataclasses that satisfy the one-method contract add_tiles already calls — build_url(x=, y=, z=) returning an http(s) URL — so they are passed straight in as the source. Nothing about the fetch, the mosaic or the composite changes.

Importing this module is free; the cleopatra[tiles] extra is needed only to render (see installation).

How each kind maps onto a tile#

Request Mapping
WMTS GetTile The (TileMatrix, TileRow, TileCol) triple is (z, y, x) — an identity mapping on the GoogleMapsCompatible grid.
WMS GetMap No tile index exists, so each tile is converted to its Web Mercator bounds and requested as its own image: one GetMap per tile.

Usage#

import matplotlib
matplotlib.use("Agg")  # any backend; Agg shown for headless rendering
import matplotlib.pyplot as plt

from cleopatra.basemap.ogc import WMSProvider, WMTSProvider
from cleopatra.basemap.tiles import add_tiles

fig, ax = plt.subplots()
ax.plot([1_000_000.0, 1_200_000.0], [6_000_000.0, 6_200_000.0])

# a WMTS service, KVP endpoint
add_tiles(ax, WMTSProvider(
    url="https://example.org/wmts",
    layer="TrueColor",
    tile_matrix_set="GoogleMapsCompatible",
    image_format="image/jpeg",
    attribution="Imagery provider",
), crs=3857)

# a WMS service: one GetMap per tile
add_tiles(ax, WMSProvider(
    url="https://example.org/wms",
    layers="ortho",
    attribution="Ortho provider",
), crs=3857)

A RESTful WMTS template works too — if the url carries any of the placeholders this module owns ({TileMatrix}, {TileRow}, {TileCol}, {TileMatrixSet}, {Layer}, {Style}, in any casing) it is substituted rather than turned into a query:

WMTSProvider(
    url="https://example.org/wmts/{Layer}/{TileMatrix}/{TileRow}/{TileCol}.png",
    layer="TrueColor",
)

Substituted values are percent-encoded, the pass is single so a value that happens to look like a placeholder is left as data, and a placeholder this module does not own is passed through untouched.

What is refused, and when#

Both providers validate at construction rather than per tile mid-render, so a mistake names the field it came from instead of arriving much later as an unreadable tile:

  • a url that is not http(s), or that contains any whitespace;
  • an empty layer / layers / image_format / version (and styles / attribution must be strings, though empty is fine);
  • a WMS version outside 1.1.0 / 1.1.1 / 1.3.0, or a WMTS version other than 1.0.0;
  • a transparent that is not a real bool, or a tile_size that is not a positive int;
  • a RESTful template missing {TileMatrix} / {TileRow} / {TileCol} — without them every tile resolves to the same URL, so the mosaic would silently repeat one image;
  • a template whose placeholders are none of the ones above — an XYZ {z}/{x}/{y} template belongs in add_tiles(source=...) as an xyzservices provider, not here;
  • extra_params that sets a parameter identifying the tile (BBOX, WIDTH, HEIGHT, REQUEST, SERVICE, TILEMATRIX, TILEROW, TILECOL), or that names one parameter twice in different casings.

Both providers are frozen, hashable, picklable and deep-copyable, so one can key a dict or an lru_cache; a copy made with dataclasses.replace re-runs every check above.

Credentials#

A keyed service is just a query parameter, so extra_params covers it. It is merged last, so it can also override a parameter the provider would otherwise generate — matched case-insensitively, as OGC parameter names are, so {"format": ...} replaces the generated FORMAT rather than joining it:

WMSProvider(url="https://example.org/wms", layers="ortho", extra_params={"token": "..."})

Two limits are worth knowing. A parameter baked into the endpoint's own query (.../wms?FORMAT=…) is kept verbatim and is not overridden this way — put it in extra_params instead of the url if you need to control it. And the parameters that identify the tile cannot be overridden at all (see above).

Credentials are kept out of the two places this package would otherwise publish them: the provider's repr() masks every extra_params value, and a failed tile fetch logs a URL whose credential-shaped query values are masked while the OGC parameter names that say which tile failed are kept. A key embedded in the url path cannot be told from an ordinary path segment and is not masked.

Only the GoogleMapsCompatible grid

The surrounding tile geometry implements one tile-matrix set: Web Mercator, 2**z columns, top-left origin, square tiles. A WMTS published on any other matrix set (NASA GIBS' EPSG4326_250m, a national grid such as EPSG:28992) will return tiles that do not line up. WMS is unaffected — it is matrix-set-free by construction, since every request names its own BBOX.

WMS efficiency

A WMS answers any BBOX, so covering the viewport in one request is cheaper than a mosaic of many. add_tiles(..., min_tiles_across=1) lowers the tile count towards a single GetMap without a separate code path.

world_texture is XYZ-only

cleopatra.basemap.tiles.world_texture keys its disk cache on provider.get("name", ...), so it needs a Mapping-like provider and raises AttributeError: 'WMTSProvider' object has no attribute 'get' on these dataclasses. Use add_tiles for an OGC service, and an xyzservices provider when you want a cached world texture.

RESTful templates fix their own format and version

On the RESTful WMTS branch the service encodes the format and version in the template path, so image_format and version are validated but never sent. They apply to the KVP branch only.

Image formats and service exceptions

Only PNG, JPEG, GIF and WebP are recognised, so image/tiff and image/svg+xml are not supported. A service that answers with an XML ServiceExceptionReport — the usual reply to a bad layer name — is treated as an unreadable tile and surfaces as a ConnectionError after the retries, which reads as a network failure rather than the service error it is.

Module Documentation#

cleopatra.basemap.ogc #

OGC WMS and WMTS services addressed as tile providers.

cleopatra.basemap.tiles talks to a provider through exactly one method -- build_url(x=, y=, z=) returning an http(s) URL (tiles.fetch_single_tile). Everything downstream of that call is service-agnostic: the fetch returns opaque bytes, the mosaic is stitched from dict[Tile, bytes], and the composite is drawn with ax.imshow. So a service that is not an XYZ template needs no new pipeline -- only a different way to turn a tile into a URL.

That is all this module is. WMTSProvider and WMSProvider are frozen dataclasses satisfying that one-method contract, so they reach the renderer through the same public entry point an XYZ provider does:

from cleopatra.basemap.ogc import WMSProvider
from cleopatra.basemap.tiles import add_tiles

add_tiles(ax, WMSProvider(url="https://example.org/wms", layers="ortho"))

Both are checked at construction rather than mid-render, and both are hashable, so a provider can key a dict or a cache. extra_params is copied behind a read-only view, so nothing a caller edits afterwards can change the URLs an already-built provider produces.

The two service kinds reach the same place differently:

  • WMTS is a tiling scheme. A GetTile request is a (TileMatrix, TileRow, TileCol) triple, which is (z, y, x). On the GoogleMapsCompatible matrix set -- EPSG:3857, 2**z columns, top-left origin, square tiles -- that is exactly the grid tiles._lonlat_to_tile_xy already computes, so the mapping is the identity.
  • WMS is a single-image request. A GetMap takes a BBOX plus WIDTH/HEIGHT, not a tile index. It fits by asking for one GetMap per tile: tiles._tile_xy_bounds already returns precisely the EPSG:3857 bounds a BBOX needs, and the size is fixed at the tile size.

Only the GoogleMapsCompatible tile-matrix set is supported, because that is the single grid the surrounding module implements. A WMTS published on any other matrix set (NASA GIBS' EPSG4326_250m, a national grid such as EPSG:28992) will return tiles that do not line up; see WMTSProvider.tile_matrix_set.

Two limits are worth knowing before you reach them. tiles.world_texture keys its disk cache on provider.get("name", ...), so it needs a Mapping-like provider and raises AttributeError on these dataclasses -- use add_tiles, or an XYZ provider for a world texture. And on the RESTful WMTS branch the service fixes the format and version in its own template, so image_format and version are validated but never sent; they apply to the KVP branch only.

Adding these providers did change one thing next door. Because extra_params is documented here as the place to put an API key, tiles.fetch_single_tile now logs a redacted URL when an attempt fails: tiles._redact_url keeps the values of the OGC parameters these providers generate -- BBOX, TILEROW and the rest, which is what tells one failed tile from another -- and masks every other value, so a credential a caller added no longer outlives the session in a debug log. The request that goes on the wire is untouched.

Importing this module pulls in nothing from the [tiles] extra -- neither xyzservices nor pyproj is touched, and the tile-grid helpers it does use are plain arithmetic. The extra is required only once you actually render, and add_tiles raises the install hint itself if it is missing.

WMSProvider dataclass #

An OGC WMS service addressed as a tile provider: one GetMap per tile.

Satisfies the build_url(x=, y=, z=) contract cleopatra.basemap.tiles.fetch_single_tile calls, so it can be handed straight to add_tiles as the source.

A WMS has no tile index; it answers a BBOX plus a pixel size. Each tile is therefore converted to its Web Mercator bounds with tiles._tile_xy_bounds and requested as its own image, and the mosaic is stitched as usual. Because the request is always pinned to EPSG:3857 -- the CRS of the tile grid -- the WMS 1.3.0 axis-order trap does not arise: 1.3.0 orders BBOX by the CRS's own axis order, which for EPSG:3857 is easting then northing, the same order 1.1.1 always used. (It is EPSG:4326 under 1.3.0 that flips to latitude-first, and that CRS is never requested here.)

A WMS covering a whole viewport in one request is more efficient than a mosaic of many. add_tiles(..., min_tiles_across=1) lowers the tile count towards one GetMap without needing a second code path.

The service description is checked once, at construction -- including on a dataclasses.replace copy, which is the supported way to vary a frozen provider. The instance is then immutable and hashable, so it can key a dict or a cache; extra_params is stored as a read-only copy, so a caller's later edit to the dict they passed cannot rewrite the URLs.

Parameters:

Name Type Description Default
url str

The GetMap endpoint. An existing query string is preserved. Whitespace anywhere in the value is refused rather than repaired, because the value is sent verbatim; an escaped %20 is not whitespace and is accepted.

required
layers str

The comma-separated Layers value to request.

required
styles str

The comma-separated Styles value. Empty means the service's default style, which is what most callers want -- so this is the one identifier that is allowed to be empty, and it is still sent. It must still be a string: None used to reach the service as the four characters None, and came back as a service exception disguised as an unreadable tile.

''
version str

The WMS version. "1.3.0" sends CRS=; "1.1.1" and "1.1.0" send SRS=.

'1.3.0'
image_format str

The Format to request. tiles._looks_like_image accepts PNG, JPEG, GIF and WebP, so a TIFF or SVG format will be rejected as an unreadable tile.

'image/png'
transparent bool

Whether to request TRANSPARENT=TRUE, so an overlay layer composites over what is already on the axes. It must be an actual bool: any truthy value would otherwise send TRANSPARENT=TRUE, so a string like "no" would mean its own opposite.

True
tile_size int

The WIDTH/HEIGHT in pixels for each GetMap. Keep this at 256 unless the service refuses it -- tiles.stitch_tiles infers the mosaic's cell size from the first decoded image, so mixing sizes within one render would mis-stitch.

256
attribution str

Credit line, which may be empty but must be a string. add_tiles(attribution=True) reads this attribute and draws it on the axes.

''
extra_params Mapping[str, str]

Extra query parameters, merged last so they can also override a generated one -- matched case-insensitively, as OGC parameter names are, so {"format": ...} replaces the generated FORMAT rather than joining it. A parameter already in the url's own query is kept verbatim and is not overridden this way. This is where an API key or token goes; a failed fetch logs the URL with every value cleopatra did not itself generate replaced by ..., so the key does not reach the debug log. Keys and values are coerced to str and stored read-only. Three things are refused rather than merged: a parameter that addresses the tile (_PROTECTED_PARAMS), two keys differing only in case, and two keys that become one name once coerced to str.

dict()

Raises:

Type Description
ValueError

If url is not a non-empty http(s) string or contains whitespace anywhere; if layers, image_format or version is not a non-empty string; if version is not one of 1.1.0, 1.1.1 or 1.3.0; if styles or attribution is not a string (empty is allowed); if transparent is not a bool; if tile_size is not a positive int (bool is rejected too); if extra_params names a tile-addressing parameter such as BBOX or WIDTH, however cased; if two of its keys differ only by case; or if two of its keys coerce to the same name.

TypeError

If extra_params is not a mapping.

Examples:

  • One tile becomes one GetMap, with the tile's Web Mercator bounds as BBOX in left,bottom,right,top metres:
    >>> from urllib.parse import parse_qs, urlsplit
    >>> from cleopatra.basemap.ogc import WMSProvider
    >>> provider = WMSProvider(url="https://example.org/wms", layers="ortho")
    >>> query = parse_qs(urlsplit(provider.build_url(x=4, y=2, z=3)).query)
    >>> [round(float(v)) for v in query["BBOX"][0].split(",")]
    [0, 5009377, 5009377, 10018754]
    >>> query["WIDTH"], query["HEIGHT"], query["REQUEST"]
    (['256'], ['256'], ['GetMap'])
    
  • 1.3.0 names the CRS CRS and 1.1.1 names it SRS, but the value is EPSG:3857 either way:
    >>> from urllib.parse import parse_qs, urlsplit
    >>> from cleopatra.basemap.ogc import WMSProvider
    >>> latest = WMSProvider(url="https://example.org/wms", layers="ortho")
    >>> latest.version, latest.crs_parameter
    ('1.3.0', 'CRS')
    >>> parse_qs(urlsplit(latest.build_url(x=0, y=0, z=0)).query)["CRS"]
    ['EPSG:3857']
    >>> older = WMSProvider(
    ...     url="https://example.org/wms", layers="ortho", version="1.1.1"
    ... )
    >>> parse_qs(urlsplit(older.build_url(x=0, y=0, z=0)).query)["SRS"]
    ['EPSG:3857']
    
  • An unsupported version is refused at construction:
    >>> from cleopatra.basemap.ogc import WMSProvider
    >>> WMSProvider(url="https://example.org/wms", layers="ortho", version="2.0.0")
    Traceback (most recent call last):
        ...
    ValueError: version must be one of '1.1.0', '1.1.1', '1.3.0', got '2.0.0'.
    
  • A near-miss on a flag is refused rather than read as truthy:
    >>> from cleopatra.basemap.ogc import WMSProvider
    >>> try:
    ...     WMSProvider(
    ...         url="https://example.org/wms", layers="ortho", transparent="no"
    ...     )
    ... except ValueError as error:
    ...     print(str(error).split(".")[0])
    transparent must be a bool, got 'no'
    
  • styles and attribution may be empty, but not None:
    >>> from cleopatra.basemap.ogc import WMSProvider
    >>> blank = WMSProvider(
    ...     url="https://example.org/wms", layers="ortho", styles="", attribution=""
    ... )
    >>> blank.styles, blank.attribution
    ('', '')
    >>> try:
    ...     WMSProvider(url="https://example.org/wms", layers="ortho", styles=None)
    ... except ValueError as error:
    ...     print(error)
    styles must be a string (empty is allowed), got None.
    
  • extra_params may retune the request but not re-address it, so the mosaic cannot be assembled from tiles it did not ask for:
    >>> from urllib.parse import parse_qs, urlsplit
    >>> from cleopatra.basemap.ogc import WMSProvider
    >>> tuned = WMSProvider(
    ...     url="https://example.org/wms",
    ...     layers="ortho",
    ...     extra_params={"format": "image/gif", "token": "abc"},
    ... )
    >>> query = parse_qs(urlsplit(tuned.build_url(x=0, y=0, z=0)).query)
    >>> query["format"], query["token"]
    (['image/gif'], ['abc'])
    >>> try:
    ...     WMSProvider(
    ...         url="https://example.org/wms",
    ...         layers="ortho",
    ...         extra_params={"bbox": "0,0,1,1"},
    ...     )
    ... except ValueError as error:
    ...     print(str(error).split(":")[0])
    extra_params may not set 'bbox'
    
See Also

WMTSProvider: The same idea for a WMTS GetTile service. cleopatra.basemap.tiles.add_tiles: The renderer both feed.

Source code in src/cleopatra/basemap/ogc.py
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
@dataclass(frozen=True)
class WMSProvider:
    """An OGC WMS service addressed as a tile provider: one `GetMap` per tile.

    Satisfies the `build_url(x=, y=, z=)` contract
    `cleopatra.basemap.tiles.fetch_single_tile` calls, so it can be handed
    straight to `add_tiles` as the `source`.

    A WMS has no tile index; it answers a `BBOX` plus a pixel size. Each tile is
    therefore converted to its Web Mercator bounds with `tiles._tile_xy_bounds`
    and requested as its own image, and the mosaic is stitched as usual. Because
    the request is always pinned to EPSG:3857 -- the CRS of the tile grid -- the
    WMS 1.3.0 axis-order trap does not arise: 1.3.0 orders `BBOX` by the CRS's
    own axis order, which for EPSG:3857 is easting then northing, the same order
    1.1.1 always used. (It is EPSG:4326 under 1.3.0 that flips to
    latitude-first, and that CRS is never requested here.)

    A WMS covering a whole viewport in one request is more efficient than a
    mosaic of many. `add_tiles(..., min_tiles_across=1)` lowers the tile count
    towards one `GetMap` without needing a second code path.

    The service description is checked once, at construction -- including on a
    `dataclasses.replace` copy, which is the supported way to vary a frozen
    provider. The instance is then immutable and hashable, so it can key a dict
    or a cache; `extra_params` is stored as a read-only copy, so a caller's
    later edit to the dict they passed cannot rewrite the URLs.

    Args:
        url: The `GetMap` endpoint. An existing query string is preserved.
            Whitespace anywhere in the value is refused rather than repaired,
            because the value is sent verbatim; an escaped `%20` is not
            whitespace and is accepted.
        layers: The comma-separated `Layers` value to request.
        styles: The comma-separated `Styles` value. Empty means the service's
            default style, which is what most callers want -- so this is the
            one identifier that is allowed to be empty, and it is still sent.
            It must still be a string: `None` used to reach the service as the
            four characters `None`, and came back as a service exception
            disguised as an unreadable tile.
        version: The WMS version. `"1.3.0"` sends `CRS=`; `"1.1.1"` and `"1.1.0"`
            send `SRS=`.
        image_format: The `Format` to request. `tiles._looks_like_image`
            accepts PNG, JPEG, GIF and WebP, so a TIFF or SVG format will be
            rejected as an unreadable tile.
        transparent: Whether to request `TRANSPARENT=TRUE`, so an overlay layer
            composites over what is already on the axes. It must be an actual
            `bool`: any truthy value would otherwise send `TRANSPARENT=TRUE`,
            so a string like `"no"` would mean its own opposite.
        tile_size: The `WIDTH`/`HEIGHT` in pixels for each `GetMap`. Keep this
            at 256 unless the service refuses it -- `tiles.stitch_tiles` infers
            the mosaic's cell size from the first decoded image, so mixing
            sizes within one render would mis-stitch.
        attribution: Credit line, which may be empty but must be a string.
            `add_tiles(attribution=True)` reads this attribute and draws it on
            the axes.
        extra_params: Extra query parameters, merged last so they can also
            override a generated one -- matched case-insensitively, as OGC
            parameter names are, so `{"format": ...}` replaces the
            generated `FORMAT` rather than joining it. A parameter already
            in the `url`'s own query is kept verbatim and is *not*
            overridden this way. This is where an API key or token goes; a
            failed fetch logs the URL with every value cleopatra did not itself
            generate replaced by `...`, so the key does not reach the debug
            log. Keys and values are coerced to `str` and stored read-only.
            Three things are refused rather than merged: a parameter that
            addresses the tile (`_PROTECTED_PARAMS`), two keys differing only
            in case, and two keys that become one name once coerced to `str`.

    Raises:
        ValueError: If `url` is not a non-empty http(s) string or contains
            whitespace anywhere; if `layers`, `image_format` or `version` is
            not a non-empty string; if `version` is not one of `1.1.0`, `1.1.1`
            or `1.3.0`; if `styles` or `attribution` is not a string (empty is
            allowed); if `transparent` is not a `bool`; if `tile_size` is not a
            positive `int` (`bool` is rejected too); if `extra_params` names a
            tile-addressing parameter such as `BBOX` or `WIDTH`, however cased;
            if two of its keys differ only by case; or if two of its keys
            coerce to the same name.
        TypeError: If `extra_params` is not a mapping.

    Examples:
        - One tile becomes one `GetMap`, with the tile's Web Mercator bounds
          as `BBOX` in `left,bottom,right,top` metres:
            ```python
            >>> from urllib.parse import parse_qs, urlsplit
            >>> from cleopatra.basemap.ogc import WMSProvider
            >>> provider = WMSProvider(url="https://example.org/wms", layers="ortho")
            >>> query = parse_qs(urlsplit(provider.build_url(x=4, y=2, z=3)).query)
            >>> [round(float(v)) for v in query["BBOX"][0].split(",")]
            [0, 5009377, 5009377, 10018754]
            >>> query["WIDTH"], query["HEIGHT"], query["REQUEST"]
            (['256'], ['256'], ['GetMap'])

            ```
        - 1.3.0 names the CRS `CRS` and 1.1.1 names it `SRS`, but the value is
          `EPSG:3857` either way:
            ```python
            >>> from urllib.parse import parse_qs, urlsplit
            >>> from cleopatra.basemap.ogc import WMSProvider
            >>> latest = WMSProvider(url="https://example.org/wms", layers="ortho")
            >>> latest.version, latest.crs_parameter
            ('1.3.0', 'CRS')
            >>> parse_qs(urlsplit(latest.build_url(x=0, y=0, z=0)).query)["CRS"]
            ['EPSG:3857']
            >>> older = WMSProvider(
            ...     url="https://example.org/wms", layers="ortho", version="1.1.1"
            ... )
            >>> parse_qs(urlsplit(older.build_url(x=0, y=0, z=0)).query)["SRS"]
            ['EPSG:3857']

            ```
        - An unsupported version is refused at construction:
            ```python
            >>> from cleopatra.basemap.ogc import WMSProvider
            >>> WMSProvider(url="https://example.org/wms", layers="ortho", version="2.0.0")
            Traceback (most recent call last):
                ...
            ValueError: version must be one of '1.1.0', '1.1.1', '1.3.0', got '2.0.0'.

            ```
        - A near-miss on a flag is refused rather than read as truthy:
            ```python
            >>> from cleopatra.basemap.ogc import WMSProvider
            >>> try:
            ...     WMSProvider(
            ...         url="https://example.org/wms", layers="ortho", transparent="no"
            ...     )
            ... except ValueError as error:
            ...     print(str(error).split(".")[0])
            transparent must be a bool, got 'no'

            ```
        - `styles` and `attribution` may be empty, but not `None`:
            ```python
            >>> from cleopatra.basemap.ogc import WMSProvider
            >>> blank = WMSProvider(
            ...     url="https://example.org/wms", layers="ortho", styles="", attribution=""
            ... )
            >>> blank.styles, blank.attribution
            ('', '')
            >>> try:
            ...     WMSProvider(url="https://example.org/wms", layers="ortho", styles=None)
            ... except ValueError as error:
            ...     print(error)
            styles must be a string (empty is allowed), got None.

            ```
        - `extra_params` may retune the request but not re-address it, so the
          mosaic cannot be assembled from tiles it did not ask for:
            ```python
            >>> from urllib.parse import parse_qs, urlsplit
            >>> from cleopatra.basemap.ogc import WMSProvider
            >>> tuned = WMSProvider(
            ...     url="https://example.org/wms",
            ...     layers="ortho",
            ...     extra_params={"format": "image/gif", "token": "abc"},
            ... )
            >>> query = parse_qs(urlsplit(tuned.build_url(x=0, y=0, z=0)).query)
            >>> query["format"], query["token"]
            (['image/gif'], ['abc'])
            >>> try:
            ...     WMSProvider(
            ...         url="https://example.org/wms",
            ...         layers="ortho",
            ...         extra_params={"bbox": "0,0,1,1"},
            ...     )
            ... except ValueError as error:
            ...     print(str(error).split(":")[0])
            extra_params may not set 'bbox'

            ```

    See Also:
        WMTSProvider: The same idea for a WMTS `GetTile` service.
        cleopatra.basemap.tiles.add_tiles: The renderer both feed.
    """

    url: str
    layers: str
    styles: str = ""
    version: str = "1.3.0"
    image_format: str = "image/png"
    transparent: bool = True
    tile_size: int = 256
    attribution: str = ""
    extra_params: Mapping[str, str] = field(default_factory=dict)

    def __post_init__(self) -> None:
        """Validate the service description and freeze `extra_params`.

        Runs on a `dataclasses.replace` copy as well as on a direct
        construction, so a copy is checked as thoroughly as the original. The
        version is matched against the supported set rather than guessed at,
        because choosing `CRS=` or `SRS=` wrongly would misplace every tile
        silently. `styles` and `attribution` may be empty but must still be
        strings -- being allowed to be empty is how they escaped the non-empty
        check and, with it, any type check at all -- and `transparent` must be
        a real `bool` rather than merely truthy.

        `extra_params` is frozen before it is validated, not after: the
        protected and duplicate checks both fold keys with `upper()`, which an
        `int` key -- exactly the type `_freeze_params` is there to coerce --
        does not have.

        Returns:
            None

        Raises:
            ValueError: If `url` is not a non-empty http(s) string or contains
                whitespace anywhere; if `layers`, `image_format` or `version`
                is not a non-empty string; if `version` is not one of `1.1.0`,
                `1.1.1` or `1.3.0`; if `styles` or `attribution` is not a
                string (empty is allowed); if `transparent` is not a `bool`; if
                `tile_size` is not a positive `int` (`bool` is rejected too);
                if `extra_params` names a tile-addressing parameter such as
                `BBOX` or `WIDTH`, however cased; if two of its keys differ
                only by case; or if two of its keys coerce to the same name.
            TypeError: If `extra_params` is not a mapping.
        """
        _validate_endpoint(self.url, "url")
        _validate_identifier(self.layers, "layers")
        _validate_identifier(self.image_format, "image_format")
        _validate_identifier(self.version, "version")
        supported = _WMS_SRS_VERSIONS + _WMS_CRS_VERSIONS
        if self.version not in supported:
            listed = ", ".join(repr(v) for v in supported)
            raise ValueError(f"version must be one of {listed}, got {self.version!r}.")
        _validate_text(self.styles, "styles")
        _validate_text(self.attribution, "attribution")
        if not isinstance(self.transparent, bool):
            raise ValueError(
                f"transparent must be a bool, got {self.transparent!r}. Any truthy "
                f"value would otherwise send TRANSPARENT=TRUE."
            )
        if (
            not isinstance(self.tile_size, int)
            or isinstance(self.tile_size, bool)
            or self.tile_size < 1
        ):
            raise ValueError(
                f"tile_size must be a positive int, got {self.tile_size!r}."
            )
        _validate_extra_params(_freeze_params(self.extra_params))
        object.__setattr__(self, "extra_params", _freeze_params(self.extra_params))

    def __repr__(self) -> str:
        """Render without the credential; see `_repr_provider`.

        Returns:
            str: A `repr` with every `extra_params` value masked.
        """
        return _repr_provider(self)

    def __reduce__(self) -> tuple:
        """Rebuild through the constructor; see `_reduce_provider`.

        Returns:
            tuple: The `(callable, args)` pair `pickle` and `copy` use.
        """
        return _reduce_provider(self)

    def __hash__(self) -> int:
        """Hash the service description, flattening `extra_params`.

        `frozen=True` would generate a `__hash__` over the field tuple, which
        the `extra_params` mapping makes unhashable; `_hash_provider` hashes
        that mapping's sorted items instead, staying consistent with the
        generated `__eq__`.

        Returns:
            int: A hash consistent with this dataclass's own equality.
        """
        return _hash_provider(self)

    @property
    def crs_parameter(self) -> str:
        """The query key this WMS version uses for the CRS.

        1.3.0 renamed 1.1.1's `SRS=` to `CRS=`. Only the key changes: the value
        `build_url` sends is `EPSG:3857` either way, because that is the CRS of
        the tile grid. Pinning it there is also why 1.3.0's axis-order rule
        never bites -- it orders `BBOX` by the CRS's own declared axis order,
        which for EPSG:3857 is easting then northing, the same order 1.1.1
        always used. The CRS that flips to latitude-first under 1.3.0 is
        EPSG:4326, and this module never asks for it.

        Returns:
            str: `"CRS"` for 1.3.0, `"SRS"` for 1.1.1 and older.

        Examples:
            - 1.3.0 is the default, and it names the key `CRS`:
                ```python
                >>> from urllib.parse import parse_qs, urlsplit
                >>> from cleopatra.basemap.ogc import WMSProvider
                >>> provider = WMSProvider(url="https://example.org/wms", layers="ortho")
                >>> provider.crs_parameter
                'CRS'
                >>> parse_qs(urlsplit(provider.build_url(x=0, y=0, z=0)).query)["CRS"]
                ['EPSG:3857']

                ```
            - An older version carries the same value under `SRS`, and sends no
              `CRS` at all:
                ```python
                >>> from urllib.parse import parse_qs, urlsplit
                >>> from cleopatra.basemap.ogc import WMSProvider
                >>> provider = WMSProvider(
                ...     url="https://example.org/wms", layers="ortho", version="1.1.1"
                ... )
                >>> provider.crs_parameter
                'SRS'
                >>> query = parse_qs(urlsplit(provider.build_url(x=0, y=0, z=0)).query)
                >>> query["SRS"]
                ['EPSG:3857']
                >>> "CRS" in query
                False

                ```
            - 1.3.0 is the only supported version on the `CRS` side of the
              rename:
                ```python
                >>> from cleopatra.basemap.ogc import WMSProvider
                >>> [
                ...     WMSProvider(
                ...         url="https://example.org/wms", layers="ortho", version=v
                ...     ).crs_parameter
                ...     for v in ("1.1.0", "1.1.1", "1.3.0")
                ... ]
                ['SRS', 'SRS', 'CRS']

                ```
        """
        return "CRS" if self.version in _WMS_CRS_VERSIONS else "SRS"

    def build_url(self, *, x: int, y: int, z: int) -> str:
        """Return the `GetMap` URL covering one tile.

        The tile index itself is never sent. It is turned into the tile's
        EPSG:3857 bounds with `tiles._tile_xy_bounds` and passed as `BBOX`,
        with `WIDTH`/`HEIGHT` fixed at `tile_size`. Any query `url` already
        carries is kept, and `extra_params` comes last, so it can override a
        generated parameter as well as add one.

        Args:
            x: Tile column.
            y: Tile row.
            z: Zoom level.

        Returns:
            str: The full request URL, with `BBOX` set to the tile's EPSG:3857
            bounds and `WIDTH`/`HEIGHT` to `tile_size`.

        Examples:
            - The `BBOX` is exactly the bounds the tile grid gives that tile:
                ```python
                >>> from urllib.parse import parse_qs, urlsplit
                >>> from cleopatra.basemap.ogc import WMSProvider
                >>> from cleopatra.basemap.tiles import Tile, _tile_xy_bounds
                >>> provider = WMSProvider(url="https://example.org/wms", layers="ortho")
                >>> query = parse_qs(urlsplit(provider.build_url(x=4, y=2, z=3)).query)
                >>> sent = tuple(float(v) for v in query["BBOX"][0].split(","))
                >>> sent == _tile_xy_bounds(Tile(4, 2, 3))
                True
                >>> [round(v) for v in sent]
                [0, 5009377, 5009377, 10018754]

                ```
            - `tile_size` is the pixel size each `GetMap` asks for:
                ```python
                >>> from urllib.parse import parse_qs, urlsplit
                >>> from cleopatra.basemap.ogc import WMSProvider
                >>> provider = WMSProvider(
                ...     url="https://example.org/wms", layers="ortho", tile_size=512
                ... )
                >>> query = parse_qs(urlsplit(provider.build_url(x=0, y=0, z=0)).query)
                >>> query["WIDTH"], query["HEIGHT"]
                (['512'], ['512'])

                ```
            - `transparent=False` asks for an opaque image, for a base layer
              rather than an overlay:
                ```python
                >>> from urllib.parse import parse_qs, urlsplit
                >>> from cleopatra.basemap.ogc import WMSProvider
                >>> opaque = WMSProvider(
                ...     url="https://example.org/wms", layers="ortho", transparent=False
                ... )
                >>> query = parse_qs(urlsplit(opaque.build_url(x=0, y=0, z=0)).query)
                >>> query["TRANSPARENT"], query["LAYERS"]
                (['FALSE'], ['ortho'])

                ```
        """
        bounds = _tile_xy_bounds(Tile(x, y, z))
        bbox = ",".join(_format_coordinate(value) for value in bounds)
        params = {
            "SERVICE": "WMS",
            "REQUEST": "GetMap",
            "VERSION": self.version,
            "LAYERS": self.layers,
            "STYLES": self.styles,
            self.crs_parameter: "EPSG:3857",
            "BBOX": bbox,
            "WIDTH": str(self.tile_size),
            "HEIGHT": str(self.tile_size),
            "FORMAT": self.image_format,
            "TRANSPARENT": "TRUE" if self.transparent else "FALSE",
        }
        return _query(self.url, _merge_params(params, self.extra_params))
crs_parameter property #

The query key this WMS version uses for the CRS.

1.3.0 renamed 1.1.1's SRS= to CRS=. Only the key changes: the value build_url sends is EPSG:3857 either way, because that is the CRS of the tile grid. Pinning it there is also why 1.3.0's axis-order rule never bites -- it orders BBOX by the CRS's own declared axis order, which for EPSG:3857 is easting then northing, the same order 1.1.1 always used. The CRS that flips to latitude-first under 1.3.0 is EPSG:4326, and this module never asks for it.

Returns:

Name Type Description
str str

"CRS" for 1.3.0, "SRS" for 1.1.1 and older.

Examples:

  • 1.3.0 is the default, and it names the key CRS:
    >>> from urllib.parse import parse_qs, urlsplit
    >>> from cleopatra.basemap.ogc import WMSProvider
    >>> provider = WMSProvider(url="https://example.org/wms", layers="ortho")
    >>> provider.crs_parameter
    'CRS'
    >>> parse_qs(urlsplit(provider.build_url(x=0, y=0, z=0)).query)["CRS"]
    ['EPSG:3857']
    
  • An older version carries the same value under SRS, and sends no CRS at all:
    >>> from urllib.parse import parse_qs, urlsplit
    >>> from cleopatra.basemap.ogc import WMSProvider
    >>> provider = WMSProvider(
    ...     url="https://example.org/wms", layers="ortho", version="1.1.1"
    ... )
    >>> provider.crs_parameter
    'SRS'
    >>> query = parse_qs(urlsplit(provider.build_url(x=0, y=0, z=0)).query)
    >>> query["SRS"]
    ['EPSG:3857']
    >>> "CRS" in query
    False
    
  • 1.3.0 is the only supported version on the CRS side of the rename:
    >>> from cleopatra.basemap.ogc import WMSProvider
    >>> [
    ...     WMSProvider(
    ...         url="https://example.org/wms", layers="ortho", version=v
    ...     ).crs_parameter
    ...     for v in ("1.1.0", "1.1.1", "1.3.0")
    ... ]
    ['SRS', 'SRS', 'CRS']
    
__hash__() #

Hash the service description, flattening extra_params.

frozen=True would generate a __hash__ over the field tuple, which the extra_params mapping makes unhashable; _hash_provider hashes that mapping's sorted items instead, staying consistent with the generated __eq__.

Returns:

Name Type Description
int int

A hash consistent with this dataclass's own equality.

Source code in src/cleopatra/basemap/ogc.py
def __hash__(self) -> int:
    """Hash the service description, flattening `extra_params`.

    `frozen=True` would generate a `__hash__` over the field tuple, which
    the `extra_params` mapping makes unhashable; `_hash_provider` hashes
    that mapping's sorted items instead, staying consistent with the
    generated `__eq__`.

    Returns:
        int: A hash consistent with this dataclass's own equality.
    """
    return _hash_provider(self)
__post_init__() #

Validate the service description and freeze extra_params.

Runs on a dataclasses.replace copy as well as on a direct construction, so a copy is checked as thoroughly as the original. The version is matched against the supported set rather than guessed at, because choosing CRS= or SRS= wrongly would misplace every tile silently. styles and attribution may be empty but must still be strings -- being allowed to be empty is how they escaped the non-empty check and, with it, any type check at all -- and transparent must be a real bool rather than merely truthy.

extra_params is frozen before it is validated, not after: the protected and duplicate checks both fold keys with upper(), which an int key -- exactly the type _freeze_params is there to coerce -- does not have.

Returns:

Type Description
None

None

Raises:

Type Description
ValueError

If url is not a non-empty http(s) string or contains whitespace anywhere; if layers, image_format or version is not a non-empty string; if version is not one of 1.1.0, 1.1.1 or 1.3.0; if styles or attribution is not a string (empty is allowed); if transparent is not a bool; if tile_size is not a positive int (bool is rejected too); if extra_params names a tile-addressing parameter such as BBOX or WIDTH, however cased; if two of its keys differ only by case; or if two of its keys coerce to the same name.

TypeError

If extra_params is not a mapping.

Source code in src/cleopatra/basemap/ogc.py
def __post_init__(self) -> None:
    """Validate the service description and freeze `extra_params`.

    Runs on a `dataclasses.replace` copy as well as on a direct
    construction, so a copy is checked as thoroughly as the original. The
    version is matched against the supported set rather than guessed at,
    because choosing `CRS=` or `SRS=` wrongly would misplace every tile
    silently. `styles` and `attribution` may be empty but must still be
    strings -- being allowed to be empty is how they escaped the non-empty
    check and, with it, any type check at all -- and `transparent` must be
    a real `bool` rather than merely truthy.

    `extra_params` is frozen before it is validated, not after: the
    protected and duplicate checks both fold keys with `upper()`, which an
    `int` key -- exactly the type `_freeze_params` is there to coerce --
    does not have.

    Returns:
        None

    Raises:
        ValueError: If `url` is not a non-empty http(s) string or contains
            whitespace anywhere; if `layers`, `image_format` or `version`
            is not a non-empty string; if `version` is not one of `1.1.0`,
            `1.1.1` or `1.3.0`; if `styles` or `attribution` is not a
            string (empty is allowed); if `transparent` is not a `bool`; if
            `tile_size` is not a positive `int` (`bool` is rejected too);
            if `extra_params` names a tile-addressing parameter such as
            `BBOX` or `WIDTH`, however cased; if two of its keys differ
            only by case; or if two of its keys coerce to the same name.
        TypeError: If `extra_params` is not a mapping.
    """
    _validate_endpoint(self.url, "url")
    _validate_identifier(self.layers, "layers")
    _validate_identifier(self.image_format, "image_format")
    _validate_identifier(self.version, "version")
    supported = _WMS_SRS_VERSIONS + _WMS_CRS_VERSIONS
    if self.version not in supported:
        listed = ", ".join(repr(v) for v in supported)
        raise ValueError(f"version must be one of {listed}, got {self.version!r}.")
    _validate_text(self.styles, "styles")
    _validate_text(self.attribution, "attribution")
    if not isinstance(self.transparent, bool):
        raise ValueError(
            f"transparent must be a bool, got {self.transparent!r}. Any truthy "
            f"value would otherwise send TRANSPARENT=TRUE."
        )
    if (
        not isinstance(self.tile_size, int)
        or isinstance(self.tile_size, bool)
        or self.tile_size < 1
    ):
        raise ValueError(
            f"tile_size must be a positive int, got {self.tile_size!r}."
        )
    _validate_extra_params(_freeze_params(self.extra_params))
    object.__setattr__(self, "extra_params", _freeze_params(self.extra_params))
__reduce__() #

Rebuild through the constructor; see _reduce_provider.

Returns:

Name Type Description
tuple tuple

The (callable, args) pair pickle and copy use.

Source code in src/cleopatra/basemap/ogc.py
def __reduce__(self) -> tuple:
    """Rebuild through the constructor; see `_reduce_provider`.

    Returns:
        tuple: The `(callable, args)` pair `pickle` and `copy` use.
    """
    return _reduce_provider(self)
__repr__() #

Render without the credential; see _repr_provider.

Returns:

Name Type Description
str str

A repr with every extra_params value masked.

Source code in src/cleopatra/basemap/ogc.py
def __repr__(self) -> str:
    """Render without the credential; see `_repr_provider`.

    Returns:
        str: A `repr` with every `extra_params` value masked.
    """
    return _repr_provider(self)
build_url(*, x, y, z) #

Return the GetMap URL covering one tile.

The tile index itself is never sent. It is turned into the tile's EPSG:3857 bounds with tiles._tile_xy_bounds and passed as BBOX, with WIDTH/HEIGHT fixed at tile_size. Any query url already carries is kept, and extra_params comes last, so it can override a generated parameter as well as add one.

Parameters:

Name Type Description Default
x int

Tile column.

required
y int

Tile row.

required
z int

Zoom level.

required

Returns:

Name Type Description
str str

The full request URL, with BBOX set to the tile's EPSG:3857

str

bounds and WIDTH/HEIGHT to tile_size.

Examples:

  • The BBOX is exactly the bounds the tile grid gives that tile:
    >>> from urllib.parse import parse_qs, urlsplit
    >>> from cleopatra.basemap.ogc import WMSProvider
    >>> from cleopatra.basemap.tiles import Tile, _tile_xy_bounds
    >>> provider = WMSProvider(url="https://example.org/wms", layers="ortho")
    >>> query = parse_qs(urlsplit(provider.build_url(x=4, y=2, z=3)).query)
    >>> sent = tuple(float(v) for v in query["BBOX"][0].split(","))
    >>> sent == _tile_xy_bounds(Tile(4, 2, 3))
    True
    >>> [round(v) for v in sent]
    [0, 5009377, 5009377, 10018754]
    
  • tile_size is the pixel size each GetMap asks for:
    >>> from urllib.parse import parse_qs, urlsplit
    >>> from cleopatra.basemap.ogc import WMSProvider
    >>> provider = WMSProvider(
    ...     url="https://example.org/wms", layers="ortho", tile_size=512
    ... )
    >>> query = parse_qs(urlsplit(provider.build_url(x=0, y=0, z=0)).query)
    >>> query["WIDTH"], query["HEIGHT"]
    (['512'], ['512'])
    
  • transparent=False asks for an opaque image, for a base layer rather than an overlay:
    >>> from urllib.parse import parse_qs, urlsplit
    >>> from cleopatra.basemap.ogc import WMSProvider
    >>> opaque = WMSProvider(
    ...     url="https://example.org/wms", layers="ortho", transparent=False
    ... )
    >>> query = parse_qs(urlsplit(opaque.build_url(x=0, y=0, z=0)).query)
    >>> query["TRANSPARENT"], query["LAYERS"]
    (['FALSE'], ['ortho'])
    
Source code in src/cleopatra/basemap/ogc.py
def build_url(self, *, x: int, y: int, z: int) -> str:
    """Return the `GetMap` URL covering one tile.

    The tile index itself is never sent. It is turned into the tile's
    EPSG:3857 bounds with `tiles._tile_xy_bounds` and passed as `BBOX`,
    with `WIDTH`/`HEIGHT` fixed at `tile_size`. Any query `url` already
    carries is kept, and `extra_params` comes last, so it can override a
    generated parameter as well as add one.

    Args:
        x: Tile column.
        y: Tile row.
        z: Zoom level.

    Returns:
        str: The full request URL, with `BBOX` set to the tile's EPSG:3857
        bounds and `WIDTH`/`HEIGHT` to `tile_size`.

    Examples:
        - The `BBOX` is exactly the bounds the tile grid gives that tile:
            ```python
            >>> from urllib.parse import parse_qs, urlsplit
            >>> from cleopatra.basemap.ogc import WMSProvider
            >>> from cleopatra.basemap.tiles import Tile, _tile_xy_bounds
            >>> provider = WMSProvider(url="https://example.org/wms", layers="ortho")
            >>> query = parse_qs(urlsplit(provider.build_url(x=4, y=2, z=3)).query)
            >>> sent = tuple(float(v) for v in query["BBOX"][0].split(","))
            >>> sent == _tile_xy_bounds(Tile(4, 2, 3))
            True
            >>> [round(v) for v in sent]
            [0, 5009377, 5009377, 10018754]

            ```
        - `tile_size` is the pixel size each `GetMap` asks for:
            ```python
            >>> from urllib.parse import parse_qs, urlsplit
            >>> from cleopatra.basemap.ogc import WMSProvider
            >>> provider = WMSProvider(
            ...     url="https://example.org/wms", layers="ortho", tile_size=512
            ... )
            >>> query = parse_qs(urlsplit(provider.build_url(x=0, y=0, z=0)).query)
            >>> query["WIDTH"], query["HEIGHT"]
            (['512'], ['512'])

            ```
        - `transparent=False` asks for an opaque image, for a base layer
          rather than an overlay:
            ```python
            >>> from urllib.parse import parse_qs, urlsplit
            >>> from cleopatra.basemap.ogc import WMSProvider
            >>> opaque = WMSProvider(
            ...     url="https://example.org/wms", layers="ortho", transparent=False
            ... )
            >>> query = parse_qs(urlsplit(opaque.build_url(x=0, y=0, z=0)).query)
            >>> query["TRANSPARENT"], query["LAYERS"]
            (['FALSE'], ['ortho'])

            ```
    """
    bounds = _tile_xy_bounds(Tile(x, y, z))
    bbox = ",".join(_format_coordinate(value) for value in bounds)
    params = {
        "SERVICE": "WMS",
        "REQUEST": "GetMap",
        "VERSION": self.version,
        "LAYERS": self.layers,
        "STYLES": self.styles,
        self.crs_parameter: "EPSG:3857",
        "BBOX": bbox,
        "WIDTH": str(self.tile_size),
        "HEIGHT": str(self.tile_size),
        "FORMAT": self.image_format,
        "TRANSPARENT": "TRUE" if self.transparent else "FALSE",
    }
    return _query(self.url, _merge_params(params, self.extra_params))

WMTSProvider dataclass #

An OGC WMTS service addressed as a tile provider.

Satisfies the build_url(x=, y=, z=) contract cleopatra.basemap.tiles.fetch_single_tile calls, so it can be handed straight to add_tiles as the source.

Both request encodings are supported. If url carries any placeholder this module owns -- {TileMatrix}, {TileRow}, {TileCol}, {TileMatrixSet}, {Layer} or {Style}, in whatever casing the service publishes them -- it is treated as a RESTful template and those placeholders are substituted; otherwise url is taken as a KVP endpoint and a GetTile query is built. A template has to address a tile, so one carrying placeholders but missing {TileMatrix}, {TileRow} or {TileCol} is refused at construction rather than shipped as a mosaic of one repeated image. So is one whose placeholders are none of this module's -- an XYZ {z}/{x}/{y} template, say, which belongs in add_tiles(source=...) as an xyzservices provider: taking it as a KVP endpoint would ship the braces to the service literally.

The service description is checked once, at construction -- including on a dataclasses.replace copy, which is the supported way to vary a frozen provider. The instance is then immutable and hashable, so it can key a dict or a cache; extra_params is stored as a read-only copy, so a caller's later edit to the dict they passed cannot rewrite the URLs.

Parameters:

Name Type Description Default
url str

The GetTile KVP endpoint, or a RESTful template containing {TileMatrix}, {TileRow} and {TileCol} -- all three, in any casing -- and optionally {TileMatrixSet}, {Layer} or {Style}. A template carrying only placeholders this module does not own is refused, since the braces would be sent literally. Whitespace anywhere in the value is refused rather than repaired, because the value is sent verbatim; an escaped %20 is not whitespace and is accepted.

required
layer str

The Layer identifier to request.

required
tile_matrix_set str

The tile-matrix set identifier, defaulting to GOOGLE_MAPS_COMPATIBLE. Only GoogleMapsCompatible (or a service's own name for that same Web Mercator grid, e.g. GoogleMapsCompatible_Level9) lines up with this package's tile geometry -- any other grid returns tiles that will be placed wrongly. Nothing validates the grid beyond it being non-empty, because the identifier is the service's to name.

GOOGLE_MAPS_COMPATIBLE
style str

The Style identifier. Most services publish "default". It is percent-encoded where it fills a {Style} placeholder, so a name carrying /, ? or a space cannot invent a path segment or start a query.

'default'
image_format str

The Format to request. tiles._looks_like_image accepts PNG, JPEG, GIF and WebP, so a TIFF or SVG format will be rejected as an unreadable tile. Sent on the KVP branch only -- a RESTful template fixes the format in its own path.

'image/png'
version str

The WMTS version. OGC has only ever published 1.0.0, so that is the only accepted value -- checked here as well, rather than WMSProvider refusing an unknown version while this one takes any non-empty string. Sent as VERSION on the KVP branch only; a RESTful template encodes it in the endpoint, but it is validated either way.

'1.0.0'
attribution str

Credit line, which may be empty but must be a string. add_tiles(attribution=True) reads this attribute and draws it on the axes.

''
extra_params Mapping[str, str]

Extra query parameters, merged last so they can also override a generated one -- matched case-insensitively, as OGC parameter names are, so {"format": ...} replaces the generated FORMAT rather than joining it. A parameter already in the url's own query is kept verbatim and is not overridden this way. On the RESTful branch there are no generated parameters to override, so these are simply appended as the query. This is where an API key or token goes; a failed fetch logs the URL with every value cleopatra did not itself generate replaced by ..., so the key does not reach the debug log. Keys and values are coerced to str and stored read-only. Three things are refused rather than merged: a parameter that addresses the tile (_PROTECTED_PARAMS), two keys differing only in case, and two keys that become one name once coerced to str.

dict()

Raises:

Type Description
ValueError

If url is not a non-empty http(s) string or contains whitespace anywhere; if layer, tile_matrix_set, style, image_format or version is not a non-empty string; if version is anything but 1.0.0; if attribution is not a string (empty is allowed); if url carries RESTful placeholders without all of {TileMatrix}, {TileRow} and {TileCol}; if url carries placeholders of which none are this module's; if extra_params names a tile-addressing parameter such as TILEROW or REQUEST, however cased; if two of its keys differ only by case; or if two of its keys coerce to the same name.

TypeError

If extra_params is not a mapping.

Examples:

  • Build a GetTile KVP request and read back the tile triple:
    >>> from urllib.parse import parse_qs, urlsplit
    >>> from cleopatra.basemap.ogc import WMTSProvider
    >>> provider = WMTSProvider(
    ...     url="https://example.org/wmts",
    ...     layer="TrueColor",
    ... )
    >>> query = parse_qs(urlsplit(provider.build_url(x=4, y=2, z=3)).query)
    >>> query["TILEMATRIX"], query["TILEROW"], query["TILECOL"]
    (['3'], ['2'], ['4'])
    >>> query["REQUEST"], query["LAYER"]
    (['GetTile'], ['TrueColor'])
    
  • A RESTful template substitutes in place instead:
    >>> from cleopatra.basemap.ogc import WMTSProvider
    >>> provider = WMTSProvider(
    ...     url="https://example.org/wmts/{Layer}/{TileMatrix}/{TileRow}/{TileCol}.png",
    ...     layer="TrueColor",
    ... )
    >>> provider.build_url(x=4, y=2, z=3)
    'https://example.org/wmts/TrueColor/3/2/4.png'
    
  • extra_params carries a credential, escaped into the query:
    >>> from urllib.parse import parse_qs, urlsplit
    >>> from cleopatra.basemap.ogc import WMTSProvider
    >>> provider = WMTSProvider(
    ...     url="https://example.org/wmts",
    ...     layer="TrueColor",
    ...     extra_params={"api key": "a&b"},
    ... )
    >>> url = provider.build_url(x=0, y=0, z=0)
    >>> url.endswith("api+key=a%26b")
    True
    >>> parse_qs(urlsplit(url).query)["api key"]
    ['a&b']
    
  • A provider is frozen and hashable, so it can key a cache:
    >>> from cleopatra.basemap.ogc import WMTSProvider
    >>> first = WMTSProvider(url="https://example.org/wmts", layer="TrueColor")
    >>> second = WMTSProvider(url="https://example.org/wmts", layer="TrueColor")
    >>> first == second
    True
    >>> len({first, second})
    1
    >>> {first: "imagery"}[second]
    'imagery'
    
  • An unusable endpoint is refused at construction, not mid-render:
    >>> from cleopatra.basemap.ogc import WMTSProvider
    >>> WMTSProvider(url="file:///tiles/wmts", layer="TrueColor")
    Traceback (most recent call last):
        ...
    ValueError: url must be an http(s) URL, got 'file:///tiles/wmts' (scheme 'file').
    
  • So is a template that cannot address a tile -- every tile would otherwise resolve to the same picture, silently:
    >>> from cleopatra.basemap.ogc import WMTSProvider
    >>> try:
    ...     WMTSProvider(url="https://example.org/w/{TileMatrix}.png", layer="L")
    ... except ValueError as error:
    ...     print(str(error).split(".")[0])
    a RESTful WMTS template must address a tile: url is missing {tilerow}, {tilecol}
    
See Also

WMSProvider: The same idea for a WMS GetMap service. cleopatra.basemap.tiles.add_tiles: The renderer both feed.

Source code in src/cleopatra/basemap/ogc.py
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
@dataclass(frozen=True)
class WMTSProvider:
    """An OGC WMTS service addressed as a tile provider.

    Satisfies the `build_url(x=, y=, z=)` contract
    `cleopatra.basemap.tiles.fetch_single_tile` calls, so it can be handed
    straight to `add_tiles` as the `source`.

    Both request encodings are supported. If `url` carries any placeholder
    this module owns -- `{TileMatrix}`, `{TileRow}`, `{TileCol}`,
    `{TileMatrixSet}`, `{Layer}` or `{Style}`, in whatever casing the service
    publishes them -- it is treated as a RESTful template and those
    placeholders are substituted; otherwise `url` is taken as a KVP endpoint
    and a `GetTile` query is built. A template has to address a tile, so one
    carrying placeholders but missing `{TileMatrix}`, `{TileRow}` or
    `{TileCol}` is refused at construction rather than shipped as a mosaic of
    one repeated image. So is one whose placeholders are *none* of this
    module's -- an XYZ `{z}/{x}/{y}` template, say, which belongs in
    `add_tiles(source=...)` as an `xyzservices` provider: taking it as a KVP
    endpoint would ship the braces to the service literally.

    The service description is checked once, at construction -- including on a
    `dataclasses.replace` copy, which is the supported way to vary a frozen
    provider. The instance is then immutable and hashable, so it can key a dict
    or a cache; `extra_params` is stored as a read-only copy, so a caller's
    later edit to the dict they passed cannot rewrite the URLs.

    Args:
        url: The `GetTile` KVP endpoint, or a RESTful template containing
            `{TileMatrix}`, `{TileRow}` and `{TileCol}` -- all three, in any
            casing -- and optionally `{TileMatrixSet}`, `{Layer}` or
            `{Style}`. A template carrying only placeholders this module does
            not own is refused, since the braces would be sent literally.
            Whitespace anywhere in the value is refused rather than repaired,
            because the value is sent verbatim; an escaped `%20` is not
            whitespace and is accepted.
        layer: The `Layer` identifier to request.
        tile_matrix_set: The tile-matrix set identifier, defaulting to
            `GOOGLE_MAPS_COMPATIBLE`. Only `GoogleMapsCompatible` (or a
            service's own name for that same Web Mercator grid, e.g.
            `GoogleMapsCompatible_Level9`) lines up with this package's tile
            geometry -- any other grid returns tiles that will be placed
            wrongly. Nothing validates the grid beyond it being non-empty,
            because the identifier is the service's to name.
        style: The `Style` identifier. Most services publish `"default"`. It
            is percent-encoded where it fills a `{Style}` placeholder, so a
            name carrying `/`, `?` or a space cannot invent a path segment or
            start a query.
        image_format: The `Format` to request. `tiles._looks_like_image`
            accepts PNG, JPEG, GIF and WebP, so a TIFF or SVG format will be
            rejected as an unreadable tile. Sent on the KVP branch only -- a
            RESTful template fixes the format in its own path.
        version: The WMTS version. OGC has only ever published 1.0.0, so that
            is the only accepted value -- checked here as well, rather than
            `WMSProvider` refusing an unknown version while this one takes any
            non-empty string. Sent as `VERSION` on the KVP branch only; a
            RESTful template encodes it in the endpoint, but it is validated
            either way.
        attribution: Credit line, which may be empty but must be a string.
            `add_tiles(attribution=True)` reads this attribute and draws it on
            the axes.
        extra_params: Extra query parameters, merged last so they can also
            override a generated one -- matched case-insensitively, as OGC
            parameter names are, so `{"format": ...}` replaces the
            generated `FORMAT` rather than joining it. A parameter already
            in the `url`'s own query is kept verbatim and is *not*
            overridden this way. On the RESTful branch there are no generated
            parameters to override, so these are simply appended as the query.
            This is where an API key or token goes; a failed fetch logs the URL
            with every value cleopatra did not itself generate replaced by
            `...`, so the key does not reach the debug log. Keys and values are
            coerced to `str` and stored read-only. Three things are refused
            rather than merged: a parameter that addresses the tile
            (`_PROTECTED_PARAMS`), two keys differing only in case, and two
            keys that become one name once coerced to `str`.

    Raises:
        ValueError: If `url` is not a non-empty http(s) string or contains
            whitespace anywhere; if `layer`, `tile_matrix_set`, `style`,
            `image_format` or `version` is not a non-empty string; if `version`
            is anything but `1.0.0`; if `attribution` is not a string (empty is
            allowed); if `url` carries RESTful placeholders without all of
            `{TileMatrix}`, `{TileRow}` and `{TileCol}`; if `url` carries
            placeholders of which *none* are this module's; if `extra_params`
            names a tile-addressing parameter such as `TILEROW` or `REQUEST`,
            however cased; if two of its keys differ only by case; or if two of
            its keys coerce to the same name.
        TypeError: If `extra_params` is not a mapping.

    Examples:
        - Build a `GetTile` KVP request and read back the tile triple:
            ```python
            >>> from urllib.parse import parse_qs, urlsplit
            >>> from cleopatra.basemap.ogc import WMTSProvider
            >>> provider = WMTSProvider(
            ...     url="https://example.org/wmts",
            ...     layer="TrueColor",
            ... )
            >>> query = parse_qs(urlsplit(provider.build_url(x=4, y=2, z=3)).query)
            >>> query["TILEMATRIX"], query["TILEROW"], query["TILECOL"]
            (['3'], ['2'], ['4'])
            >>> query["REQUEST"], query["LAYER"]
            (['GetTile'], ['TrueColor'])

            ```
        - A RESTful template substitutes in place instead:
            ```python
            >>> from cleopatra.basemap.ogc import WMTSProvider
            >>> provider = WMTSProvider(
            ...     url="https://example.org/wmts/{Layer}/{TileMatrix}/{TileRow}/{TileCol}.png",
            ...     layer="TrueColor",
            ... )
            >>> provider.build_url(x=4, y=2, z=3)
            'https://example.org/wmts/TrueColor/3/2/4.png'

            ```
        - `extra_params` carries a credential, escaped into the query:
            ```python
            >>> from urllib.parse import parse_qs, urlsplit
            >>> from cleopatra.basemap.ogc import WMTSProvider
            >>> provider = WMTSProvider(
            ...     url="https://example.org/wmts",
            ...     layer="TrueColor",
            ...     extra_params={"api key": "a&b"},
            ... )
            >>> url = provider.build_url(x=0, y=0, z=0)
            >>> url.endswith("api+key=a%26b")
            True
            >>> parse_qs(urlsplit(url).query)["api key"]
            ['a&b']

            ```
        - A provider is frozen and hashable, so it can key a cache:
            ```python
            >>> from cleopatra.basemap.ogc import WMTSProvider
            >>> first = WMTSProvider(url="https://example.org/wmts", layer="TrueColor")
            >>> second = WMTSProvider(url="https://example.org/wmts", layer="TrueColor")
            >>> first == second
            True
            >>> len({first, second})
            1
            >>> {first: "imagery"}[second]
            'imagery'

            ```
        - An unusable endpoint is refused at construction, not mid-render:
            ```python
            >>> from cleopatra.basemap.ogc import WMTSProvider
            >>> WMTSProvider(url="file:///tiles/wmts", layer="TrueColor")
            Traceback (most recent call last):
                ...
            ValueError: url must be an http(s) URL, got 'file:///tiles/wmts' (scheme 'file').

            ```
        - So is a template that cannot address a tile -- every tile would
          otherwise resolve to the same picture, silently:
            ```python
            >>> from cleopatra.basemap.ogc import WMTSProvider
            >>> try:
            ...     WMTSProvider(url="https://example.org/w/{TileMatrix}.png", layer="L")
            ... except ValueError as error:
            ...     print(str(error).split(".")[0])
            a RESTful WMTS template must address a tile: url is missing {tilerow}, {tilecol}

            ```

    See Also:
        WMSProvider: The same idea for a WMS `GetMap` service.
        cleopatra.basemap.tiles.add_tiles: The renderer both feed.
    """

    url: str
    layer: str
    tile_matrix_set: str = GOOGLE_MAPS_COMPATIBLE
    style: str = "default"
    image_format: str = "image/png"
    version: str = "1.0.0"
    attribution: str = ""
    extra_params: Mapping[str, str] = field(default_factory=dict)

    def __post_init__(self) -> None:
        """Validate the service description and freeze `extra_params`.

        Runs on a `dataclasses.replace` copy as well as on a direct
        construction, so a copy is checked as thoroughly as the original. The
        version is matched against the one published WMTS version rather than
        merely required to be non-empty, so the two providers refuse the same
        class of mistake; `attribution` may be empty but is still required to
        be a string, since a non-string one would reach the axes as its `repr`;
        and a template carrying placeholders is required to carry the three
        that identify a tile -- or, if it carries none this module owns at all,
        is refused outright rather than read as a KVP endpoint whose braces go
        on the wire.

        `extra_params` is frozen before it is validated, not after: the
        protected and duplicate checks both fold keys with `upper()`, which an
        `int` key -- exactly the type `_freeze_params` is there to coerce --
        does not have.

        Returns:
            None

        Raises:
            ValueError: If `url` is not a non-empty http(s) string or contains
                whitespace anywhere; if `layer`, `tile_matrix_set`, `style`,
                `image_format` or `version` is not a non-empty string; if
                `version` is anything but `1.0.0`; if `attribution` is not a
                string (empty is allowed); if `url` carries RESTful
                placeholders without all of `{TileMatrix}`, `{TileRow}` and
                `{TileCol}`; if `url` carries placeholders of which *none* are
                this module's; if `extra_params` names a tile-addressing
                parameter such as `TILEROW` or `REQUEST`, however cased; if two
                of its keys differ only by case; or if two of its keys coerce
                to the same name.
            TypeError: If `extra_params` is not a mapping.
        """
        _validate_endpoint(self.url, "url")
        _validate_identifier(self.layer, "layer")
        _validate_identifier(self.tile_matrix_set, "tile_matrix_set")
        _validate_identifier(self.style, "style")
        _validate_identifier(self.image_format, "image_format")
        _validate_identifier(self.version, "version")
        if self.version not in _WMTS_VERSIONS:
            listed = ", ".join(repr(v) for v in _WMTS_VERSIONS)
            raise ValueError(f"version must be one of {listed}, got {self.version!r}.")
        _validate_text(self.style, "style")
        _validate_text(self.attribution, "attribution")
        present = _restful_placeholders(self.url)
        all_placeholders = {
            match.group(1).lower() for match in _PLACEHOLDER_RE.finditer(self.url)
        }
        if all_placeholders and not present:
            listed = ", ".join(sorted(f"{{{name}}}" for name in all_placeholders))
            raise ValueError(
                f"url carries placeholders this module does not fill -- {listed} -- "
                f"and none it does. A RESTful WMTS template uses {{TileMatrix}}, "
                f"{{TileRow}} and {{TileCol}}; an XYZ {{z}}/{{x}}/{{y}} template "
                f"belongs in add_tiles(source=...) as an xyzservices provider, not "
                f"here. As written the braces would be sent literally."
            )
        if present:
            missing = [f for f in _REQUIRED_RESTFUL_FIELDS if f not in present]
            if missing:
                listed = ", ".join(f"{{{f}}}" for f in missing)
                raise ValueError(
                    f"a RESTful WMTS template must address a tile: url is "
                    f"missing {listed}. Without them every tile resolves to "
                    f"the same URL, so the mosaic would repeat one image."
                )
        _validate_extra_params(_freeze_params(self.extra_params))
        object.__setattr__(self, "extra_params", _freeze_params(self.extra_params))

    def __repr__(self) -> str:
        """Render without the credential; see `_repr_provider`.

        Returns:
            str: A `repr` with every `extra_params` value masked.
        """
        return _repr_provider(self)

    def __reduce__(self) -> tuple:
        """Rebuild through the constructor; see `_reduce_provider`.

        Returns:
            tuple: The `(callable, args)` pair `pickle` and `copy` use.
        """
        return _reduce_provider(self)

    def __hash__(self) -> int:
        """Hash the service description, flattening `extra_params`.

        `frozen=True` would generate a `__hash__` over the field tuple, which
        the `extra_params` mapping makes unhashable; `_hash_provider` hashes
        that mapping's sorted items instead, staying consistent with the
        generated `__eq__`.

        Returns:
            int: A hash consistent with this dataclass's own equality.
        """
        return _hash_provider(self)

    @property
    def is_restful(self) -> bool:
        """Whether `url` is a RESTful template rather than a KVP endpoint.

        Any placeholder this module owns is the marker -- `{TileMatrix}`,
        `{TileRow}`, `{TileCol}`, `{TileMatrixSet}`, `{Layer}` or `{Style}` --
        matched case-insensitively, because services are inconsistent about
        the spelling and a mis-cased one used to fail this test, fall through
        to the KVP branch and ship a URL with literal braces still in it. Its
        presence is what selects `build_url`'s substitution branch over the
        `GetTile` query branch.

        A name this module does not own -- a service's own `{Time}`, say --
        does not count, and neither does a query string in the endpoint.
        Because `__post_init__` refuses a template missing `{TileMatrix}`,
        `{TileRow}` or `{TileCol}`, a constructed provider that answers `True`
        always carries all three.

        Returns:
            bool: `True` when `url` carries at least one recognised
            placeholder.

        Examples:
            - A plain endpoint is KVP, so a `GetTile` query gets built:
                ```python
                >>> from cleopatra.basemap.ogc import WMTSProvider
                >>> provider = WMTSProvider(url="https://example.org/wmts", layer="L")
                >>> provider.is_restful
                False
                >>> "REQUEST=GetTile" in provider.build_url(x=1, y=1, z=1)
                True

                ```
            - A template carrying the marker is substituted in place instead:
                ```python
                >>> from cleopatra.basemap.ogc import WMTSProvider
                >>> provider = WMTSProvider(
                ...     url="https://example.org/{TileMatrix}/{TileRow}/{TileCol}.png",
                ...     layer="L",
                ... )
                >>> provider.is_restful
                True
                >>> provider.build_url(x=4, y=2, z=3)
                'https://example.org/3/2/4.png'

                ```
            - The marker is matched case-insensitively, so a lower-cased
              template is filled in rather than shipped with literal braces:
                ```python
                >>> from cleopatra.basemap.ogc import WMTSProvider
                >>> provider = WMTSProvider(
                ...     url="https://example.org/{tilematrix}/{tilerow}/{tilecol}.png",
                ...     layer="L",
                ... )
                >>> provider.is_restful
                True
                >>> provider.build_url(x=4, y=2, z=3)
                'https://example.org/3/2/4.png'

                ```
            - An endpoint that merely carries a query is still KVP:
                ```python
                >>> from cleopatra.basemap.ogc import WMTSProvider
                >>> WMTSProvider(url="https://example.org/wmts?layer=x", layer="L").is_restful
                False

                ```
            - A template whose placeholders are *all* unowned is refused
              outright, since the braces would be sent literally:
                ```python
                >>> from cleopatra.basemap.ogc import WMTSProvider
                >>> WMTSProvider(url="https://example.org/wmts/{Time}.png", layer="L")
                Traceback (most recent call last):
                    ...
                ValueError: url carries placeholders this module does not fill ...

                ```
            - An unowned name *alongside* the required three is fine, and does
              not itself make the template RESTful:
                ```python
                >>> from cleopatra.basemap.ogc import WMTSProvider
                >>> template = "https://e.org/{Time}/{TileMatrix}/{TileRow}/{TileCol}.png"
                >>> WMTSProvider(url=template, layer="L").is_restful
                True

                ```
        """
        return bool(_restful_placeholders(self.url))

    def build_url(self, *, x: int, y: int, z: int) -> str:
        """Return the `GetTile` URL for one tile.

        The WMTS tile triple is the slippy triple under another name:
        `z` is `TileMatrix`, `y` is `TileRow`, `x` is `TileCol`.

        A RESTful `url` (see `is_restful`) has its placeholders filled in, in
        one left-to-right pass over the whole URL -- query included, not just
        the path. Four things follow from that. Names are matched
        case-insensitively, so whatever spelling the service publishes works.
        Each value is percent-encoded, so a layer or style name carrying `/`,
        `?` or a space cannot invent a path segment or start a query. The pass
        never re-scans what it has already written, so a field whose value
        itself looks like a placeholder stays data instead of being rewritten
        by a later field. And a placeholder this module does not own -- a
        service's own `{Time}` -- is left exactly as it stands, for the caller
        to see rather than silently mangled. `extra_params` is then appended as
        a query, with an `&` if the filled-in template already carries one.

        Otherwise a `GetTile` KVP query is appended to `url`, keeping any query
        it already carries, and `extra_params` comes last so it can override a
        generated parameter -- matched case-insensitively -- as well as add
        one.

        Args:
            x: Tile column, i.e. `TileCol`.
            y: Tile row, i.e. `TileRow`.
            z: Zoom level, i.e. `TileMatrix`.

        Returns:
            str: The full request URL.

        Examples:
            - A KVP endpoint keeps the query it already carries and gains the
              `GetTile` parameters:
                ```python
                >>> from urllib.parse import parse_qs, urlsplit
                >>> from cleopatra.basemap.ogc import WMTSProvider
                >>> provider = WMTSProvider(
                ...     url="https://example.org/wmts?map=/etc/base.map",
                ...     layer="TrueColor",
                ... )
                >>> query = parse_qs(urlsplit(provider.build_url(x=1, y=1, z=1)).query)
                >>> query["map"]
                ['/etc/base.map']
                >>> query["SERVICE"], query["TILEMATRIXSET"]
                (['WMTS'], ['GoogleMapsCompatible'])

                ```
            - A template is filled in as far as its placeholders go, and
              `extra_params` follows it as a query:
                ```python
                >>> from cleopatra.basemap.ogc import WMTSProvider
                >>> provider = WMTSProvider(
                ...     url="https://example.org/wmts/{TileMatrix}/{TileRow}/{TileCol}.png",
                ...     layer="TrueColor",
                ...     extra_params={"token": "abc"},
                ... )
                >>> provider.build_url(x=4, y=2, z=3)
                'https://example.org/wmts/3/2/4.png?token=abc'

                ```
            - `extra_params` wins over a parameter the provider generates, so a
              service demanding its own spelling needs no code change:
                ```python
                >>> from urllib.parse import parse_qs, urlsplit
                >>> from cleopatra.basemap.ogc import WMTSProvider
                >>> provider = WMTSProvider(
                ...     url="https://example.org/wmts",
                ...     layer="TrueColor",
                ...     extra_params={"FORMAT": "image/jpeg"},
                ... )
                >>> parse_qs(urlsplit(provider.build_url(x=0, y=0, z=0)).query)["FORMAT"]
                ['image/jpeg']

                ```
            - Casing does not matter and every substituted value is
              percent-encoded, so a layer name cannot break out of its segment:
                ```python
                >>> from cleopatra.basemap.ogc import WMTSProvider
                >>> provider = WMTSProvider(
                ...     url="https://example.org/w/{layer}/{tilematrix}/{tilerow}/{tilecol}.png",
                ...     layer="a b/c",
                ... )
                >>> provider.build_url(x=4, y=2, z=3)
                'https://example.org/w/a%20b%2Fc/3/2/4.png'

                ```
            - A placeholder this module does not own survives untouched, and a
              value that merely looks like one is not substituted a second
              time:
                ```python
                >>> from cleopatra.basemap.ogc import WMTSProvider
                >>> provider = WMTSProvider(
                ...     url="https://example.org/{Custom}/{Layer}/{TileMatrix}/{TileRow}/{TileCol}.png",
                ...     layer="{TileRow}",
                ... )
                >>> provider.build_url(x=4, y=2, z=3)
                'https://example.org/{Custom}/%7BTileRow%7D/3/2/4.png'

                ```
        """
        if self.is_restful:
            values = {
                "tilematrixset": self.tile_matrix_set,
                "tilematrix": str(z),
                "tilerow": str(y),
                "tilecol": str(x),
                "layer": self.layer,
                "style": self.style,
            }

            def substitute(match: re.Match[str]) -> str:
                """Replace one placeholder, leaving an unknown one alone."""
                name = match.group(1).lower()
                if name not in values:
                    return match.group(0)
                return urllib.parse.quote(values[name], safe="")

            # One pass, so a value that happens to look like a placeholder is
            # not rewritten again by a later field.
            filled = _PLACEHOLDER_RE.sub(substitute, self.url)
            return _query(filled, self.extra_params)

        params = {
            "SERVICE": "WMTS",
            "REQUEST": "GetTile",
            "VERSION": self.version,
            "LAYER": self.layer,
            "STYLE": self.style,
            "TILEMATRIXSET": self.tile_matrix_set,
            "TILEMATRIX": str(z),
            "TILEROW": str(y),
            "TILECOL": str(x),
            "FORMAT": self.image_format,
        }
        return _query(self.url, _merge_params(params, self.extra_params))
is_restful property #

Whether url is a RESTful template rather than a KVP endpoint.

Any placeholder this module owns is the marker -- {TileMatrix}, {TileRow}, {TileCol}, {TileMatrixSet}, {Layer} or {Style} -- matched case-insensitively, because services are inconsistent about the spelling and a mis-cased one used to fail this test, fall through to the KVP branch and ship a URL with literal braces still in it. Its presence is what selects build_url's substitution branch over the GetTile query branch.

A name this module does not own -- a service's own {Time}, say -- does not count, and neither does a query string in the endpoint. Because __post_init__ refuses a template missing {TileMatrix}, {TileRow} or {TileCol}, a constructed provider that answers True always carries all three.

Returns:

Name Type Description
bool bool

True when url carries at least one recognised

bool

placeholder.

Examples:

  • A plain endpoint is KVP, so a GetTile query gets built:
    >>> from cleopatra.basemap.ogc import WMTSProvider
    >>> provider = WMTSProvider(url="https://example.org/wmts", layer="L")
    >>> provider.is_restful
    False
    >>> "REQUEST=GetTile" in provider.build_url(x=1, y=1, z=1)
    True
    
  • A template carrying the marker is substituted in place instead:
    >>> from cleopatra.basemap.ogc import WMTSProvider
    >>> provider = WMTSProvider(
    ...     url="https://example.org/{TileMatrix}/{TileRow}/{TileCol}.png",
    ...     layer="L",
    ... )
    >>> provider.is_restful
    True
    >>> provider.build_url(x=4, y=2, z=3)
    'https://example.org/3/2/4.png'
    
  • The marker is matched case-insensitively, so a lower-cased template is filled in rather than shipped with literal braces:
    >>> from cleopatra.basemap.ogc import WMTSProvider
    >>> provider = WMTSProvider(
    ...     url="https://example.org/{tilematrix}/{tilerow}/{tilecol}.png",
    ...     layer="L",
    ... )
    >>> provider.is_restful
    True
    >>> provider.build_url(x=4, y=2, z=3)
    'https://example.org/3/2/4.png'
    
  • An endpoint that merely carries a query is still KVP:
    >>> from cleopatra.basemap.ogc import WMTSProvider
    >>> WMTSProvider(url="https://example.org/wmts?layer=x", layer="L").is_restful
    False
    
  • A template whose placeholders are all unowned is refused outright, since the braces would be sent literally:
    >>> from cleopatra.basemap.ogc import WMTSProvider
    >>> WMTSProvider(url="https://example.org/wmts/{Time}.png", layer="L")
    Traceback (most recent call last):
        ...
    ValueError: url carries placeholders this module does not fill ...
    
  • An unowned name alongside the required three is fine, and does not itself make the template RESTful:
    >>> from cleopatra.basemap.ogc import WMTSProvider
    >>> template = "https://e.org/{Time}/{TileMatrix}/{TileRow}/{TileCol}.png"
    >>> WMTSProvider(url=template, layer="L").is_restful
    True
    
__hash__() #

Hash the service description, flattening extra_params.

frozen=True would generate a __hash__ over the field tuple, which the extra_params mapping makes unhashable; _hash_provider hashes that mapping's sorted items instead, staying consistent with the generated __eq__.

Returns:

Name Type Description
int int

A hash consistent with this dataclass's own equality.

Source code in src/cleopatra/basemap/ogc.py
def __hash__(self) -> int:
    """Hash the service description, flattening `extra_params`.

    `frozen=True` would generate a `__hash__` over the field tuple, which
    the `extra_params` mapping makes unhashable; `_hash_provider` hashes
    that mapping's sorted items instead, staying consistent with the
    generated `__eq__`.

    Returns:
        int: A hash consistent with this dataclass's own equality.
    """
    return _hash_provider(self)
__post_init__() #

Validate the service description and freeze extra_params.

Runs on a dataclasses.replace copy as well as on a direct construction, so a copy is checked as thoroughly as the original. The version is matched against the one published WMTS version rather than merely required to be non-empty, so the two providers refuse the same class of mistake; attribution may be empty but is still required to be a string, since a non-string one would reach the axes as its repr; and a template carrying placeholders is required to carry the three that identify a tile -- or, if it carries none this module owns at all, is refused outright rather than read as a KVP endpoint whose braces go on the wire.

extra_params is frozen before it is validated, not after: the protected and duplicate checks both fold keys with upper(), which an int key -- exactly the type _freeze_params is there to coerce -- does not have.

Returns:

Type Description
None

None

Raises:

Type Description
ValueError

If url is not a non-empty http(s) string or contains whitespace anywhere; if layer, tile_matrix_set, style, image_format or version is not a non-empty string; if version is anything but 1.0.0; if attribution is not a string (empty is allowed); if url carries RESTful placeholders without all of {TileMatrix}, {TileRow} and {TileCol}; if url carries placeholders of which none are this module's; if extra_params names a tile-addressing parameter such as TILEROW or REQUEST, however cased; if two of its keys differ only by case; or if two of its keys coerce to the same name.

TypeError

If extra_params is not a mapping.

Source code in src/cleopatra/basemap/ogc.py
def __post_init__(self) -> None:
    """Validate the service description and freeze `extra_params`.

    Runs on a `dataclasses.replace` copy as well as on a direct
    construction, so a copy is checked as thoroughly as the original. The
    version is matched against the one published WMTS version rather than
    merely required to be non-empty, so the two providers refuse the same
    class of mistake; `attribution` may be empty but is still required to
    be a string, since a non-string one would reach the axes as its `repr`;
    and a template carrying placeholders is required to carry the three
    that identify a tile -- or, if it carries none this module owns at all,
    is refused outright rather than read as a KVP endpoint whose braces go
    on the wire.

    `extra_params` is frozen before it is validated, not after: the
    protected and duplicate checks both fold keys with `upper()`, which an
    `int` key -- exactly the type `_freeze_params` is there to coerce --
    does not have.

    Returns:
        None

    Raises:
        ValueError: If `url` is not a non-empty http(s) string or contains
            whitespace anywhere; if `layer`, `tile_matrix_set`, `style`,
            `image_format` or `version` is not a non-empty string; if
            `version` is anything but `1.0.0`; if `attribution` is not a
            string (empty is allowed); if `url` carries RESTful
            placeholders without all of `{TileMatrix}`, `{TileRow}` and
            `{TileCol}`; if `url` carries placeholders of which *none* are
            this module's; if `extra_params` names a tile-addressing
            parameter such as `TILEROW` or `REQUEST`, however cased; if two
            of its keys differ only by case; or if two of its keys coerce
            to the same name.
        TypeError: If `extra_params` is not a mapping.
    """
    _validate_endpoint(self.url, "url")
    _validate_identifier(self.layer, "layer")
    _validate_identifier(self.tile_matrix_set, "tile_matrix_set")
    _validate_identifier(self.style, "style")
    _validate_identifier(self.image_format, "image_format")
    _validate_identifier(self.version, "version")
    if self.version not in _WMTS_VERSIONS:
        listed = ", ".join(repr(v) for v in _WMTS_VERSIONS)
        raise ValueError(f"version must be one of {listed}, got {self.version!r}.")
    _validate_text(self.style, "style")
    _validate_text(self.attribution, "attribution")
    present = _restful_placeholders(self.url)
    all_placeholders = {
        match.group(1).lower() for match in _PLACEHOLDER_RE.finditer(self.url)
    }
    if all_placeholders and not present:
        listed = ", ".join(sorted(f"{{{name}}}" for name in all_placeholders))
        raise ValueError(
            f"url carries placeholders this module does not fill -- {listed} -- "
            f"and none it does. A RESTful WMTS template uses {{TileMatrix}}, "
            f"{{TileRow}} and {{TileCol}}; an XYZ {{z}}/{{x}}/{{y}} template "
            f"belongs in add_tiles(source=...) as an xyzservices provider, not "
            f"here. As written the braces would be sent literally."
        )
    if present:
        missing = [f for f in _REQUIRED_RESTFUL_FIELDS if f not in present]
        if missing:
            listed = ", ".join(f"{{{f}}}" for f in missing)
            raise ValueError(
                f"a RESTful WMTS template must address a tile: url is "
                f"missing {listed}. Without them every tile resolves to "
                f"the same URL, so the mosaic would repeat one image."
            )
    _validate_extra_params(_freeze_params(self.extra_params))
    object.__setattr__(self, "extra_params", _freeze_params(self.extra_params))
__reduce__() #

Rebuild through the constructor; see _reduce_provider.

Returns:

Name Type Description
tuple tuple

The (callable, args) pair pickle and copy use.

Source code in src/cleopatra/basemap/ogc.py
def __reduce__(self) -> tuple:
    """Rebuild through the constructor; see `_reduce_provider`.

    Returns:
        tuple: The `(callable, args)` pair `pickle` and `copy` use.
    """
    return _reduce_provider(self)
__repr__() #

Render without the credential; see _repr_provider.

Returns:

Name Type Description
str str

A repr with every extra_params value masked.

Source code in src/cleopatra/basemap/ogc.py
def __repr__(self) -> str:
    """Render without the credential; see `_repr_provider`.

    Returns:
        str: A `repr` with every `extra_params` value masked.
    """
    return _repr_provider(self)
build_url(*, x, y, z) #

Return the GetTile URL for one tile.

The WMTS tile triple is the slippy triple under another name: z is TileMatrix, y is TileRow, x is TileCol.

A RESTful url (see is_restful) has its placeholders filled in, in one left-to-right pass over the whole URL -- query included, not just the path. Four things follow from that. Names are matched case-insensitively, so whatever spelling the service publishes works. Each value is percent-encoded, so a layer or style name carrying /, ? or a space cannot invent a path segment or start a query. The pass never re-scans what it has already written, so a field whose value itself looks like a placeholder stays data instead of being rewritten by a later field. And a placeholder this module does not own -- a service's own {Time} -- is left exactly as it stands, for the caller to see rather than silently mangled. extra_params is then appended as a query, with an & if the filled-in template already carries one.

Otherwise a GetTile KVP query is appended to url, keeping any query it already carries, and extra_params comes last so it can override a generated parameter -- matched case-insensitively -- as well as add one.

Parameters:

Name Type Description Default
x int

Tile column, i.e. TileCol.

required
y int

Tile row, i.e. TileRow.

required
z int

Zoom level, i.e. TileMatrix.

required

Returns:

Name Type Description
str str

The full request URL.

Examples:

  • A KVP endpoint keeps the query it already carries and gains the GetTile parameters:
    >>> from urllib.parse import parse_qs, urlsplit
    >>> from cleopatra.basemap.ogc import WMTSProvider
    >>> provider = WMTSProvider(
    ...     url="https://example.org/wmts?map=/etc/base.map",
    ...     layer="TrueColor",
    ... )
    >>> query = parse_qs(urlsplit(provider.build_url(x=1, y=1, z=1)).query)
    >>> query["map"]
    ['/etc/base.map']
    >>> query["SERVICE"], query["TILEMATRIXSET"]
    (['WMTS'], ['GoogleMapsCompatible'])
    
  • A template is filled in as far as its placeholders go, and extra_params follows it as a query:
    >>> from cleopatra.basemap.ogc import WMTSProvider
    >>> provider = WMTSProvider(
    ...     url="https://example.org/wmts/{TileMatrix}/{TileRow}/{TileCol}.png",
    ...     layer="TrueColor",
    ...     extra_params={"token": "abc"},
    ... )
    >>> provider.build_url(x=4, y=2, z=3)
    'https://example.org/wmts/3/2/4.png?token=abc'
    
  • extra_params wins over a parameter the provider generates, so a service demanding its own spelling needs no code change:
    >>> from urllib.parse import parse_qs, urlsplit
    >>> from cleopatra.basemap.ogc import WMTSProvider
    >>> provider = WMTSProvider(
    ...     url="https://example.org/wmts",
    ...     layer="TrueColor",
    ...     extra_params={"FORMAT": "image/jpeg"},
    ... )
    >>> parse_qs(urlsplit(provider.build_url(x=0, y=0, z=0)).query)["FORMAT"]
    ['image/jpeg']
    
  • Casing does not matter and every substituted value is percent-encoded, so a layer name cannot break out of its segment:
    >>> from cleopatra.basemap.ogc import WMTSProvider
    >>> provider = WMTSProvider(
    ...     url="https://example.org/w/{layer}/{tilematrix}/{tilerow}/{tilecol}.png",
    ...     layer="a b/c",
    ... )
    >>> provider.build_url(x=4, y=2, z=3)
    'https://example.org/w/a%20b%2Fc/3/2/4.png'
    
  • A placeholder this module does not own survives untouched, and a value that merely looks like one is not substituted a second time:
    >>> from cleopatra.basemap.ogc import WMTSProvider
    >>> provider = WMTSProvider(
    ...     url="https://example.org/{Custom}/{Layer}/{TileMatrix}/{TileRow}/{TileCol}.png",
    ...     layer="{TileRow}",
    ... )
    >>> provider.build_url(x=4, y=2, z=3)
    'https://example.org/{Custom}/%7BTileRow%7D/3/2/4.png'
    
Source code in src/cleopatra/basemap/ogc.py
def build_url(self, *, x: int, y: int, z: int) -> str:
    """Return the `GetTile` URL for one tile.

    The WMTS tile triple is the slippy triple under another name:
    `z` is `TileMatrix`, `y` is `TileRow`, `x` is `TileCol`.

    A RESTful `url` (see `is_restful`) has its placeholders filled in, in
    one left-to-right pass over the whole URL -- query included, not just
    the path. Four things follow from that. Names are matched
    case-insensitively, so whatever spelling the service publishes works.
    Each value is percent-encoded, so a layer or style name carrying `/`,
    `?` or a space cannot invent a path segment or start a query. The pass
    never re-scans what it has already written, so a field whose value
    itself looks like a placeholder stays data instead of being rewritten
    by a later field. And a placeholder this module does not own -- a
    service's own `{Time}` -- is left exactly as it stands, for the caller
    to see rather than silently mangled. `extra_params` is then appended as
    a query, with an `&` if the filled-in template already carries one.

    Otherwise a `GetTile` KVP query is appended to `url`, keeping any query
    it already carries, and `extra_params` comes last so it can override a
    generated parameter -- matched case-insensitively -- as well as add
    one.

    Args:
        x: Tile column, i.e. `TileCol`.
        y: Tile row, i.e. `TileRow`.
        z: Zoom level, i.e. `TileMatrix`.

    Returns:
        str: The full request URL.

    Examples:
        - A KVP endpoint keeps the query it already carries and gains the
          `GetTile` parameters:
            ```python
            >>> from urllib.parse import parse_qs, urlsplit
            >>> from cleopatra.basemap.ogc import WMTSProvider
            >>> provider = WMTSProvider(
            ...     url="https://example.org/wmts?map=/etc/base.map",
            ...     layer="TrueColor",
            ... )
            >>> query = parse_qs(urlsplit(provider.build_url(x=1, y=1, z=1)).query)
            >>> query["map"]
            ['/etc/base.map']
            >>> query["SERVICE"], query["TILEMATRIXSET"]
            (['WMTS'], ['GoogleMapsCompatible'])

            ```
        - A template is filled in as far as its placeholders go, and
          `extra_params` follows it as a query:
            ```python
            >>> from cleopatra.basemap.ogc import WMTSProvider
            >>> provider = WMTSProvider(
            ...     url="https://example.org/wmts/{TileMatrix}/{TileRow}/{TileCol}.png",
            ...     layer="TrueColor",
            ...     extra_params={"token": "abc"},
            ... )
            >>> provider.build_url(x=4, y=2, z=3)
            'https://example.org/wmts/3/2/4.png?token=abc'

            ```
        - `extra_params` wins over a parameter the provider generates, so a
          service demanding its own spelling needs no code change:
            ```python
            >>> from urllib.parse import parse_qs, urlsplit
            >>> from cleopatra.basemap.ogc import WMTSProvider
            >>> provider = WMTSProvider(
            ...     url="https://example.org/wmts",
            ...     layer="TrueColor",
            ...     extra_params={"FORMAT": "image/jpeg"},
            ... )
            >>> parse_qs(urlsplit(provider.build_url(x=0, y=0, z=0)).query)["FORMAT"]
            ['image/jpeg']

            ```
        - Casing does not matter and every substituted value is
          percent-encoded, so a layer name cannot break out of its segment:
            ```python
            >>> from cleopatra.basemap.ogc import WMTSProvider
            >>> provider = WMTSProvider(
            ...     url="https://example.org/w/{layer}/{tilematrix}/{tilerow}/{tilecol}.png",
            ...     layer="a b/c",
            ... )
            >>> provider.build_url(x=4, y=2, z=3)
            'https://example.org/w/a%20b%2Fc/3/2/4.png'

            ```
        - A placeholder this module does not own survives untouched, and a
          value that merely looks like one is not substituted a second
          time:
            ```python
            >>> from cleopatra.basemap.ogc import WMTSProvider
            >>> provider = WMTSProvider(
            ...     url="https://example.org/{Custom}/{Layer}/{TileMatrix}/{TileRow}/{TileCol}.png",
            ...     layer="{TileRow}",
            ... )
            >>> provider.build_url(x=4, y=2, z=3)
            'https://example.org/{Custom}/%7BTileRow%7D/3/2/4.png'

            ```
    """
    if self.is_restful:
        values = {
            "tilematrixset": self.tile_matrix_set,
            "tilematrix": str(z),
            "tilerow": str(y),
            "tilecol": str(x),
            "layer": self.layer,
            "style": self.style,
        }

        def substitute(match: re.Match[str]) -> str:
            """Replace one placeholder, leaving an unknown one alone."""
            name = match.group(1).lower()
            if name not in values:
                return match.group(0)
            return urllib.parse.quote(values[name], safe="")

        # One pass, so a value that happens to look like a placeholder is
        # not rewritten again by a later field.
        filled = _PLACEHOLDER_RE.sub(substitute, self.url)
        return _query(filled, self.extra_params)

    params = {
        "SERVICE": "WMTS",
        "REQUEST": "GetTile",
        "VERSION": self.version,
        "LAYER": self.layer,
        "STYLE": self.style,
        "TILEMATRIXSET": self.tile_matrix_set,
        "TILEMATRIX": str(z),
        "TILEROW": str(y),
        "TILECOL": str(x),
        "FORMAT": self.image_format,
    }
    return _query(self.url, _merge_params(params, self.extra_params))