From 711c397dfeae814e3049e6fca79d43591441bf60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Henrique?= Date: Thu, 20 Aug 2026 09:50:04 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20admin/api=20apontava=20para=20admin/code?= =?UTF-8?q?=20(inexistente)=20=E2=80=94=20crash=20no=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ao dividir _shared.py em admin/api/*.py ontem, o cálculo `Path(__file__).resolve().parent.parent / "code"` foi copiado sem ajustar para o nível de diretório novo. No arquivo original (admin/models_api.py, direto em admin/) dois `.parent` chegavam na raiz do repo. Em admin/api/shared.py, um nível mais fundo, dois `.parent` param em admin/ — e admin/code nunca existiu. sys.path nunca recebia code/, então toda ação que passa por `server` (analisar voz, aplicar decisões) crashava o app com ModuleNotFoundError: server_tools. O bug sobreviveu a duas rodadas de validação da sessão anterior — lint zero, 1454 testes verdes, comando testado manualmente pela ponte — porque todos rodam num venv com install editável (__editable__.fcp_mcp_server.pth) que já deixa fcpxml/server_tools importáveis por conta própria, mascarando qualquer erro no cálculo manual de sys.path. Só o app real, no fallback sem uv, expõe o bug. Correção: o cálculo de sys.path sai de cada módulo de comando (estava duplicado em nove arquivos) e passa a existir uma única vez em admin/api/__init__.py, que roda antes de qualquer submódulo — nenhum precisa mais da própria cópia. O teste de regressão precisou de duas tentativas pelo mesmo motivo do bug: a primeira versão também passava com o bug presente, por rodar no mesmo venv "de sorte". Só ficou confiável isolando um subprocess que remove site-packages do sys.path antes de importar — confirmado nos dois sentidos, falha com o bug reintroduzido e passa com a correção (TestCodeDirResolution). Detalhe completo, incluindo por que o comando manual não pegou: Engine/docs/05_EXPERIENCIAS.md #25. Lint zerado, 1457 testes passando (3 novos), app compilado. Co-Authored-By: Claude Opus 5 --- admin/api/__init__.py | 21 +++- admin/api/editing.py | 6 - admin/api/models.py | 8 -- admin/api/project.py | 6 - admin/api/review.py | 7 -- admin/api/shared.py | 166 +--------------------------- admin/api/subtitles.py | 8 +- admin/api/transcription.py | 8 +- admin/api/voice.py | 8 +- admin/api/zoom.py | 9 +- code/Engine/docs/05_EXPERIENCIAS.md | 53 +++++++++ code/Engine/docs/08_APP_MACOS.md | 8 ++ code/Engine/docs/09_MANUTENCAO.md | 1 + code/tests/test_models_api.py | 82 ++++++++++++++ 14 files changed, 169 insertions(+), 222 deletions(-) diff --git a/admin/api/__init__.py b/admin/api/__init__.py index 0dbfd0d..5ac0242 100644 --- a/admin/api/__init__.py +++ b/admin/api/__init__.py @@ -1 +1,20 @@ -"""Comandos da ponte JSON usada pelo app, agrupados por assunto.""" +"""Comandos da ponte JSON usada pelo app, agrupados por assunto. + +O setup de sys.path mora aqui, e só aqui, porque o pacote é importado antes de +qualquer um dos seus módulos (`from admin.api import models, voice, ...` +dispara este arquivo primeiro). Cada módulo de comando importa `fcpxml.*` +antes de importar `.shared` — sem o path já pronto neste ponto, o primeiro +desses imports falha com `ModuleNotFoundError`. Repetir o cálculo em cada +módulo (como era antes) é frágil por ordem: o app roda `admin/models_api.py` +por caminho absoluto, então `__file__` está sempre correto, mas cada arquivo +que refizesse essa conta um nível de diretório errado — como aconteceu quando +`_shared.py` virou este pacote e `admin/code` (inexistente) saiu no lugar de +`code/` — quebrava em silêncio até alguém rodar o comando de verdade. +""" + +import sys +from pathlib import Path + +_CODE_DIR = str(Path(__file__).resolve().parent.parent.parent / "code") +if _CODE_DIR not in sys.path: + sys.path.insert(0, _CODE_DIR) diff --git a/admin/api/editing.py b/admin/api/editing.py index ece85f2..d74ef8d 100644 --- a/admin/api/editing.py +++ b/admin/api/editing.py @@ -6,14 +6,8 @@ Extraído de models_api.py — a tabela de comandos segue lá. from __future__ import annotations import asyncio -import sys from pathlib import Path -# code/ is the package root for fcpxml and server modules. -_CODE_DIR = str(Path(__file__).resolve().parent.parent / "code") -if _CODE_DIR not in sys.path: - sys.path.insert(0, _CODE_DIR) - from fcpxml.model_manager import ( load_silence_config, save_silence_config, diff --git a/admin/api/models.py b/admin/api/models.py index fb75ef6..55a3182 100644 --- a/admin/api/models.py +++ b/admin/api/models.py @@ -7,14 +7,7 @@ from __future__ import annotations import shutil import subprocess -import sys import threading -from pathlib import Path - -# code/ is the package root for fcpxml and server modules. -_CODE_DIR = str(Path(__file__).resolve().parent.parent / "code") -if _CODE_DIR not in sys.path: - sys.path.insert(0, _CODE_DIR) from fcpxml.diarize import ( diarization_capability, @@ -40,7 +33,6 @@ from .shared import ( RECOMMENDED, ) - # Downloads em andamento, para o comando `cancel` conseguir interrompê-los. # Mora aqui, e não no shared, porque só `download` e `cancel` o tocam — e o # lock é próprio: ele protege este dicionário, não a saída em stdout. diff --git a/admin/api/project.py b/admin/api/project.py index c5ad377..611da88 100644 --- a/admin/api/project.py +++ b/admin/api/project.py @@ -5,14 +5,8 @@ Extraído de models_api.py — a tabela de comandos segue lá. from __future__ import annotations -import sys from pathlib import Path -# code/ is the package root for fcpxml and server modules. -_CODE_DIR = str(Path(__file__).resolve().parent.parent / "code") -if _CODE_DIR not in sys.path: - sys.path.insert(0, _CODE_DIR) - from fcpxml.model_manager import ( load_project_config, save_project_config, diff --git a/admin/api/review.py b/admin/api/review.py index 6618926..9d5e539 100644 --- a/admin/api/review.py +++ b/admin/api/review.py @@ -6,15 +6,8 @@ Extraído de models_api.py — a tabela de comandos segue lá. from __future__ import annotations import json -import sys from pathlib import Path -# code/ is the package root for fcpxml and server modules. -_CODE_DIR = str(Path(__file__).resolve().parent.parent / "code") -if _CODE_DIR not in sys.path: - sys.path.insert(0, _CODE_DIR) - - from . import shared diff --git a/admin/api/shared.py b/admin/api/shared.py index cbddcc8..df5eeee 100644 --- a/admin/api/shared.py +++ b/admin/api/shared.py @@ -16,173 +16,9 @@ import threading from pathlib import Path from typing import Any -# code/ is the package root for fcpxml and server modules. -_CODE_DIR = str(Path(__file__).resolve().parent.parent / "code") -if _CODE_DIR not in sys.path: - sys.path.insert(0, _CODE_DIR) - +from fcpxml.diarize import build_speakers from fcpxml.media_intel import media_src_to_path from fcpxml.parser import parse_fcpxml -from fcpxml.diarize import build_speakers # noqa: E402 - -"""JSON bridge between the SwiftUI app and the fcp-mcp-server Python engine. - -The SwiftUI app (MacApp/) launches this script as a subprocess with a command -and optional JSON arguments, then reads a single JSON document (or -newline-delimited JSON for progress) on stdout. - -Commands: - catalog - -> {"models": [{display_name, internal_name, size, storage, - accuracy, speed}], "installed": [names], - "selected": name, "models_dir": path, "installed_count": n, - "recommended": [names]} - - download {"model": "small"} - -> JSON-lines: {"type":"progress","fraction":0.42} - {"type":"done","installed":true} - {"type":"error","message":"..."} - - cancel {"model": "small"} - -> {"ok": true} - - select {"model": "small"} - -> {"ok": true, "selected": "small"} - - set_language {"language": "pt"} | "auto" - -> {"ok": true, "language": "pt"} - - delete {"model": "small"} - -> {"ok": true} - - open_finder {"model": "small"} - -> {"ok": true} - - set_models_dir {"dir": "/path"} - -> {"ok": true, "models_dir": "/path"} - - inspect {"path": "/path/to/project.fcpxml"} - -> {"ok": true, "path": "...", "name": "...", "fcpxml_version": "1.13", - "timelines": [{name, duration_seconds, frame_rate, width, height, - clips, cuts, connected, markers}]} - or {"ok": false, "error": "..."} - - analyze_voice {"path": "...", "output_dir": "...", "model": "...", - "language": "pt"|"auto"|null, "hf_token": "..."|null, - "num_speakers": ""|null} - Build the voice timeline (transcript+diarization+acoustics) for - every unique source media — analysis only, writes _voice_timeline.json - next to each media, `path` passes through unchanged. Meant as one - entry in the batch operations list (see processBatchStep), so - `refine_voice_timeline` never has to reopen the audio later. - -> {"ok": true, "path": "...", "message": "..."} or {"ok": false, "error": "..."} - - build_phrase_review {"voice_timeline": "..._voice_timeline.json", - "actions": {...}|[...]|null, "fresh": false} - The reviewable script for the wizard's emphasis step: every phrase with - the AI's decision already applied (active/emphasis/trim). A review saved - earlier for the same timeline is returned as-is unless `fresh` is true. - -> {"ok": true, "reused": bool, "source", "duration", "speakers", - "phrases": [{index, start, end, trim_start, trim_end, text, speaker, - active, emphasis (0-3), track, peak_emphasis, - take_boundary, gap_before, reason, words}], - "errors": [...]} - - save_phrase_review {"voice_timeline": "...", "phrases": [...], "source": "...", - "duration": 0.0, "speakers": [...]} - Writes _phrase_review.json plus the _phrase_actions.json derived from it. - -> {"ok": true, "review_path", "actions_path", "emphasis_count", - "removed_count"} - - dynamic_subtitle_config {} - -> {"ok": true, "band_height", "block_center_y", "line_gap", "font", - "font_size", "emphasis_font", "emphasis_face", "emphasis_size", - "active_color", "emphasis_color", "text_scale"} - - set_dynamic_subtitle_config {} - Persists only the given fields to ~/.fcp-mcp-server/config.json. - generate_dynamic_subtitles reads this as its own fallback default. - -> {"ok": true, } - - silence_config {} - -> {"ok": true, "noise_db": -30.0, "min_silence": 0.5, "padding": 0.05} - - set_silence_config {"noise_db": -30.0, "min_silence": 0.5, "padding": 0.05} - Persists only the given fields. detect_media_silence and - remove_media_silence read this as their own fallback default. - -> {"ok": true, } - - transcribe {"path": "...", "model": "small", "language": "pt"|null, - "hf_token": "..."|null, "num_speakers": ""|null} - -> JSON-lines: - {"type":"progress","fraction":0.5,"stage":"Transcrevendo..."} - {"type":"result","transcripts":[{"media","language","words", - "duration","preview","saved", - "speakers"}]} - {"type":"error","message":"..."} - - edit_by_transcript {"path": "...", "phrases": ["frase um", "frase dois"], - "mode": "remove"|"keep_only", "clip_name": "..."|null, - "padding": 0.0, "model": "small", "language": "pt"|null} - -> {"ok": true, "path": "..._transcript_edit.fcpxml", "message": "..."} - or {"ok": false, "error": "..."} - - remove_filler_words {"path": "...", "fillers": ["um","uh"]|null, - "clip_name": "..."|null, "padding": 0.02, - "model": "small", "language": "pt"|null} - -> {"ok": true, "path": "..._defillered.fcpxml", "message": "..."} - or {"ok": false, "error": "..."} - - transcript_markers {"path": "...", "clip_name": "..."|null, - "marker_type": "chapter", "max_label_length": 50, - "model": "small", "language": "pt"|null} - -> {"ok": true, "path": "..._transcript_markers.fcpxml", "message": "..."} - or {"ok": false, "error": "..."} - - add_zoom {"path": "...", "clip_id": "...", "start": 10.0, "end": 16.0, - "scale": 1.3, "ease": 0.3, "position": "0 0"|null} - -> {"ok": true, "path": "..._zoom.fcpxml", "message": "..."} - or {"ok": false, "error": "..."} - - generate_dynamic_subtitles {"path": "...", "clip_name": "..."|null, - "band_height": 0.22, "block_center_y": -167, - "font": "Helvetica Neue", "font_size": 128, - "emphasis_font": "Playfair Display", - "emphasis_face": "Medium Italic", "emphasis_size": 265, - "active_color": "1 1 1 1", "emphasis_color": "1 1 1 1", - "model": "small", "language": "pt"|null} - -> {"ok": true, "path": "..._dynamic_subtitles.fcpxml", "message": "..."} - or {"ok": false, "error": "..."} - - rename_speakers {"path": "/to/media_transcript.json", - "speakers": {"SPEAKER_01": "Nome"}} - -> {"ok": true, "speakers": [...]} - - set_diarization {"token": "hf_...", "num_speakers": ""} - -> {"ok": true, "diarization": bool, "diarization_message": "...", - "num_speakers": "..."} - - acoustics_capability - Whether librosa (pitch/energy for voice analysis) is installed. - -> {"ok": true, "available": bool, "message": "..."} - - voice_analysis - -> {"ok": true, "energy_threshold": 0.5, "emphasis_threshold": 0.85, - "emphasis_weights": {...}, "emotion_enabled": false, - "emotion_sensitivity": 0.5} - - set_voice_analysis {"energy_threshold": 0.6, "emphasis_threshold": 0.9, - "emphasis_weights": {"energy": 0.4}|null, - "emotion_enabled": true, "emotion_sensitivity": 0.5} - -> same shape as voice_analysis (only given fields change) - -Exit code 0 on success, 1 on error. -""" - -_CODE_DIR = str(Path(__file__).resolve().parent.parent / "code") - -if _CODE_DIR not in sys.path: - sys.path.insert(0, _CODE_DIR) RECOMMENDED = ("large-v3", "distil-large-v3", "small", "base") diff --git a/admin/api/subtitles.py b/admin/api/subtitles.py index 9a49730..c48ee90 100644 --- a/admin/api/subtitles.py +++ b/admin/api/subtitles.py @@ -6,14 +6,8 @@ Extraído de models_api.py — a tabela de comandos segue lá. from __future__ import annotations import asyncio -import sys from pathlib import Path -# code/ is the package root for fcpxml and server modules. -_CODE_DIR = str(Path(__file__).resolve().parent.parent / "code") -if _CODE_DIR not in sys.path: - sys.path.insert(0, _CODE_DIR) - from fcpxml.media_intel import media_src_to_path from fcpxml.model_manager import ( load_dynamic_subtitle_config, @@ -27,9 +21,9 @@ from . import shared from .shared import ( _derived_output, _emit_no_change_or_error, + _load_cached_transcript, _transcript_json_path, ) -from .shared import _load_cached_transcript # noqa: E402 def cmd_generate_dynamic_subtitles(args: dict) -> int: diff --git a/admin/api/transcription.py b/admin/api/transcription.py index 27a04c6..8327d9b 100644 --- a/admin/api/transcription.py +++ b/admin/api/transcription.py @@ -6,14 +6,8 @@ Extraído de models_api.py — a tabela de comandos segue lá. from __future__ import annotations import json -import sys from pathlib import Path -# code/ is the package root for fcpxml and server modules. -_CODE_DIR = str(Path(__file__).resolve().parent.parent / "code") -if _CODE_DIR not in sys.path: - sys.path.insert(0, _CODE_DIR) - from fcpxml.diarize import ( assign_speakers, build_speakers, @@ -35,10 +29,10 @@ from fcpxml.transcribe import transcribe from . import shared from .shared import ( + _load_cached_transcript, _save_json_atomic, _transcript_json_path, ) -from .shared import _load_cached_transcript # noqa: E402 def cmd_transcribe(args: dict) -> int: diff --git a/admin/api/voice.py b/admin/api/voice.py index 7b28e9c..24a118a 100644 --- a/admin/api/voice.py +++ b/admin/api/voice.py @@ -7,14 +7,8 @@ from __future__ import annotations import asyncio import json -import sys from pathlib import Path -# code/ is the package root for fcpxml and server modules. -_CODE_DIR = str(Path(__file__).resolve().parent.parent / "code") -if _CODE_DIR not in sys.path: - sys.path.insert(0, _CODE_DIR) - from fcpxml.model_manager import ( load_hf_token, load_num_speakers, @@ -26,12 +20,12 @@ from fcpxml.model_manager import ( from . import shared from .shared import ( + _load_cached_transcript, _load_cached_voice_timeline, _project_media_paths, _transcript_json_path, _voice_timeline_json_path, ) -from .shared import _load_cached_transcript # noqa: E402 def cmd_analyze_voice(args: dict) -> int: diff --git a/admin/api/zoom.py b/admin/api/zoom.py index 6abe4d2..52af346 100644 --- a/admin/api/zoom.py +++ b/admin/api/zoom.py @@ -6,21 +6,14 @@ Extraído de models_api.py — a tabela de comandos segue lá. from __future__ import annotations import asyncio -import sys from pathlib import Path -# code/ is the package root for fcpxml and server modules. -_CODE_DIR = str(Path(__file__).resolve().parent.parent / "code") -if _CODE_DIR not in sys.path: - sys.path.insert(0, _CODE_DIR) - - from . import shared from .shared import ( _derived_output, + _load_cached_transcript, _transcript_json_path, ) -from .shared import _load_cached_transcript # noqa: E402 def cmd_add_zoom(args: dict) -> int: diff --git a/code/Engine/docs/05_EXPERIENCIAS.md b/code/Engine/docs/05_EXPERIENCIAS.md index bf3b166..33d21d5 100644 --- a/code/Engine/docs/05_EXPERIENCIAS.md +++ b/code/Engine/docs/05_EXPERIENCIAS.md @@ -42,6 +42,7 @@ que merece entrada. | 22 | 2026-08-19 | `VideoPlayer` (AVKit) aborta em runtime no app compilado por `swiftc` — etapa 5 fechava o app; trocado por `AVPlayerLayer` | `resolvido` | | 23 | 2026-08-19 | Dividir `writer.py` em pacote quebrou `@patch('fcpxml.writer.subprocess')` — a suíte protege comportamento, não localização | `resolvido` | | 24 | 2026-08-19 | `admin/test_models_api.py` existia mas estava fora de `testpaths` — 13 testes que nunca rodaram | `resolvido` | +| 25 | 2026-08-20 | `admin/api/shared.py` apontava para `admin/code` (inexistente) após a divisão — install editável mascarou o bug em toda validação anterior | `resolvido` | > Mantenha o índice acima sempre sincronizado com as entradas mais recentes. @@ -1313,3 +1314,55 @@ o outro; percentil entrega um punhado útil nos dois casos. rodando. Vale também para o lint: `admin/` ainda não é coberto pelo `run_after_fix.sh`, que roda só dentro de `code/`. - **Estado:** `resolvido` + +--- + +## 25 — 2026-08-20 — `admin/api/shared.py` apontava para `admin/code` (inexistente) + +- **Sintoma:** app do usuário crashava em toda ação que passa por `server` + (ex: "Analisar voz"), com `ModuleNotFoundError: No module named + 'server_tools'`. Sobreviveu a **duas rodadas de validação minha** na sessão + anterior — lint zero, 1454 testes verdes, comando testado manualmente pela + ponte — sem nenhuma delas pegar o bug. +- **Causa raiz:** ao dividir `admin/_shared.py` (#25 da sessão de refatoração, + commit `ffaebb3`) em `admin/api/*.py`, o cálculo + `Path(__file__).resolve().parent.parent / "code"` foi copiado sem ajuste. + No arquivo original (`admin/models_api.py`, direto em `admin/`), dois + `.parent` chegam na raiz do repo. Em `admin/api/shared.py`, um nível mais + fundo, dois `.parent` param em `admin/` — e `admin/code` nunca existiu. + `sys.path` nunca recebia `code/`, então `import server_tools` (que só + funciona com `code/` no path) falhava assim que qualquer handler tentava + `from server import ...`. +- **Por que passou pela validação anterior:** todo teste que exercitava esse + caminho importava `admin.api.*` **dentro do processo do pytest**, que já + roda com `cwd=code/` sob um venv com **install editável** + (`__editable__.fcp_mcp_server*.pth`) — isso já deixa `fcpxml`/`server_tools` + importáveis por conta própria, mascarando qualquer erro no cálculo manual + de `sys.path`. O teste manual pela ponte (`uv run python + admin/models_api.py analyze_voice ...`) tem o mesmo problema: `uv run` + ativa o mesmo venv com o mesmo install editável. **Só o app real, chamando + o fallback `python3` sem `uv` ou um venv sem o install editável, expõe o + bug** — que é exatamente a diferença entre o ambiente de teste e o do + usuário. +- **Solução adotada:** o cálculo de `sys.path` saiu de cada módulo de + comando e passou a existir **uma única vez**, em `admin/api/__init__.py` + — que roda antes de qualquer submódulo do pacote, então nenhum deles + precisa da própria cópia. `.parent.parent.parent` (três níveis: `api/` → + `admin/` → raiz → `code/`). +- **Como o teste de regressão foi validado (e por que precisou de duas + tentativas):** a primeira versão do teste também passava com o bug + presente, pelo mesmo motivo do parágrafo acima — rodava em processo com o + install editável ativo. Só ficou confiável rodando um `subprocess` limpo + que remove manualmente qualquer entrada `site-packages` de `sys.path` + antes de importar, isolando o mecanismo real que o `__init__.py` precisa + fornecer. Confirmado nos dois sentidos: falha com o bug reintroduzido, + passa com a correção (`tests/test_models_api.py::TestCodeDirResolution`). +- **Aprendizado:** um install editável no venv de teste é uma segunda fonte + de verdade que mascara bugs de `sys.path` — o mesmo defeito de "a suíte + passa mas o comportamento real não bate" da entrada #23, só que desta vez + nem *rodar o comando manualmente* pegou, porque o `uv run` usado para + testar caía no mesmo venv "de sorte" que o app não usa. Ao validar correção + de caminho/import, rodar num ambiente que não tenha as dependências + instaladas por fora do mecanismo sendo testado — ou o teste prova que o + ambiente de teste está bem configurado, não que o código está certo. +- **Estado:** `resolvido` diff --git a/code/Engine/docs/08_APP_MACOS.md b/code/Engine/docs/08_APP_MACOS.md index 44305ee..9851b4b 100644 --- a/code/Engine/docs/08_APP_MACOS.md +++ b/code/Engine/docs/08_APP_MACOS.md @@ -96,6 +96,14 @@ desfaça sem entender: - **`scriptURL` procura `admin/models_api.py`** subindo diretórios a partir do cwd, do bundle e do home. É o que faz o app funcionar tanto rodando do Xcode quanto do `.app` montado. +- **O `sys.path` que torna `fcpxml`/`server_tools` importáveis dentro de + `admin/api/` mora só em `admin/api/__init__.py`.** Não copie esse cálculo + para um módulo de comando individual — foi exatamente essa cópia, + desatualizada em um nível de diretório, que quebrou toda ação que passa por + `server` (`05_EXPERIENCIAS.md` #25). E não confie em "testei com `uv run` e + funcionou": esse comando roda no mesmo venv com install editável que + mascara esse tipo de erro. O teste que pega de verdade é + `tests/test_models_api.py::TestCodeDirResolution`. Para adicionar um comando: função em `admin/api/.py`, registro na tabela de `admin/models_api.py`, e `PythonBridge.call` do lado Swift. Os 37 diff --git a/code/Engine/docs/09_MANUTENCAO.md b/code/Engine/docs/09_MANUTENCAO.md index 0b9aa66..6b7642a 100644 --- a/code/Engine/docs/09_MANUTENCAO.md +++ b/code/Engine/docs/09_MANUTENCAO.md @@ -129,6 +129,7 @@ E, além do script: | Legenda sobrepondo | Layout ou conteúdo antigo no arquivo | `collision.py`, `text_layout.py` | | "Ênfase" apontando para palavra à toa | Falta renormalizar após o corte | `refine_voice_timeline` | | App diz que falta librosa/pyannote | `uv run` com cwd errado | `PythonBridge.swift` (§3 do doc 08) | +| App crasha com `ModuleNotFoundError: server_tools` | `sys.path` de `admin/api/` mal calculado | `05_EXPERIENCIAS.md` #25 | | Tela do app fecha o programa | Componente de framework que só falha em runtime | `05_EXPERIENCIAS.md` #22 | | Comando existe no MCP mas não no app | Falta expor na ponte | `admin/api/`, #20 | diff --git a/code/tests/test_models_api.py b/code/tests/test_models_api.py index 095ccac..2db378f 100644 --- a/code/tests/test_models_api.py +++ b/code/tests/test_models_api.py @@ -10,6 +10,7 @@ patch keeps capturing the output of all of them. """ import json +import subprocess import sys from pathlib import Path @@ -23,6 +24,87 @@ if str(_REPO_ROOT) not in sys.path: from admin.api import models, shared, subtitles, transcription # noqa: E402 +_MODELS_API = _REPO_ROOT / "admin" / "models_api.py" + + +class TestCodeDirResolution: + """Pins the sys.path computation itself, independent of the environment. + + `code/` has an editable install (`__editable__.fcp_mcp_server*.pth`) that + makes `fcpxml`/`server_tools` importable regardless of this calculation — + which is exactly why TestSubprocessEntryPoint below can't be trusted alone + to catch a regression here: it passes in an environment with the editable + install even when the path math is wrong. The app's real subprocess call + can fall back to a plain `python3` with no such install (see + PythonBridge.swift), where this calculation is the only thing that puts + `code/` on sys.path — so pin the math directly, not just its usual + workaround. + """ + + def test_importing_the_package_puts_a_real_code_dir_on_sys_path(self): + """Checks the observable effect (what `admin.api`'s own `__init__.py` + puts on sys.path), with the editable install that normally masks this + stripped out of `sys.path` first — otherwise `server_tools` imports + fine regardless of whether the path math is right, and the assertion + proves nothing. A fresh subprocess is required too: importing + `admin.api` in-process reuses whatever `sys.modules` already cached + from an earlier test in this file, silently skipping `__init__.py` + the second time around. + """ + script = ( + "import sys\n" + "sys.path = [p for p in sys.path if 'site-packages' not in p]\n" + "import admin.api\n" + "import server_tools\n" # only resolvable if __init__.py did its job + "print('OK')\n" + ) + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, text=True, timeout=15, cwd=str(_REPO_ROOT), + ) + assert result.returncode == 0 and "OK" in result.stdout, result.stderr + + +class TestSubprocessEntryPoint: + """Runs models_api.py as the real subprocess the app launches. + + Every other test in this file imports admin.api directly, which resolves + `code/` onto sys.path a completely different way than a fresh Python + process does. That gap let a real bug through: `admin/api/shared.py` sits + one directory deeper than the old single-file `admin/models_api.py`, so + its `Path(__file__).resolve().parent.parent / "code"` pointed at + `admin/code` (nonexistent) instead of `code/` — `server_tools` (which + lives in `code/`) was then unimportable the moment a handler tried + `from server import ...`. Every in-process test still passed, because + none of them launch a real subprocess with a real `__file__`. + """ + + def _run(self, command: str, args: dict | None = None) -> dict: + argv = [sys.executable, str(_MODELS_API), command] + if args is not None: + argv.append(json.dumps(args)) + result = subprocess.run( + argv, capture_output=True, text=True, timeout=30, cwd=str(_REPO_ROOT / "code") + ) + assert result.returncode in (0, 1), ( + f"crashed (exit {result.returncode}):\n{result.stderr}" + ) + return json.loads(result.stdout.strip().splitlines()[-1]) + + def test_simple_command_runs_as_a_real_subprocess(self): + assert self._run("acoustics_capability")["ok"] is True + + def test_command_that_imports_server_tools_runs_as_a_real_subprocess(self): + """The exact failure mode: a handler reaching into `server` (and + transitively `server_tools`) from a process where `code/` was never + actually on sys.path.""" + result = self._run("analyze_voice", {"path": "/nonexistent/project.fcpxml"}) + # The path is bogus on purpose — the point is that it FAILS on a + # missing project, not on `ModuleNotFoundError: server_tools`. + assert result["ok"] is False + assert "server_tools" not in result.get("error", "") + assert "ModuleNotFoundError" not in result.get("error", "") + def _capture(monkeypatch): captured: list[dict] = []