fix: admin/api apontava para admin/code (inexistente) — crash no app
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cbd9297751
commit
711c397dfe
+20
-1
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+1
-165
@@ -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 {<any of the fields above>}
|
||||
Persists only the given fields to ~/.fcp-mcp-server/config.json.
|
||||
generate_dynamic_subtitles reads this as its own fallback default.
|
||||
-> {"ok": true, <same shape as dynamic_subtitle_config>}
|
||||
|
||||
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, <same shape as silence_config>}
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+1
-7
@@ -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:
|
||||
|
||||
+1
-8
@@ -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:
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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/<assunto>.py`, registro na
|
||||
tabela de `admin/models_api.py`, e `PythonBridge.call` do lado Swift. Os 37
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
Reference in New Issue
Block a user