Source code for georest.esri.cli.check_themes
"""Report drift between the live EDW catalog and edw._SERVICE_THEMES.
_SERVICE_THEMES is hand-curated and powers theme filtering plus description
matching in search_edw_services. EDW publishes and retires services without
notice, so the table rots quietly: an untyped service still turns up in
search results, but as theme "uncategorized" with an empty description, so
theme filters and keyword aliases stop reaching it.
georest-check-themes # report drift
georest-check-themes --emit # + paste-ready entries
Exit status is 0 when the table is in sync, 1 when it has drifted, and 2 if
the catalog could not be reached.
Copyright 2026 Ryan Rock and Ian Housman
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
"""
from __future__ import annotations
import argparse
import sys
import time
from .._http import fetch_json
from ..edw import _SERVICE_THEMES, EDW_BASE_URL, search_edw_services, theme_drift
#: Longest a generated description may run before being elided, matching the
#: existing entries in _SERVICE_THEMES.
DESC_LIMIT = 150
[docs]
def retry(func, tries=6, delay=3.0):
"""EDW answers 404/500/'Layer not found' intermittently; retry through it."""
last = None
for attempt in range(tries):
try:
return func()
except Exception as exc: # noqa: BLE001 - reported, not swallowed
last = exc
if attempt < tries - 1:
print(f" attempt {attempt + 1} failed ({str(exc)[:60]}); retrying",
file=sys.stderr)
time.sleep(delay)
raise SystemExit(f"could not reach EDW after {tries} attempts: {last}")
[docs]
def describe(service_name: str) -> str:
"""Best-effort one-line description from a service's own MapServer doc."""
try:
data = fetch_json(f"{EDW_BASE_URL}/{service_name}/MapServer",
{"f": "pjson"})
except Exception: # noqa: BLE001 - a missing description is not fatal
return ""
text = " ".join(
((data.get("description") or "").strip()
or (data.get("serviceDescription") or "").strip()).split()
)
if len(text) > DESC_LIMIT:
text = text[:DESC_LIMIT].rstrip() + "…"
return text
#: `--help` text. Split off the license header, which is part of the module
#: docstring for consistency with the rest of the package but has no business
#: in a CLI's help output. RawDescriptionHelpFormatter keeps the indented
#: usage examples intact; the default formatter reflows them into one run-on
#: paragraph. tests/test_packaging.py guards both.
_HELP = __doc__.split("\nCopyright ")[0].rstrip()
[docs]
def main() -> int:
parser = argparse.ArgumentParser(
description=_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--emit", action="store_true",
help="print paste-ready _SERVICE_THEMES entries for "
"services that lack one")
args = parser.parse_args()
print(f"fetching the EDW catalog from {EDW_BASE_URL} ...")
try:
services = retry(search_edw_services)
except SystemExit as exc:
# `retry` gives up by raising SystemExit with a string message. Handle
# it here rather than under `if __name__ == "__main__"`: the generated
# console-script wrapper calls `sys.exit(main())` directly and would
# bypass a module-level handler, so `georest-check-themes` and
# `python -m` would disagree about the exit code.
if isinstance(exc.code, str):
print(exc.code, file=sys.stderr)
return 2
raise
drift = theme_drift(services)
mapservers = [s for s in services if s.get("type") == "MapServer"]
print(f"\n live catalog {len(services)} services "
f"({len(mapservers)} MapServer)")
print(f" _SERVICE_THEMES {len(_SERVICE_THEMES)} entries")
for label, heading in (
("untyped", "MapServer services with NO theme entry "
"(searchable, but uncategorized and undescribed)"),
("orphaned", "theme entries for services no longer in the catalog"),
("bad_theme", "entries whose theme is not a known theme"),
("missing_desc", "entries with an empty description"),
("non_mapserver", "non-MapServer services (informational - these "
"carry no layers and need no theme)"),
):
names = drift[label]
print(f"\n {heading}: {len(names)}")
for name in names:
print(f" {name}")
if args.emit and drift["untyped"]:
print("\n" + "=" * 70)
print("Paste into _SERVICE_THEMES in src/georest/esri/edw.py, under")
print("the right theme heading, replacing THEME with the category:")
print("=" * 70)
for name in drift["untyped"]:
desc = describe(name).replace('"', "'")
print(f' "{name}": {{"theme": "THEME", "desc": "{desc}"}},')
# non_mapserver is informational and deliberately excluded here.
drifted = any(drift[key] for key in
("untyped", "orphaned", "bad_theme", "missing_desc"))
if drifted:
print("\nRESULT: the theme table has drifted from the live catalog.")
if not args.emit and drift["untyped"]:
print("Re-run with --emit for paste-ready entries.")
return 1
print("\nRESULT: theme table is in sync with the live catalog.")
return 0
if __name__ == "__main__":
sys.exit(main())