Files
MeshTerm/scripts/basemap-doctor.py
T
Jean-Pierre MartineauandClaude Opus 5 839da9ab8f A doctor for the one failure the map has no words for
The map is the only networked part of MeshTerm, and every failure on that path
is logged at DEBUG while log_level defaults to WARNING: a reader whose tiles
never arrive gets a blank ground and no explanation anywhere. On macOS that is
routinely a Python with no certificate store at all, which no amount of looking
at the app can reveal.

scripts/basemap-doctor.py is the explanation. It reports the interpreter, the
trust store, the tile source and the cached-tile count, then fetches the
TileJSON and one real tile the way BasemapSource does, and turns the failure
into one verdict with its fix.

The verdict is an A/B rather than a reading: curl verifies against the OS trust
store and Python against its own, so curl succeeding where Python fails is the
app's store and nothing else, while both refusing the same certificate is TLS
interception or a genuinely bad server — one exception, opposite causes,
opposite fixes. Collapsing curl's exit codes into "failed" had it blaming the
store and the network in the same breath.

It also answers the other way a map looks wrong, since it costs nothing in the
same round trip: an unset COLORTERM is 256 colours, where the basemap's greens
and blues collide.

Started under the wrong Python it finds the installed meshterm command, reads
its shebang and hands itself over, so nobody has to know which of their three
Pythons the app went into. By subprocess, not execve, which on Windows is not a
real exec and segfaults under a redirect — and stdout is flushed first, or the
banner loses the report it introduces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 11:05:07 -04:00

428 lines
18 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""Diagnose why MeshTerm's map has no basemap, on the machine that has the problem.
The map is the only networked part of MeshTerm: it fetches OpenStreetMap vector tiles over
HTTPS from the source named by the ``basemap_tilejson_url`` preference. Every failure on
that path — a missing certificate store, DNS, a timeout, a middlebox — is swallowed and
logged at DEBUG (:mod:`meshterm.services.basemap`), and ``log_level`` defaults to WARNING,
so a reader whose tiles never arrive sees a blank ground and no explanation anywhere.
This script is that explanation. It is standalone and stdlib-only so it can be pasted onto
any machine running any version of MeshTerm, and it prints one verdict with the fix.
It runs under MeshTerm's *own* interpreter, which is the one thing that matters here: a Mac
routinely has three Pythons, only one of them is the app's, and only that one's certificate
store is the one the map actually uses. Started under any other, it finds the installed
``meshterm`` command, reads the interpreter out of its shebang and hands itself over — so
the user needs no idea which Python they installed into. Save it to a file rather than
piping it into ``python3 -``: with no file on disk there is nothing to hand over.
The decisive test is an A/B. ``curl`` verifies against the operating system's trust store
and Python verifies against its own, so curl succeeding where Python fails means the
certificates are at fault and nothing about the network is — the one comparison that
separates the two causes that look identical from inside the app.
"""
from __future__ import annotations
import importlib.util
import json
import os
import platform
import shutil
import socket
import ssl
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path
from typing import NamedTuple
#: Where tiles come from unless the preference says otherwise. Kept in step with
#: ``basemap.DEFAULT_TILEJSON_URL`` by hand: this script may run against an install whose
#: ``meshterm`` it cannot import, so it cannot read the constant it is mirroring.
FALLBACK_TILEJSON_URL = "https://tiles.openfreemap.org/planet"
#: Per-request timeout, matching ``BasemapSource``'s own so that a link which is merely
#: slow fails here for the same reason it fails in the app rather than for a stricter one.
TIMEOUT = 12.0
#: What the app sends, so a source that filters by agent treats this run the same way.
USER_AGENT = "MeshTerm-basemap-doctor (+https://github.com/jpmartineau/MeshTerm)"
#: A tile that exists: zoom 9 over central Poland. The TileJSON resolving is only half the
#: path — the tiles themselves are often a different host — so the check fetches real
#: geometry before it reports success.
SAMPLE_TILE = (9, 285, 167)
def heading(text: str) -> None:
"""Print a section heading."""
print(f"\n=== {text} ===")
def line(label: str, value: object) -> None:
"""Print one aligned ``label: value`` fact."""
print(f" {label:<20} {value}")
def find_meshterm() -> tuple[str | None, str | None, str | None]:
"""Report MeshTerm's version, tile URL and config dir, if this interpreter has them.
Returns:
``(version, tilejson_url, config_dir)``, each ``None`` where it could not be read.
"""
try:
import meshterm
from meshterm.core.config import default_config_dir
from meshterm.core.preferences import Preferences
except Exception: # noqa: BLE001 - any import failure means "not this interpreter"
return None, None, None
config_dir = default_config_dir()
url = None
try:
url = Preferences.load(config_dir / "preferences.toml").basemap_tilejson_url
except Exception: # noqa: BLE001 - an unreadable preferences file is itself a finding
pass
return getattr(meshterm, "__version__", "?"), url, str(config_dir)
#: Set on the re-executed child, so a second interpreter that still cannot import MeshTerm
#: gets on with the diagnosis instead of handing itself on forever.
REEXEC_ENV = "MESHTERM_DOCTOR_REEXEC"
def meshterm_interpreter() -> str | None:
"""The interpreter the installed ``meshterm`` command runs under, if it can be read.
A console script on Unix is a text file whose shebang names the exact interpreter the
app was installed into — and that interpreter's certificate store is the one the map
actually uses, which no other Python on the machine can speak for. On Windows the
console script is a binary launcher with no shebang, so this finds nothing and the
diagnosis proceeds with a banner saying which interpreter it did use.
"""
script = shutil.which("meshterm")
if script is None:
return None
try:
with open(script, "rb") as fh:
first = fh.readline(512)
except OSError:
return None
if not first.startswith(b"#!"):
return None
# A shebang may carry arguments (``#!/usr/bin/env python3``); the interpreter is the
# first word, and an ``env`` line names no absolute path worth re-execing.
interpreter = first[2:].decode("utf-8", "replace").strip().split(" ")[0]
return interpreter if interpreter and Path(interpreter).is_file() else None
def reexec_under_meshterm() -> None:
"""Hand this script to MeshTerm's own interpreter when it is not the one running it.
The user should not have to know which of their three Pythons the app was installed
into — that detail is exactly what the failure is made of.
"""
if os.environ.get(REEXEC_ENV):
return
try:
if importlib.util.find_spec("meshterm") is not None:
return
except (ImportError, ValueError): # a broken or shadowed package: keep looking
pass
target = meshterm_interpreter()
here = Path(globals().get("__file__", "")).resolve() if globals().get("__file__") else None
if target is None or here is None or not here.is_file():
return
if Path(target).resolve() == Path(sys.executable).resolve():
return
print(f" (handing over to MeshTerm's own interpreter: {target})")
# The child inherits this stdout, so the parent's buffer has to go out first or the
# banner lands after the report it introduces. Block buffering makes that certain the
# moment the output is piped into a file or a paste, which is how it is meant to
# travel. (``os.execve`` would avoid the extra process, but on Windows it is not a
# real exec: it spawns and exits, and segfaults outright under a redirect.)
sys.stdout.flush()
child = subprocess.run([target, str(here), *sys.argv[1:]], env={**os.environ, REEXEC_ENV: "1"})
sys.exit(child.returncode)
def exists_marker(path: str) -> str:
"""Return a marker saying whether ``path`` is actually on disk."""
return "" if Path(path).exists() else " <- MISSING"
def describe_env() -> tuple[str | None, str | None]:
"""Print interpreter, platform and MeshTerm facts; return the tile URL and config dir."""
heading("This machine")
line("Python", sys.version.split()[0])
line("Interpreter", sys.executable)
line("OS", f"{platform.system()} {platform.release()} ({platform.machine()})")
line("TERM", os.environ.get("TERM", "(unset)"))
# Not a tile question, but it is the other thing that makes a map look wrong and it
# costs nothing to answer in the same round trip: no COLORTERM means 256 colours, and
# the basemap's greens and blues are what collide when they are quantized.
# Windows is exempt: prompt_toolkit's Windows output reports 24-bit outright, so an
# unset COLORTERM there says nothing. Everywhere else it is the whole of the evidence.
unset = "(unset)" if os.name == "nt" else "(unset) <- only 256 colours"
line("COLORTERM", os.environ.get("COLORTERM") or unset)
version, url, config_dir = find_meshterm()
if version is None:
line("MeshTerm", "NOT importable here <- probably the wrong interpreter")
else:
line("MeshTerm", version)
line("Config dir", config_dir)
return url, config_dir
def describe_trust() -> None:
"""Print the certificate store this Python would verify a tile server against."""
heading("Certificate store")
line("OpenSSL", ssl.OPENSSL_VERSION)
paths = ssl.get_default_verify_paths()
for label, value in (("CA file", paths.cafile), ("CA path", paths.capath)):
line(label, f"{value}{exists_marker(value)}" if value else "(none)")
try:
import certifi
line("certifi", certifi.where())
except ImportError:
line("certifi", "not installed")
for var in (
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"REQUESTS_CA_BUNDLE",
"HTTPS_PROXY",
"https_proxy",
"ALL_PROXY",
):
if os.environ.get(var):
line(var, os.environ[var])
loaded = len(ssl.create_default_context().get_ca_certs())
line("Certs loaded", loaded if loaded else "0 <- nothing to verify against")
#: ``curl`` exit codes that mean TLS, as opposed to never getting that far. The whole
#: value of asking curl is that it verifies against the *operating system's* trust store,
#: so "curl got a certificate it disliked" and "curl could not reach the host" are
#: opposite answers and must not be collapsed into "curl failed".
CURL_TLS_CODES = frozenset({35, 51, 58, 59, 60, 66, 77, 83, 90, 91})
class Curl(NamedTuple):
"""What the system ``curl`` made of the same URL.
Attributes:
verdict: ``ok`` (the server answered, whatever its status), ``tls`` (a certificate
or handshake failure), ``unreachable`` (never got that far), or ``absent``
(no ``curl`` on this machine to ask).
detail: The status or error, for the transcript.
"""
verdict: str
detail: str
def curl_says(url: str) -> Curl:
"""Fetch ``url`` with the system ``curl``, which verifies against the OS trust store."""
curl = shutil.which("curl")
if curl is None:
return Curl("absent", "no curl on this machine to compare against")
argv = [
curl,
"-sS",
"-o",
os.devnull,
"-w",
"%{http_code}",
"--max-time",
str(int(TIMEOUT)),
url,
]
try:
proc = subprocess.run(argv, capture_output=True, text=True, timeout=TIMEOUT + 5)
except (subprocess.TimeoutExpired, OSError):
return Curl("unreachable", "timed out")
if proc.returncode == 0:
# Any HTTP status at all means the transport worked, which is the only thing this
# comparison is asking about - a 301 or a 404 still proves TLS and the route.
return Curl("ok", f"HTTP {proc.stdout.strip()}")
kind = "tls" if proc.returncode in CURL_TLS_CODES else "unreachable"
return Curl(kind, f"exit {proc.returncode}: {proc.stderr.strip() or 'no detail'}")
def fetch(url: str) -> tuple[bool, object]:
"""GET ``url`` exactly the way the app does. Returns ``(ok, body-or-exception)``."""
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
return True, resp.read()
except Exception as exc: # noqa: BLE001 - classifying the failure is the whole job
return False, exc
#: What to do about a certificate store this Python cannot verify with. The macOS case is
#: first because it is the one that reaches a user who did nothing wrong: a python.org
#: install ships with an empty store until its own installer command is run.
CERT_FIX = (
"Give this Python a certificate store. On macOS a python.org install ships\n"
" without one until you run its own command:\n"
" open /Applications/Python*/Install\\ Certificates.command\n"
" Or point it at certifi's, in the environment MeshTerm runs in:\n"
" pip install certifi\n"
' export SSL_CERT_FILE="$(python3 -m certifi)" # then relaunch MeshTerm'
)
#: What to do when TLS is being terminated in the middle rather than at the tile server.
INTERCEPT_FIX = (
"Trust the interceptor's root certificate in this Python (SSL_CERT_FILE can\n"
" point at a bundle that includes it), or exempt the tile host from HTTPS\n"
" inspection. MeshTerm has no way to accept a certificate it cannot verify."
)
def classify(exc: BaseException) -> tuple[str, str, str]:
"""Turn a fetch exception into ``(kind, what happened, what to do about it)``.
The *kind* is what the verdict reasons with: a certificate failure and a timeout point
at opposite halves of the world, and the ``curl`` comparison means something different
for each.
"""
reason = getattr(exc, "reason", exc)
if isinstance(exc, urllib.error.HTTPError):
return (
"http",
f"the server answered HTTP {exc.code}",
"The network is fine. A 4xx means the tile URL is wrong (check the "
"'Map tiles' preference); a 5xx is the tile server itself.",
)
if isinstance(reason, ssl.SSLCertVerificationError):
return ("cert", "this Python could not verify the tile server's certificate", CERT_FIX)
if isinstance(reason, ssl.SSLError):
return ("tls", "the TLS handshake failed", INTERCEPT_FIX)
if isinstance(reason, socket.gaierror):
return (
"dns",
"the tile host does not resolve",
"DNS. Check the machine is online, and that no DNS filter or ad-blocker "
"is swallowing the tile host.",
)
if isinstance(reason, TimeoutError):
return (
"timeout",
"the connection timed out",
"The host is unreachable, or something is dropping the traffic silently: "
"a firewall, or a link too slow for a 12-second timeout.",
)
if isinstance(reason, ConnectionResetError):
return (
"reset",
"the connection was reset",
"Something in the path is cutting the connection - a firewall or a "
"middlebox rather than the tile server.",
)
return ("other", f"of {type(reason).__name__}: {reason}", "")
def verdict(kind: str, cause: str, fix: str, curl: Curl) -> None:
"""Print one coherent story from what Python saw and what ``curl`` saw.
The two readings have to be reconciled rather than printed side by side: a certificate
Python rejects *and curl accepts* is MeshTerm's own trust store, while one they both
reject is the network re-signing traffic or a genuinely bad server - the same Python
exception, opposite causes, opposite fixes.
"""
print(f" The map cannot fetch tiles because {cause}.")
if kind in ("cert", "tls"):
if curl.verdict == "ok":
print(" The system curl reached that very URL, verifying against the OS trust")
print(" store, so the network, the ISP and the tile server are all innocent:")
print(" the fault is this Python's own certificate store.")
print(f"\n Fix: {CERT_FIX}")
return
if curl.verdict == "tls":
print(" The system curl refuses the same certificate, so this is not just")
print(" MeshTerm's trust store: either something on this network is")
print(" intercepting TLS and re-signing it, or the certificate really is bad.")
print(f"\n Fix: {INTERCEPT_FIX}")
return
if curl.verdict == "unreachable":
print(" The system curl could not reach the host at all, so start with the")
print(" network - a firewall, a proxy or a DNS filter - before the certificates.")
return
elif curl.verdict == "ok":
print(" The system curl reached that very URL, so the route exists and this")
print(" Python is going somewhere else - check the proxy variables above.")
elif curl.verdict in ("tls", "unreachable"):
print(" The system curl could not reach it either, so this is the network path")
print(" rather than MeshTerm - a firewall, a proxy, a DNS filter or an outage.")
if fix:
print(f"\n Fix: {fix}")
def count_cached(config_dir: str | None) -> None:
"""Print how many tiles are already on disk, which the map can draw with no network."""
if not config_dir:
return
tiles = Path(config_dir) / "tilecache" / "tiles"
if not tiles.exists():
line("Tile cache", "empty - no tile has ever arrived")
return
line("Tile cache", f"{sum(1 for _ in tiles.rglob('*.pbf'))} tiles at {tiles.parent}")
def main() -> int:
"""Run every check and print one verdict. Returns a process exit status."""
print("MeshTerm basemap doctor")
reexec_under_meshterm()
url, config_dir = describe_env()
tilejson_url = url or FALLBACK_TILEJSON_URL
describe_trust()
heading("Reaching the tile source")
line("Tile source", tilejson_url + ("" if url else " (default)"))
count_cached(config_dir)
curl = curl_says(tilejson_url)
line("System curl", curl.detail)
ok, result = fetch(tilejson_url)
line("This Python", "OK" if ok else f"FAILED - {type(result).__name__}")
tile_ok = False
if ok:
try:
template = json.loads(result)["tiles"][0]
z, x, y = SAMPLE_TILE
tile_url = template.replace("{z}", str(z)).replace("{x}", str(x)).replace("{y}", str(y))
tile_ok, tile_result = fetch(tile_url)
line("One real tile", f"OK, {len(tile_result)} bytes" if tile_ok else "FAILED")
if not tile_ok:
result = tile_result
except (ValueError, KeyError, IndexError) as exc:
line("TileJSON", f"unreadable: {exc}")
heading("Verdict")
if ok and tile_ok:
print(" Tiles reach this machine, so the basemap is NOT a network problem.")
print(" If the map still looks wrong it is the terminal: COLORTERM unset means")
print(" 256 colours, and the basemap's greens and blues collide once they are")
print(" quantized. Apple's Terminal.app cannot do 24-bit colour at all - try")
print(" iTerm2, Ghostty or kitty.")
return 0
failure = result if isinstance(result, BaseException) else RuntimeError("no answer")
verdict(*classify(failure), curl)
prefs = Path(config_dir) / "preferences.toml" if config_dir else Path("~/.meshterm")
print("\n Meanwhile the map still works - it plots nodes without a basemap.")
print(" Per-request detail goes to the log if you set log_level = DEBUG in")
print(f" {prefs} and reopen the map.")
return 1
if __name__ == "__main__":
sys.exit(main())