diff --git a/milestones/README.md b/milestones/README.md index 0aa614b..72a953b 100644 --- a/milestones/README.md +++ b/milestones/README.md @@ -5,12 +5,15 @@ repositories that install `docgen` and maintain their own demo bundle. The library no longer ships an in-repo dogfood; consumers are the integration test of record. -**Active:** **[pipeline-fail-closed.md](pipeline-fail-closed.md)** — `generate-all` -and `yaml-generate` fail closed (preserve still/recording wiring; no silent -compose/validate skip). +**Active:** **[timestamps-fail-closed.md](timestamps-fail-closed.md)** — timestamps, +concat, Anthropic `base_url`, and remaining CLI/wizard silent-success paths. -**Shipped:** **[multi-host-ai-hardening.md](multi-host-ai-hardening.md)** — Cloud / -local Cursor / Claude Code keys, `--repo` clone cache, fail-closed chat. +**Shipped:** +- **[pipeline-fail-closed.md](pipeline-fail-closed.md)** — `generate-all` / + `yaml-generate` fail closed (preserve still/recording wiring; no silent + compose/validate skip). +- **[multi-host-ai-hardening.md](multi-host-ai-hardening.md)** — Cloud / + local Cursor / Claude Code keys, `--repo` clone cache, fail-closed chat. **Not in this milestone:** archived slides / i18n / Playwright / Embabel roadmaps under [archive/](archive/) (Playwright was removed from the product; diff --git a/milestones/timestamps-fail-closed.md b/milestones/timestamps-fail-closed.md new file mode 100644 index 0000000..5051824 --- /dev/null +++ b/milestones/timestamps-fail-closed.md @@ -0,0 +1,25 @@ +# Milestone — Timestamps, concat, and remaining silent success + +**Goal:** Timing, concat, Manim, compose CLI, and wizard steps must not +report success while dropping stems, muxing a partial concat, or ignoring +`ai.base_url` for Anthropic. + +**Branch / PR:** `cursor/timestamps-fail-closed-2ccd` + +Follows **[pipeline-fail-closed.md](pipeline-fail-closed.md)** (#77). + +## Shipped + +- [x] Anthropic chat posts to `{ai.base_url or DOCGEN_AI_BASE_URL}/v1/messages` +- [x] `timestamps extract_all` walks `segments.all` via `find_segment_asset` + (no `*.mp3` glob wipe); missing audio for a listed segment is an error +- [x] `concat` raises on unknown target, missing recordings, or ffmpeg failure +- [x] `ManimRunner.render` raises when `scenes.py` or the manim binary is missing +- [x] `docgen compose` exits non-zero when composed count is short +- [x] Wizard `/api/generate-narration` rejects paths that escape `repo_root` +- [x] Wizard manim / compose / validate steps fail closed + +## Not this milestone + +- Issue #56 residual quality (unpaced compile when `pace: none` is explicit) +- Archived Playwright / Embabel / slides diff --git a/src/docgen/ai_client.py b/src/docgen/ai_client.py index 8be2556..b98c8ea 100644 --- a/src/docgen/ai_client.py +++ b/src/docgen/ai_client.py @@ -548,6 +548,16 @@ def echo_ai_status(cfg: "Config | None" = None) -> None: click.echo(format_ai_status_line(cfg=cfg), err=True) +def _anthropic_messages_url(settings: AISettings) -> str: + """Chat completions URL for Anthropic or a compatible proxy.""" + base = (settings.base_url or "https://api.anthropic.com").rstrip("/") + if base == "https://api.anthropic.com": + return ANTHROPIC_MESSAGES_URL + if base.endswith("/v1"): + return f"{base}/messages" + return f"{base}/v1/messages" + + def _anthropic_chat( *, system_prompt: str, @@ -566,7 +576,7 @@ def _anthropic_chat( "messages": [{"role": "user", "content": user_message}], } raw = _http_json( - ANTHROPIC_MESSAGES_URL, + _anthropic_messages_url(settings), payload, settings=settings, accept="application/json", diff --git a/src/docgen/cli.py b/src/docgen/cli.py index ebd9639..24835a4 100644 --- a/src/docgen/cli.py +++ b/src/docgen/cli.py @@ -441,7 +441,10 @@ def manim(ctx: click.Context, scene: str | None) -> None: cfg = _require_config(ctx) runner = ManimRunner(cfg) - runner.render(scene=scene) + try: + runner.render(scene=scene) + except RuntimeError as exc: + raise click.ClickException(str(exc)) from exc @main.command() @@ -485,7 +488,19 @@ def compose( f"({', '.join(only_visual_types)})." ) click.echo(f"=== Composing {len(target)} segments ===") - comp.compose_segments(target) + composed = comp.compose_segments(target) + mapped = [ + sid + for sid in target + if isinstance(cfg.visual_map.get(sid), dict) + and str(cfg.visual_map[sid].get("type", "")).strip() + ] + expected = len(mapped) if mapped else len(target) + if expected and composed < expected: + raise click.ClickException( + f"[compose] produced {composed}/{expected} segment videos " + "(missing audio or visuals)." + ) @main.command() @@ -1254,11 +1269,13 @@ def clean_bundle( @click.pass_context def concat(ctx: click.Context, concat_name: str | None) -> None: """Concatenate full demo files from composed segments.""" - from docgen.concat import ConcatBuilder + from docgen.concat import ConcatBuilder, ConcatError cfg = _require_config(ctx) - builder = ConcatBuilder(cfg) - builder.build(name=concat_name) + try: + ConcatBuilder(cfg).build(name=concat_name) + except ConcatError as exc: + raise click.ClickException(str(exc)) from exc @main.command() diff --git a/src/docgen/concat.py b/src/docgen/concat.py index b4fac2d..7e0bf30 100644 --- a/src/docgen/concat.py +++ b/src/docgen/concat.py @@ -10,37 +10,55 @@ from docgen.config import Config +class ConcatError(RuntimeError): + """Raised when concat cannot build a complete output.""" + + class ConcatBuilder: def __init__(self, config: Config) -> None: self.config = config def build(self, name: str | None = None) -> None: concat_map = self.config.concat_map - if not concat_map: + if not isinstance(concat_map, dict) or not concat_map: print("[concat] No concat map in config") return - targets = {name: concat_map[name]} if name and name in concat_map else concat_map + if name: + if name not in concat_map: + raise ConcatError( + f"[concat] unknown target {name!r}; " + f"known: {', '.join(sorted(concat_map))}" + ) + targets = {name: concat_map[name]} + else: + targets = concat_map for out_name, seg_ids in targets.items(): self._build_one(out_name, seg_ids) def _build_one(self, out_name: str, seg_ids: list[str]) -> None: recordings_dir = self.config.recordings_dir if not recordings_dir.exists(): - print("[concat] Recordings dir not found") - return + raise ConcatError(f"[concat] recordings dir not found: {recordings_dir}") files: list[Path] = [] + missing: list[str] = [] for seg_id in seg_ids: - found = self.config.find_segment_asset(recordings_dir, seg_id, ".mp4") + found = self.config.find_segment_asset(recordings_dir, str(seg_id), ".mp4") if found: files.append(found) else: - print(f"[concat] Missing recording for segment {seg_id}") - - if not files: - print(f"[concat] No files to concatenate for {out_name}") - return + missing.append(str(seg_id)) + if missing: + raise ConcatError( + "[concat] missing recording(s) for " + + ", ".join(missing) + + f" (needed for {out_name})" + ) + if len(files) != len(seg_ids): + raise ConcatError( + f"[concat] {out_name}: expected {len(seg_ids)} files, got {len(files)}" + ) fname = out_name if out_name.endswith(".mp4") else f"{out_name}.mp4" out = recordings_dir / fname @@ -57,7 +75,12 @@ def _build_one(self, out_name: str, seg_ids: list[str]) -> None: check=True, capture_output=True, text=True, timeout=300, cwd=str(recordings_dir), ) - except Exception as exc: - print(f"[concat] Failed: {exc}") + except FileNotFoundError as exc: + raise ConcatError("[concat] ffmpeg not found in PATH") from exc + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or exc.stdout or "")[:400] + raise ConcatError(f"[concat] ffmpeg failed: {detail}") from exc + except subprocess.TimeoutExpired as exc: + raise ConcatError("[concat] ffmpeg timed out") from exc finally: concat_list.unlink(missing_ok=True) diff --git a/src/docgen/config.py b/src/docgen/config.py index 5a02857..5a094c8 100644 --- a/src/docgen/config.py +++ b/src/docgen/config.py @@ -57,8 +57,10 @@ def resolve_segment_name(self, seg_id: str) -> str: def find_segment_asset(self, directory: Path, seg_id: str, suffix: str) -> Path | None: """Resolve a per-segment file without substring glob collisions (``01`` vs ``101``). - Prefers ``segment_names[id]`` stem, then ``{id}{suffix}``, then ``{id}-*{suffix}``. - Does not use ``*{id}*``. + Prefers ``segment_names[id]`` stem. If the stem is the id itself, also + accepts ``{id}-*{suffix}``. Does not use ``*{id}*`` and does not treat + a bare ``{id}{suffix}`` as a match for a longer stem (orphan ``01.mp3`` + must not satisfy segment ``01-intro``). """ if not directory.is_dir(): return None @@ -69,9 +71,7 @@ def find_segment_asset(self, directory: Path, seg_id: str, suffix: str) -> Path if exact.is_file(): return exact if stem != sid: - by_id = directory / f"{sid}{ext}" - if by_id.is_file(): - return by_id + return None prefixed = sorted(p for p in directory.glob(f"{sid}-*{ext}") if p.is_file()) return prefixed[0] if prefixed else None diff --git a/src/docgen/manim_runner.py b/src/docgen/manim_runner.py index 3ec16ea..19d386e 100644 --- a/src/docgen/manim_runner.py +++ b/src/docgen/manim_runner.py @@ -46,15 +46,17 @@ def render( scenes_file = self.config.animations_dir / "scenes.py" if not scenes_file.exists(): - print(f"[manim] scenes.py not found at {scenes_file}") - return + raise RuntimeError(f"[manim] scenes.py not found at {scenes_file}") self._check_font() quality_args, quality_label = self._quality_args() manim_bin = self._resolve_manim_binary() if not manim_bin: - return + raise RuntimeError( + "[manim] manim executable not found. " + "Install with `pip install manim` or set `manim.manim_path` in docgen.yaml." + ) font = self.config.manim_font print(f"[manim] Rendering at {quality_label}, font={font}") diff --git a/src/docgen/timestamps.py b/src/docgen/timestamps.py index 8280bc9..f91dc4d 100644 --- a/src/docgen/timestamps.py +++ b/src/docgen/timestamps.py @@ -24,6 +24,10 @@ ENGINES = ("local", "whisper") +class TimestampError(RuntimeError): + """Raised when timestamps cannot be extracted for required segments.""" + + class TimestampExtractor: def __init__(self, config: Config) -> None: self.config = config @@ -77,12 +81,12 @@ def resolve_engine(self, engine: str | None = None) -> str: return chosen def extract_all(self, engine: str | None = None) -> None: - """Extract timestamps for all segments and write timing.json.""" - audio_dir = self.config.audio_dir - if not audio_dir.exists(): - print("[timestamps] No audio directory found") - return + """Extract timestamps for ``segments.all`` and write timing.json. + Walks configured segment ids via :meth:`Config.find_segment_asset` (no + ``*.mp3`` glob). Missing audio for a listed segment is an error. With + no ``segments.all`` entries, existing ``timing.json`` is left unchanged. + """ chosen = self.resolve_engine(engine) print(f"[timestamps] engine: {chosen}") if chosen == "whisper": @@ -96,19 +100,35 @@ def extract_all(self, engine: str | None = None) -> None: f"(offline) or OpenAI/Grok. {st.auth_help()}" ) - mp3s = sorted(audio_dir.glob("*.mp3")) - if not mp3s: - print("[timestamps] No audio/*.mp3 files found; leaving timing.json unchanged") + seg_ids = [str(s) for s in self.config.segments_all] + if not seg_ids: + print("[timestamps] segments.all is empty; leaving timing.json unchanged") return + missing: list[str] = [] + jobs: list[tuple[str, Path]] = [] + for sid in seg_ids: + mp3 = self.config.find_segment_asset(self.config.audio_dir, sid, ".mp3") + if mp3 is None: + stem = self.config.resolve_segment_name(sid) + missing.append(f"{sid} ({stem}.mp3)") + else: + jobs.append((sid, mp3)) + if missing: + raise TimestampError( + "[timestamps] missing audio for segment(s): " + + ", ".join(missing) + + ". Run `docgen tts` first." + ) + timing: dict[str, Any] = {} - for mp3 in mp3s: - seg_id = mp3.stem - print(f"[timestamps] Extracting timestamps for {seg_id}") + for sid, mp3 in jobs: + key = mp3.stem + print(f"[timestamps] Extracting timestamps for {sid} ({key})") if chosen == "whisper": - timing[seg_id] = self.extract(mp3) + timing[key] = self.extract(mp3) else: - timing[seg_id] = self.extract_local(mp3) + timing[key] = self.extract_local(mp3) out = self.config.animations_dir / "timing.json" out.parent.mkdir(parents=True, exist_ok=True) diff --git a/src/docgen/wizard.py b/src/docgen/wizard.py index ce5347b..10796ae 100644 --- a/src/docgen/wizard.py +++ b/src/docgen/wizard.py @@ -581,10 +581,15 @@ def api_generate_narration(): source_texts = [] for rel in source_paths: - fpath = root / rel + rel_s = str(rel).strip().replace("\\", "/") + if not rel_s or rel_s.startswith("/") or ".." in rel_s.split("/"): + return jsonify({"error": f"invalid path: {rel!r}"}), 400 + fpath = (root / rel_s).resolve() + if not _is_under_root(fpath, root): + return jsonify({"error": f"path escapes repo_root: {rel_s}"}), 400 if fpath.exists() and fpath.is_file(): source_texts.append( - f"## File: {rel}\n{fpath.read_text(encoding='utf-8', errors='replace')}" + f"## File: {rel_s}\n{fpath.read_text(encoding='utf-8', errors='replace')}" ) try: @@ -913,21 +918,45 @@ def _run_segment_step(cfg: Any, step: str, segment_id: str) -> dict[str, Any]: runner = ManimRunner(cfg) vmap = cfg.visual_map.get(segment_id, {}) - scene = vmap.get("scene") - if scene: - runner.render(scene=scene) + scene = None + if isinstance(vmap, dict): + scene = vmap.get("scene") or vmap.get("class") + if not scene: + raise RuntimeError( + f"no manim scene for segment {segment_id} " + "(set visual_map scene/class)" + ) + runner.render(scene=str(scene)) return {"ok": True, "step": "manim", "segment": segment_id} if step == "compose": from docgen.compose import Composer - Composer(cfg).compose_segments([segment_id]) + n = Composer(cfg).compose_segments([segment_id]) + if n < 1: + raise RuntimeError( + f"compose produced no video for segment {segment_id}" + ) return {"ok": True, "step": "compose", "segment": segment_id} if step == "validate": from docgen.validate import Validator report = Validator(cfg).validate_segment(segment_id) + passed = True + if isinstance(report, dict): + if "passed" in report: + passed = bool(report["passed"]) + else: + checks = report.get("checks") or [] + passed = all( + (not isinstance(c, dict)) or c.get("passed", True) + for c in checks + ) + if not passed: + raise RuntimeError( + f"validate failed for segment {segment_id}" + ) return { "ok": True, "step": "validate", diff --git a/tests/test_ai_client.py b/tests/test_ai_client.py index f6d4177..bb9997e 100644 --- a/tests/test_ai_client.py +++ b/tests/test_ai_client.py @@ -323,6 +323,31 @@ def _http(url: str, *, data: bytes, headers: dict, **_kwargs) -> bytes: assert captured["payload"]["model"] == DEFAULT_ANTHROPIC_CHAT_MODEL +def test_anthropic_chat_honors_base_url( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clear_ai_env: None +) -> None: + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test") + monkeypatch.setenv("DOCGEN_AI_BASE_URL", "https://proxy.example/anthropic") + cfg = _cfg(tmp_path, {"ai": {"provider": "anthropic"}}) + captured: dict = {} + + def _http(url: str, *, data: bytes, headers: dict, **_kwargs) -> bytes: + captured["url"] = url + return json.dumps( + {"content": [{"type": "text", "text": "ok"}]} + ).encode() + + with patch("docgen.ai_client._http_with_retries", side_effect=_http): + chat_completion( + system_prompt="s", + user_message="u", + model="claude-sonnet-4-5", + temperature=0.1, + cfg=cfg, + ) + assert captured["url"] == "https://proxy.example/anthropic/v1/messages" + + def test_anthropic_tts_raises(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clear_ai_env: None) -> None: monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test") with pytest.raises(AIError, match="no TTS"): diff --git a/tests/test_compose.py b/tests/test_compose.py index 629cbde..d087241 100644 --- a/tests/test_compose.py +++ b/tests/test_compose.py @@ -140,3 +140,21 @@ def test_find_audio_does_not_use_substring_glob(tmp_path: Path) -> None: found = composer._find_audio("01") assert found is not None assert found.name == "01-demo.mp3" + + +def test_cli_compose_exits_nonzero_when_nothing_composed(tmp_path: Path) -> None: + from click.testing import CliRunner + + from docgen.cli import main + + cfg = { + "dirs": {"animations": "animations", "audio": "audio", "recordings": "recordings"}, + "segments": {"default": ["01"], "all": ["01"]}, + "segment_names": {"01": "01-demo"}, + "visual_map": {"01": {"type": "manim", "source": "Scene01.mp4"}}, + } + c = _write_cfg(tmp_path, cfg) + runner = CliRunner() + result = runner.invoke(main, ["--config", str(c.yaml_path), "compose"]) + assert result.exit_code != 0 + assert "0/" in result.output or "produced" in result.output diff --git a/tests/test_concat.py b/tests/test_concat.py new file mode 100644 index 0000000..0295b3a --- /dev/null +++ b/tests/test_concat.py @@ -0,0 +1,42 @@ +"""Tests for fail-closed concat.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from docgen.concat import ConcatBuilder, ConcatError +from docgen.config import Config + + +def _cfg(tmp_path: Path, concat: dict) -> Config: + raw = { + "dirs": {"recordings": "recordings"}, + "segments": {"all": ["01", "02"], "default": ["01", "02"]}, + "segment_names": {"01": "01-a", "02": "02-b"}, + "concat": concat, + } + p = tmp_path / "docgen.yaml" + p.write_text(yaml.dump(raw), encoding="utf-8") + return Config.from_yaml(p) + + +def test_concat_unknown_target_raises(tmp_path: Path) -> None: + cfg = _cfg(tmp_path, {"full": ["01", "02"]}) + with pytest.raises(ConcatError, match="unknown target"): + ConcatBuilder(cfg).build(name="missing") + + +def test_concat_missing_recording_raises_before_ffmpeg(tmp_path: Path) -> None: + cfg = _cfg(tmp_path, {"full": ["01", "02"]}) + (tmp_path / "recordings").mkdir() + (tmp_path / "recordings" / "01-a.mp4").write_bytes(b"x") + with pytest.raises(ConcatError, match="missing recording"): + ConcatBuilder(cfg).build(name="full") + + +def test_concat_empty_map_is_noop(tmp_path: Path) -> None: + cfg = _cfg(tmp_path, {}) + ConcatBuilder(cfg).build() diff --git a/tests/test_manim_runner.py b/tests/test_manim_runner.py index aeaa734..0635176 100644 --- a/tests/test_manim_runner.py +++ b/tests/test_manim_runner.py @@ -130,3 +130,10 @@ def always_fail(cmd, **_kwargs): # type: ignore[no-untyped-def] pytest.raises(RuntimeError, match="Manim failed for scene"), ): runner.render(scene="Scene01") + + +def test_render_raises_when_scenes_py_missing(tmp_path: Path) -> None: + cfg = _config_with_quality(tmp_path, "720p30") + runner = ManimRunner(cfg) + with pytest.raises(RuntimeError, match="scenes.py not found"): + runner.render(scene="Scene01") diff --git a/tests/test_timestamps_local.py b/tests/test_timestamps_local.py index 3f1b2b1..0463996 100644 --- a/tests/test_timestamps_local.py +++ b/tests/test_timestamps_local.py @@ -97,13 +97,59 @@ def test_markdown_is_stripped_before_alignment(self, cfg, monkeypatch) -> None: assert "**Bold**" not in words - def test_no_mp3s_leaves_existing_timing_json(self, cfg) -> None: + def test_no_mp3s_fails_when_segments_listed(self, cfg) -> None: + out = cfg.animations_dir / "timing.json" + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text('{"keep": true}\n', encoding="utf-8") + from docgen.timestamps import TimestampError + + with pytest.raises(TimestampError, match="missing audio"): + TimestampExtractor(cfg).extract_all() + assert json.loads(out.read_text(encoding="utf-8")) == {"keep": True} + + def test_empty_segments_all_leaves_existing_timing_json(self, tmp_path) -> None: + (tmp_path / "docgen.yaml").write_text( + yaml.dump({"segments": {"all": []}}), encoding="utf-8" + ) + cfg = Config.from_yaml(tmp_path / "docgen.yaml") out = cfg.animations_dir / "timing.json" out.parent.mkdir(parents=True, exist_ok=True) out.write_text('{"keep": true}\n', encoding="utf-8") TimestampExtractor(cfg).extract_all() assert json.loads(out.read_text(encoding="utf-8")) == {"keep": True} + def test_orphan_short_id_mp3_is_not_used(self, cfg, monkeypatch) -> None: + _fake_audio_env(monkeypatch) + (cfg.narration_dir / "01-x.md").write_text("Alpha begins. Beta ends.\n", encoding="utf-8") + (cfg.audio_dir / "01.mp3").write_bytes(b"orphan") + from docgen.timestamps import TimestampError + + with pytest.raises(TimestampError, match="01-x.mp3"): + TimestampExtractor(cfg).extract_all() + + def test_does_not_wipe_timing_when_a_listed_segment_is_missing( + self, tmp_path, monkeypatch + ) -> None: + _fake_audio_env(monkeypatch) + raw = { + "segments": {"all": ["01", "02"]}, + "segment_names": {"01": "01-a", "02": "02-b"}, + } + (tmp_path / "docgen.yaml").write_text(yaml.dump(raw), encoding="utf-8") + (tmp_path / "audio").mkdir() + (tmp_path / "narration").mkdir() + cfg = Config.from_yaml(tmp_path / "docgen.yaml") + (cfg.narration_dir / "01-a.md").write_text("Hello world.\n", encoding="utf-8") + (cfg.audio_dir / "01-a.mp3").write_bytes(b"fake") + out = cfg.animations_dir / "timing.json" + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text('{"02-b": {"text": "keep"}}\n', encoding="utf-8") + from docgen.timestamps import TimestampError + + with pytest.raises(TimestampError, match="02"): + TimestampExtractor(cfg).extract_all() + assert json.loads(out.read_text(encoding="utf-8")) == {"02-b": {"text": "keep"}} + def test_whisper_engine_fails_fast_without_stt( self, tmp_path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_wizard.py b/tests/test_wizard.py index 4e92627..5c14518 100644 --- a/tests/test_wizard.py +++ b/tests/test_wizard.py @@ -87,3 +87,30 @@ def test_api_file_rejects_prefix_escape(tmp_path): assert ok.get_json()["content"] == "safe" escaped = client.get("/api/file", query_string={"path": "../proj-evil/secret.md"}) assert escaped.status_code == 404 + + +def test_generate_narration_rejects_escaped_source_path(tmp_path): + from docgen.config import Config + from docgen.wizard import create_app + + repo = tmp_path / "proj" + evil = tmp_path / "secret.md" + repo.mkdir() + evil.write_text("leak", encoding="utf-8") + yaml_path = repo / "docgen.yaml" + yaml_path.write_text( + "repo_root: .\nsegments:\n default: ['01']\n all: ['01']\n", + encoding="utf-8", + ) + cfg = Config.from_yaml(yaml_path) + client = create_app(cfg).test_client() + resp = client.post( + "/api/generate-narration", + json={ + "segment_name": "01-intro", + "source_paths": ["../secret.md"], + "guidance": "x", + }, + ) + assert resp.status_code == 400 + assert "invalid path" in resp.get_json()["error"]