Files
mop-skills/skills/zwift-workout/scripts/zwo.py
T
IT Guy 109162889c feat(skills): add zwift-workout skill (.zwo builder, validator, preview, publishing)
Bike-only Zwift .zwo generator with human-readable preview + confirmation loop.

- scripts/zwo_builder.py: spec<->.zwo build/validate/parse/render/TSS estimate
- scripts/zwo.py: CLI (build, from-template, validate, parse, render, templates)
- references/zwo-format.md: condensed offline format reference
- assets/example-vo2max.zwo + tests/test_zwo.py (9 tests)
- Publishes via hosting skill; files result into Knowledge Wiki

Format ground truth: h4l/zwift-workout-file-reference
2026-09-20 07:47:05 +00:00

178 lines
5.4 KiB
Python

#!/usr/bin/env python3
"""zwo.py — CLI for creating, validating, previewing and publishing Zwift .zwo workouts.
Commands
--------
build <spec.json> [-o out.zwo] [--preview] [--publish]
from-template <name> [--title T] [-o out.zwo] [--preview] [--publish]
validate <file.zwo>
parse <file.zwo> [--json]
render <file.zwo|spec.json>
templates
Exit codes: 0 = ok, 1 = validation/usage error.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import zwo_builder as zb # noqa: E402
HOSTING_SCRIPT = Path("/home/node/mop-skills/mop-skills/skills/hosting/scripts/publish_file.py")
def _slug(text: str) -> str:
s = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
return s or "workout"
def _load_spec(path: str) -> dict:
with open(path, encoding="utf-8") as fh:
return json.load(fh)
def _publish(zwo_path: Path, title: str) -> str | None:
"""Publish the .zwo via the hosting skill. Returns the public URL or None."""
if not HOSTING_SCRIPT.exists():
print(f"⚠️ hosting publisher not found at {HOSTING_SCRIPT}", file=sys.stderr)
return None
cmd = [sys.executable, str(HOSTING_SCRIPT), "--file", str(zwo_path),
"--category", "docs", "--slug", _slug(title)]
try:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
except Exception as exc: # noqa: BLE001
print(f"⚠️ hosting publish failed: {exc}", file=sys.stderr)
return None
url = None
for line in out.stdout.splitlines():
if line.startswith("URL:"):
url = line.split("URL:", 1)[1].strip()
if not url:
print(out.stdout, out.stderr, file=sys.stderr)
return url
def cmd_build(args: argparse.Namespace) -> int:
spec = _load_spec(args.spec)
warnings = zb.validate_spec(spec)
out = Path(args.output or f"{_slug(spec['name'])}.zwo")
zb.write_zwo(spec, str(out))
print(f"✅ {out} ({out.stat().st_size} bytes)")
for w in warnings:
print(f"⚠️ {w}")
if args.preview:
print()
print(zb.render_text(spec))
if args.publish:
url = _publish(out, spec["name"])
if url:
print(f"🔗 {url}")
return 0
def cmd_from_template(args: argparse.Namespace) -> int:
spec = zb.from_template(args.name, args.title)
out = Path(args.output or f"{_slug(spec['name'])}.zwo")
zb.write_zwo(spec, str(out))
print(f"✅ {out} ({out.stat().st_size} bytes)")
print()
print(zb.render_text(spec))
if args.publish:
url = _publish(out, spec["name"])
if url:
print(f"🔗 {url}")
return 0
def cmd_validate(args: argparse.Namespace) -> int:
ok, messages = zb.validate_file(args.file)
if ok:
print(f"✅ {args.file} is a valid .zwo file")
for m in messages:
print(f"⚠️ {m}")
return 0
print(f"❌ {args.file} is invalid:")
for m in messages:
print(f" - {m}")
return 1
def cmd_parse(args: argparse.Namespace) -> int:
spec = zb.parse_file(args.file)
if args.json:
print(json.dumps(spec, ensure_ascii=False, indent=2))
else:
print(zb.render_text(spec))
return 0
def cmd_render(args: argparse.Namespace) -> int:
if args.file.endswith(".json"):
spec = _load_spec(args.file)
else:
spec = zb.parse_file(args.file)
print(zb.render_text(spec))
return 0
def cmd_templates(_: argparse.Namespace) -> int:
for t in zb.templates():
print(t)
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="zwo.py", description="Zwift .zwo workout toolkit (bike-only)")
sub = parser.add_subparsers(dest="command", required=True)
p = sub.add_parser("build", help="build a .zwo from a JSON spec")
p.add_argument("spec")
p.add_argument("-o", "--output")
p.add_argument("--preview", action="store_true", help="print the human-readable preview")
p.add_argument("--publish", action="store_true", help="upload via the hosting skill")
p.set_defaults(func=cmd_build)
p = sub.add_parser("from-template", help="build from a built-in deterministic template")
p.add_argument("name")
p.add_argument("--title")
p.add_argument("-o", "--output")
p.add_argument("--publish", action="store_true")
p.set_defaults(func=cmd_from_template)
p = sub.add_parser("validate", help="validate an existing .zwo file")
p.add_argument("file")
p.set_defaults(func=cmd_validate)
p = sub.add_parser("parse", help="parse a .zwo into a JSON spec")
p.add_argument("file")
p.add_argument("--json", action="store_true")
p.set_defaults(func=cmd_parse)
p = sub.add_parser("render", help="render a human-readable preview")
p.add_argument("file")
p.set_defaults(func=cmd_render)
p = sub.add_parser("templates", help="list built-in templates")
p.set_defaults(func=cmd_templates)
args = parser.parse_args(argv)
try:
return args.func(args)
except zb.ZwoValidationError as exc:
print(f"❌ validation error: {exc}", file=sys.stderr)
return 1
except (OSError, json.JSONDecodeError) as exc:
print(f"❌ {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())