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
178 lines
5.4 KiB
Python
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()) |