tz_convert
Time zones from the IANA database: convert a date-time from one zone to several, show a zone's UTC offset, abbreviation and DST state at a moment, or list a year's daylight-saving transitions. Local times that fall in a DST gap or happen twice are flagged, not silently shifted. Reports the tzdata version used.
Install
/tools install tz_convert /tools install tz_convert@1.0.0
In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · tz_convert 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool tz_convert; each run is approved as a local_exec.
Arguments
One JSON object on stdin, validated against the schema before the program starts.
| name | type | required | default | meaning |
|---|---|---|---|---|
operation | string | no | "convert" | one of "convert", "offset", "transitions" |
datetime | string | no | ISO 8601; local to from_zone or zone unless it carries an offset or Z | |
from_zone | string | no | IANA name, such as Europe/Madrid | |
to_zones | array of string | no | ||
zone | string | no | ||
year | integer | no | ≥ 1900; ≤ 2100 | |
fold | integer | no | 0 | For a repeated local time: 0 the first, 1 the second. one of 0, 1 |
Input schema (JSON)
{
"type": "object",
"additionalProperties": false,
"properties": {
"operation": {
"type": "string",
"enum": [
"convert",
"offset",
"transitions"
],
"default": "convert"
},
"datetime": {
"type": "string",
"description": "ISO 8601; local to from_zone or zone unless it carries an offset or Z"
},
"from_zone": {
"type": "string",
"description": "IANA name, such as Europe/Madrid"
},
"to_zones": {
"type": "array",
"items": {
"type": "string"
}
},
"zone": {
"type": "string"
},
"year": {
"type": "integer",
"minimum": 1900,
"maximum": 2100
},
"fold": {
"type": "integer",
"enum": [
0,
1
],
"default": 0,
"description": "For a repeated local time: 0 the first, 1 the second"
}
}
}
Example
Input
{
"datetime": "2026-07-01T12:00",
"from_zone": "Europe/Madrid",
"to_zones": [
"America/New_York"
]
}
Output (stdout)
{
"to": [
{
"zone": "America/New_York",
"datetime": "2026-07-01T06:00:00-04:00",
"abbreviation": "EDT"
}
]
}
Files
The command the install card registers is python3 tz_convert.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.
tz_convert.py · sha256 44b44ae158caa6d179207b955f6320295fae4d1a291e33420d221716a9f5f592
#!/usr/bin/env python3
"""Time-zone arithmetic over the IANA database (zoneinfo): convert a date-time between zones,
show a zone's offset and abbreviation on a date, or list a year's DST transitions. Local
times that do not exist (the spring-forward gap) or happen twice (the fall-back hour) are
reported, never silently shifted.
Input (JSON on stdin):
operation "convert" (default) | "offset" | "transitions"
datetime "2026-07-01T12:00" (local in from_zone) or with an offset/Z convert, offset
from_zone "Europe/Madrid" convert
to_zones ["America/New_York", "UTC"] convert
zone "Europe/Madrid" offset, transitions
year 2026 transitions
fold 0 | 1 which of two repeated local times is meant (default 0, the first)
"""
import datetime as dt
import json
import pathlib
import sys
import zoneinfo
def fail(message):
print(json.dumps({"error": message}))
sys.exit(2)
def tzdata_version():
for root in zoneinfo.TZPATH:
path = pathlib.Path(root) / "tzdata.zi"
try:
first = path.read_text().splitlines()[0]
except (OSError, IndexError):
continue
if first.startswith("# version"):
return first.split()[-1]
return None
def zone(name, field):
if not isinstance(name, str) or not name:
fail(f"{field} must be an IANA zone name such as Europe/Madrid")
try:
return zoneinfo.ZoneInfo(name)
except (zoneinfo.ZoneInfoNotFoundError, ValueError):
fail(f"{field}: unknown zone {name!r}")
def offset_text(delta):
minutes = int(delta.total_seconds() // 60)
sign = "+" if minutes >= 0 else "-"
return f"{sign}{abs(minutes) // 60:02d}:{abs(minutes) % 60:02d}"
def describe(moment):
return {"datetime": moment.isoformat(), "utc_offset": offset_text(moment.utcoffset()), "abbreviation": moment.tzname(),
"dst": bool(moment.dst()), "weekday": moment.strftime("%A")}
def localise(text, tz, fold):
try:
naive = dt.datetime.fromisoformat(text.replace("Z", "+00:00"))
except (TypeError, ValueError, AttributeError):
fail("datetime must be ISO 8601, such as 2026-07-01T12:00")
if naive.tzinfo is not None:
return naive.astimezone(tz), None
local = naive.replace(tzinfo=tz, fold=fold)
back = local.astimezone(dt.timezone.utc).astimezone(tz)
if back.replace(tzinfo=None) != naive:
return local, "nonexistent: this local time falls in a daylight-saving gap"
other = naive.replace(tzinfo=tz, fold=1 - fold)
if other.utcoffset() != local.utcoffset():
return local, f"ambiguous: this local time happens twice; fold={fold} chose the {'first' if fold == 0 else 'second'}"
return local, None
def transitions(tz, year):
start = dt.datetime(year, 1, 1, tzinfo=dt.timezone.utc)
hour, out, previous = dt.timedelta(hours=1), [], start.astimezone(tz).utcoffset()
t = start
while t.year == year:
nxt = t + hour
offset = nxt.astimezone(tz).utcoffset()
if offset != previous:
lo, hi = t, nxt
while hi - lo > dt.timedelta(seconds=1):
mid = lo + (hi - lo) / 2
if mid.astimezone(tz).utcoffset() == previous:
lo = mid
else:
hi = mid
hi = hi.replace(microsecond=0)
before, after = (hi - dt.timedelta(seconds=1)).astimezone(tz), hi.astimezone(tz)
out.append({"utc": hi.strftime("%Y-%m-%dT%H:%M:%SZ"), "local_before": (before + dt.timedelta(seconds=1)).replace(tzinfo=None).isoformat(),
"local_after": after.replace(tzinfo=None).isoformat(), "offset_before": offset_text(previous),
"offset_after": offset_text(offset), "abbreviation_after": after.tzname()})
previous = offset
t = nxt
return out
def main():
try:
request = json.load(sys.stdin)
except ValueError as error:
fail(f"stdin is not JSON: {error}")
if not isinstance(request, dict):
fail("input must be a JSON object")
operation = request.get("operation", "convert")
fold = request.get("fold", 0)
if fold not in (0, 1):
fail("fold must be 0 or 1")
result = {"operation": operation, "tzdata": tzdata_version()}
if operation == "convert":
source = zone(request.get("from_zone"), "from_zone")
targets = request.get("to_zones")
if not isinstance(targets, list) or not targets:
fail("to_zones must be a non-empty list of zone names")
moment, warning = localise(request.get("datetime"), source, fold)
result["from"] = {"zone": request["from_zone"], **describe(moment)}
result["to"] = [{"zone": name, **describe(moment.astimezone(zone(name, "to_zones[]")))} for name in targets]
if warning:
result["warning"] = warning
elif operation == "offset":
tz = zone(request.get("zone"), "zone")
moment, warning = localise(request.get("datetime"), tz, fold)
result.update({"zone": request["zone"], **describe(moment)})
if warning:
result["warning"] = warning
elif operation == "transitions":
tz = zone(request.get("zone"), "zone")
year = request.get("year")
if not isinstance(year, int) or isinstance(year, bool) or not 1900 <= year <= 2100:
fail("year must be an integer from 1900 to 2100")
result.update({"zone": request["zone"], "year": year, "transitions": transitions(tz, year)})
else:
fail("operation must be convert, offset or transitions")
print(json.dumps(result))
if __name__ == "__main__":
main()
Where it is served
The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/tz_convert/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/tz_convert/1.0.0/tz_convert.py