From b1c7a4217242787616f01395ec28937da39c2d0f Mon Sep 17 00:00:00 2001 From: RenatoXSR Date: Fri, 7 Aug 2026 00:03:19 -0300 Subject: [PATCH] Unified aedocw backends --- .gitignore | 5 +- .python-version | 1 + README.md | 29 ++++++ src/epub_tts/__main__.py | 25 +++-- src/epub_tts/tts_aedocw.py | 119 +++++------------------ src/epub_tts/tts_edge.py | 14 --- src/epub_tts/tts_epub2tts.py | 75 --------------- src/epub_tts/tts_epub2tts_chatterbox.py | 62 ------------ src/epub_tts/tts_epub2tts_edge.py | 53 ---------- src/epub_tts/tts_epub2tts_kokoro.py | 53 ---------- src/epub_tts/tts_generic.py | 122 ++++++++++++++++-------- src/epub_tts/vendor_utils.py | 8 +- 12 files changed, 169 insertions(+), 397 deletions(-) create mode 100644 .python-version delete mode 100644 src/epub_tts/tts_edge.py delete mode 100644 src/epub_tts/tts_epub2tts.py delete mode 100644 src/epub_tts/tts_epub2tts_chatterbox.py delete mode 100644 src/epub_tts/tts_epub2tts_edge.py delete mode 100644 src/epub_tts/tts_epub2tts_kokoro.py diff --git a/.gitignore b/.gitignore index cb8314b..0b9eabf 100644 --- a/.gitignore +++ b/.gitignore @@ -137,6 +137,8 @@ ENV/ env.bak/ venv.bak/ .tmp-samples/ +.tmp* +.venv* # Spyder project settings .spyderproject @@ -223,7 +225,8 @@ $RECYCLE.BIN/ .LSOverride # Icon must end with two \r -Icon +Icon + # Thumbnails ._* diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/README.md b/README.md index d4170c3..bd71259 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,35 @@ Python project to gather several projects that convert EPUB files to audiobooks (M4B files, mostly), with different TTS backends. +# TL;DR + +## Setup +```sh +git clone https://git.silveirarosa.com/renatoxsr/epub-tts +cd .\epub-tts\ +pyenv local 3.11 +python3 -m venv .venv +git submodule update --init --recursive +``` + +## Install on Windows +```pwsh +.\.venv\Scripts\python.exe -m pip install -e '.[dev]' +.\.venv\Scripts\download-epub-minimal.exe +.\.venv\Scripts\download-moby-dick.exe +.\.venv\Scripts\download-moby-dick-media-overlays.exe +.\.venv\Scripts\epub-tts.exe -i .\.tmp-samples\minimal.epub -o .\.tmp-m4b\minimal.m4b -b epub2tts-edge +``` + +## Install on POSIX +```sh +.\.venv\bin\python -m pip install -e '.[dev]' +.\.venv\bin\download-epub-minimal +.\.venv\bin\download-moby-dick +.\.venv\bin\download-moby-dick-media-overlays +.\.venv\bin\epub-tts -i ./.tmp-samples/minimal.epub -o ./.tmp-m4b/minimal.m4b -b epub2tts-edge +``` + # Requirements Recent Python (>=3.11), pip (>=22.3), some sort of virtula environment recommended (such as the builtin venv package), and a Rust compatible platform (avoid embedded systems which do not have pre-built binaries for the compiled requirements, e.g., Android's Termux try to build everything from sources and fails miserably when doing so). diff --git a/src/epub_tts/__main__.py b/src/epub_tts/__main__.py index 4959c86..1f2c00f 100644 --- a/src/epub_tts/__main__.py +++ b/src/epub_tts/__main__.py @@ -23,7 +23,9 @@ optional arguments: -b {default,edge,epub2tts,epub2tts-edge,epub2tts-chatterbox,epub2tts-kokoro}, --backend {default,edge,epub2tts,epub2tts-edge,epub2tts-chatterbox,epub2tts-kokoro} Backend to use for TTS """ +import os import sys +from pathlib import Path from argparse import ArgumentParser from . import tts_aedocw, tts_edge, tts_generic @@ -39,6 +41,7 @@ backend = { "epub2tts-edge": tts_aedocw.AedocwEpub2TTSEdge, "epub2tts-chatterbox": tts_aedocw.AedocwChatterbox, "epub2tts-kokoro": tts_aedocw.AedocwKokoro, + "generic-epub2tts": tts_aedocw.Epub2TTS } @@ -63,18 +66,28 @@ def main(args=None): ) args = p.parse_args(args or sys.argv[1:]) print( - f"Converting {args.input} to {args.output} using voice " - f"{args.voice}, language {args.language}, " - f"backend {args.backend}, ..." + f"Converting {args.input} to {args.output} " + f"using {args.backend} backend " + "and args: " + ", ".join( + f"{k}={v}" for k, v in vars(args).items() + if k not in ("input", "output", "backend") and v is not None) ) + input_path = Path(args.input).resolve() + if not input_path.exists(): + raise FileNotFoundError(f"Input file {input_path} does not exist.") + output_path = Path(args.output).resolve() + if not output_path.parent.exists(): + os.makedirs(output_path.parent, exist_ok=True) + backend_cls = backend[args.backend] backend_instance = backend_cls( backend_cmd=None, - language=args.language, - voice=args.voice, ) - backend_instance.run(args.input, args.output) + backend_instance.run( + input_path, output_path, + **vars(args), + ) if __name__ == "__main__": diff --git a/src/epub_tts/tts_aedocw.py b/src/epub_tts/tts_aedocw.py index 6f2d8f2..a051d49 100644 --- a/src/epub_tts/tts_aedocw.py +++ b/src/epub_tts/tts_aedocw.py @@ -13,12 +13,6 @@ The vendored packages are run in isolated virtual environments when the corresponding console script is not available in the current environment. """ -from typing import Optional - -from . import tts_epub2tts as epub2tts_adapter -from . import tts_epub2tts_chatterbox as epub2tts_chatterbox_adapter -from . import tts_epub2tts_edge as epub2tts_edge_adapter -from . import tts_epub2tts_kokoro as epub2tts_kokoro_adapter from .tts_generic import GenericTTSBackend @@ -35,110 +29,45 @@ class AedocwBackend(GenericTTSBackend): but the vendored adapters may still run the package in their own venv if the console script is not present. """ - - _CMD_MAP = { - "epub2tts": "epub2tts", - "epub2tts-edge": "epub2tts-edge", - "epub2tts-chatterbox": "epub2tts-chatterbox", - "epub2tts-kokoro": "epub2tts-kokoro", - } - - _RUNNER_MAP = { - "epub2tts": epub2tts_adapter.run, - "epub2tts-edge": epub2tts_edge_adapter.run, - "epub2tts-chatterbox": epub2tts_chatterbox_adapter.run, - "epub2tts-kokoro": epub2tts_kokoro_adapter.run, - } - - def __init__( - self, - repo: str = "epub2tts", - backend_cmd: Optional[str] = None, - language: Optional[str] = None, - voice: Optional[str] = None, - ): - if backend_cmd is None: - backend_cmd = self._CMD_MAP.get(repo, "epub2tts") - super().__init__(backend_cmd=backend_cmd, language=language, voice=voice) - self.repo = repo - - def run(self, input_source, output_dest): - runner = self._RUNNER_MAP.get(self.repo) - if runner is None: - raise ValueError(f"Unknown repository backend: {self.repo}") - - return runner( - input_source, - output_dest, - language=self.language, - voice=self.voice, - backend_cmd=self.backend_cmd, - ) - + FLAGS = { + "speaker": "--speaker", + "voice": "--speaker", + "language": "--language", + } + INTERMEDIATE_TXT = True + class AedocwEpub2TTS(AedocwBackend): """Backend configured for github.com/aedocw/epub2tts.""" - - def __init__( - self, - backend_cmd: Optional[str] = None, - language: Optional[str] = None, - voice: Optional[str] = None, - ): - super().__init__( - repo="epub2tts", backend_cmd=backend_cmd, language=language, voice=voice - ) - + pass + class AedocwEpub2TTSEdge(AedocwBackend): """Backend configured for github.com/aedocw/epub2tts-edge.""" + DEFAULT_SPEAKER = "en-US-AndrewNeural" - def __init__( - self, - backend_cmd: Optional[str] = None, - language: Optional[str] = None, - voice: Optional[str] = None, - ): - super().__init__( - repo="epub2tts-edge", - backend_cmd=backend_cmd, - language=language, - voice=voice, - ) + +class TTSEdge(AedocwEpub2TTSEdge): + """Backward-compatible edge backend wrapper.""" + pass class AedocwChatterbox(AedocwBackend): """Backend configured for github.com/aedocw/epub2tts-chatterbox.""" - - def __init__( - self, - backend_cmd: Optional[str] = None, - language: Optional[str] = None, - voice: Optional[str] = None, - ): - super().__init__( - repo="epub2tts-chatterbox", - backend_cmd=backend_cmd, - language=language, - voice=voice, - ) + + DEFAULT_SAMPLE = "none" class AedocwKokoro(AedocwBackend): """Backend configured for github.com/aedocw/epub2tts-kokoro.""" + DEFAULT_SPEAKER = "af_heart" - def __init__( - self, - backend_cmd: Optional[str] = None, - language: Optional[str] = None, - voice: Optional[str] = None, - ): - super().__init__( - repo="epub2tts-kokoro", - backend_cmd=backend_cmd, - language=language, - voice=voice, - ) + + +class Epub2TTS(AedocwBackend): + """Backward-compatible chatterbox backend wrapper.""" + + pass __all__ = [ @@ -147,4 +76,6 @@ __all__ = [ "AedocwEpub2TTSEdge", "AedocwChatterbox", "AedocwKokoro", + "TTSEdge", + "Epub2TTS", ] diff --git a/src/epub_tts/tts_edge.py b/src/epub_tts/tts_edge.py deleted file mode 100644 index c624b66..0000000 --- a/src/epub_tts/tts_edge.py +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# Copyright (c) 2025-2026 Renato Xavier da Silveira Rosa -# See [LICENSE](./LICENSE) or [BSD-3-Clause-Clear](https://spdx.org/licenses/BSD-3-Clause-Clear.html) -# Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> -"""Compatibility shim for the edge backend in epub-tts.""" - -from .tts_aedocw import AedocwEpub2TTSEdge - - -class TTSEdge(AedocwEpub2TTSEdge): - """Backward-compatible edge backend wrapper.""" - - pass diff --git a/src/epub_tts/tts_epub2tts.py b/src/epub_tts/tts_epub2tts.py deleted file mode 100644 index ad538ca..0000000 --- a/src/epub_tts/tts_epub2tts.py +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# Copyright (c) 2025-2026 Renato Xavier da Silveira Rosa -# See [LICENSE](./LICENSE) or [BSD-3-Clause-Clear](https://spdx.org/licenses/BSD-3-Clause-Clear.html) -# Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> -"""Adapter for the vendored github.com/aedocw/epub2tts repository.""" - -import subprocess -from pathlib import Path -from typing import Optional - -from .vendor_utils import build_run_command - -SUPPORTED_AUDIO_FORMATS = { - ".m4b": "m4b", - ".wav": "wav", - ".flac": "flac", -} - - -def _normalize_path(path_input: Path | str) -> Path: - return Path(path_input) - - -def _build_command( - input_source: Path, - output_dest: Path, - language: Optional[str] = None, - voice: Optional[str] = None, - backend_cmd: Optional[str] = None, -) -> list[str]: - command = build_run_command("epub2tts", "epub2tts", backend_cmd=backend_cmd) - command.append(str(input_source)) - - if language: - command.extend(["--language", language]) - - if voice: - command.extend(["--speaker", voice]) - - output_format = SUPPORTED_AUDIO_FORMATS.get(output_dest.suffix.lower(), "m4b") - if output_format != "m4b": - command.extend(["--audioformat", output_format]) - - return command - - -def run( - input_source: Path | str, - output_dest: Path | str, - language: Optional[str] = None, - voice: Optional[str] = None, - backend_cmd: Optional[str] = None, -) -> subprocess.CompletedProcess: - input_path = _normalize_path(input_source) - output_path = _normalize_path(output_dest) - output_path.parent.mkdir(parents=True, exist_ok=True) - - command = _build_command( - input_path, - output_path, - language=language, - voice=voice, - backend_cmd=backend_cmd, - ) - - completed = subprocess.run(command, cwd=str(input_path.parent), check=True) - - generated_path = input_path.with_suffix( - SUPPORTED_AUDIO_FORMATS.get(output_path.suffix.lower(), "m4b") - ) - if generated_path != output_path: - generated_path.replace(output_path) - - return completed diff --git a/src/epub_tts/tts_epub2tts_chatterbox.py b/src/epub_tts/tts_epub2tts_chatterbox.py deleted file mode 100644 index 40d7f27..0000000 --- a/src/epub_tts/tts_epub2tts_chatterbox.py +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# Copyright (c) 2025-2026 Renato Xavier da Silveira Rosa -# See [LICENSE](./LICENSE) or [BSD-3-Clause-Clear](https://spdx.org/licenses/BSD-3-Clause-Clear.html) -# Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> -"""Adapter for the vendored github.com/aedocw/epub2tts-chatterbox repository.""" - -import subprocess -from pathlib import Path -from typing import Optional - -from .vendor_utils import build_run_command - -DEFAULT_SAMPLE = "none" - - -def _normalize_path(path_input: Path | str) -> Path: - return Path(path_input) - - -def _build_command( - input_source: Path, - sample: Optional[str] = None, - backend_cmd: Optional[str] = None, -) -> list[str]: - command = build_run_command( - "epub2tts-chatterbox", - "epub2tts_chatterbox", - backend_cmd=backend_cmd, - ) - command.append(str(input_source)) - if sample and sample != DEFAULT_SAMPLE: - command.extend(["--sample", sample]) - return command - - -def run( - input_source: Path | str, - output_dest: Path | str, - voice: Optional[str] = None, - backend_cmd: Optional[str] = None, -) -> subprocess.CompletedProcess: - input_path = _normalize_path(input_source) - output_path = _normalize_path(output_dest) - output_path.parent.mkdir(parents=True, exist_ok=True) - - sample_text = DEFAULT_SAMPLE - cwd = input_path.parent - if voice: - voice_path = Path(voice) - sample_text = voice_path.name - if voice_path.parent != Path(""): - cwd = str(voice_path.parent) - - command = _build_command(input_path, sample=sample_text, backend_cmd=backend_cmd) - completed = subprocess.run(command, cwd=cwd, check=True) - - generated_path = Path(cwd) / f"{input_path.stem} ({sample_text}).m4b" - if generated_path != output_path: - generated_path.replace(output_path) - - return completed diff --git a/src/epub_tts/tts_epub2tts_edge.py b/src/epub_tts/tts_epub2tts_edge.py deleted file mode 100644 index e3dbcfb..0000000 --- a/src/epub_tts/tts_epub2tts_edge.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# Copyright (c) 2025-2026 Renato Xavier da Silveira Rosa -# See [LICENSE](./LICENSE) or [BSD-3-Clause-Clear](https://spdx.org/licenses/BSD-3-Clause-Clear.html) -# Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> -"""Adapter for the vendored github.com/aedocw/epub2tts-edge repository.""" - -import subprocess -from pathlib import Path -from typing import Optional - -from .vendor_utils import build_run_command - -DEFAULT_SPEAKER = "en-US-AndrewNeural" - - -def _normalize_path(path_input: Path | str) -> Path: - return Path(path_input) - - -def _build_command( - input_source: Path, - voice: Optional[str] = None, - backend_cmd: Optional[str] = None, -) -> list[str]: - command = build_run_command( - "epub2tts-edge", "epub2tts_edge", backend_cmd=backend_cmd - ) - command.append(str(input_source)) - if voice: - command.extend(["--speaker", voice]) - return command - - -def run( - input_source: Path | str, - output_dest: Path | str, - voice: Optional[str] = None, - backend_cmd: Optional[str] = None, -) -> subprocess.CompletedProcess: - input_path = _normalize_path(input_source) - output_path = _normalize_path(output_dest) - output_path.parent.mkdir(parents=True, exist_ok=True) - - speaker = voice or DEFAULT_SPEAKER - command = _build_command(input_path, voice=speaker, backend_cmd=backend_cmd) - completed = subprocess.run(command, cwd=str(input_path.parent), check=True) - - generated_path = input_path.parent / f"{input_path.stem} ({speaker}).m4b" - if generated_path != output_path: - generated_path.replace(output_path) - - return completed diff --git a/src/epub_tts/tts_epub2tts_kokoro.py b/src/epub_tts/tts_epub2tts_kokoro.py deleted file mode 100644 index c16391c..0000000 --- a/src/epub_tts/tts_epub2tts_kokoro.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# Copyright (c) 2025-2026 Renato Xavier da Silveira Rosa -# See [LICENSE](./LICENSE) or [BSD-3-Clause-Clear](https://spdx.org/licenses/BSD-3-Clause-Clear.html) -# Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> -"""Adapter for the vendored github.com/aedocw/epub2tts-kokoro repository.""" - -import subprocess -from pathlib import Path -from typing import Optional - -from .vendor_utils import build_run_command - -DEFAULT_SPEAKER = "af_heart" - - -def _normalize_path(path_input: Path | str) -> Path: - return Path(path_input) - - -def _build_command( - input_source: Path, - voice: Optional[str] = None, - backend_cmd: Optional[str] = None, -) -> list[str]: - command = build_run_command( - "epub2tts-kokoro", "epub2tts_kokoro", backend_cmd=backend_cmd - ) - command.append(str(input_source)) - if voice: - command.extend(["--speaker", voice]) - return command - - -def run( - input_source: Path | str, - output_dest: Path | str, - voice: Optional[str] = None, - backend_cmd: Optional[str] = None, -) -> subprocess.CompletedProcess: - input_path = _normalize_path(input_source) - output_path = _normalize_path(output_dest) - output_path.parent.mkdir(parents=True, exist_ok=True) - - speaker = voice or DEFAULT_SPEAKER - command = _build_command(input_path, voice=speaker, backend_cmd=backend_cmd) - completed = subprocess.run(command, cwd=str(input_path.parent), check=True) - - generated_path = input_path.parent / f"{input_path.stem} ({speaker}).m4b" - if generated_path != output_path: - generated_path.replace(output_path) - - return completed diff --git a/src/epub_tts/tts_generic.py b/src/epub_tts/tts_generic.py index 9c7d48a..a6ca797 100644 --- a/src/epub_tts/tts_generic.py +++ b/src/epub_tts/tts_generic.py @@ -8,16 +8,23 @@ This module provides a generic TTS backend that can be used to convert text to speech using various TTS engines. It defines a `GenericTTSBackend` class that can be initialized with a specific backend command, language, and voice. The class provides methods to normalize paths, determine input and output flags based on the type of input/output (file or directory), build the command for the TTS engine, and run the command. Classes: + GenericTTSBackend: + Parameters: backend_cmd (str): Command or executable for the backend. language (Optional[str]): Language code for TTS output. voice (Optional[str]): Voice name for TTS output. + Methods: _normalize_path(value: PathLike) -> Path: Normalize a string or Path to Path. - _input_flag(input_path: Path) -> str: Return backend flag for input file or directory. - _output_flag(output_path: Path) -> str: Return backend flag for output file or directory. + + gen_input_flag(input_path: Path) -> str: Return backend flag for input file or directory. + + gen_output_flag(output_path: Path) -> str: Return backend flag for output file or directory. + _build_command(input_source: PathLike, output_dest: PathLike) -> list[str]: Build command list for subprocess. + run(input_source: PathLike, output_dest: PathLike) -> subprocess.CompletedProcess: Execute backend command. """ @@ -28,52 +35,91 @@ from typing import Optional, Union PathLike = Union[str, Path] +from .vendor_utils import build_run_command + class GenericTTSBackend: - def __init__( - self, - backend_cmd: str, - language: Optional[str] = None, - voice: Optional[str] = None, - ): - self.backend_cmd = backend_cmd - self.language = language - self.voice = voice + """Generic TTS backend for handling text-to-speech conversion. + + Parameters: + backend_cmd (str): Command or executable for the backend. + language (Optional[str]): Language code for TTS output. + voice (Optional[str]): Voice name for TTS output. + """ + + FLAGS = {} + INTERMEDIATE_TXT = False + DEFAULT_SPEAKER = None + DEFAULT_SAMPLE = None + SUPPORTED_AUDIO_FORMATS = { + # subclasses may override this + ".m4b": "m4b", + ".wav": "wav", + ".flac": "flac", + ".mp3": "mp3", + ".ogg": "ogg", + } + + _INPUT_FLAG_MAP = lambda x: [ + "--input-dir", x] if x else ["--input-file", x] + + _OUTPUT_FLAG_MAP = lambda x: [ + "--output-dir", x] if x else ["--output-file", x] + + def __init__(self,**kwargs): + for k,v in kwargs.items(): + setattr(self, k, v) + def _normalize_path(self, value: PathLike) -> Path: if isinstance(value, Path): - return value - return Path(value) + return value.resolve() + else: + return Path(value).resolve() - def _input_flag(self, input_path: Path) -> str: - return "--input-dir" if input_path.is_dir() else "--input-file" - def _output_flag(self, output_path: Path) -> str: - return "--output-dir" if output_path.is_dir() else "--output-file" + def gen_input_flag(self, input_path: Path) -> str: + return self._INPUT_FLAG_MAP(self._normalize_path(input_path).is_dir()) - def _build_command( - self, input_source: PathLike, output_dest: PathLike - ) -> list[str]: - input_path = self._normalize_path(input_source) - output_path = self._normalize_path(output_dest) - command = [ - self.backend_cmd, - self._input_flag(input_path), - str(input_path), - self._output_flag(output_path), - str(output_path), - ] + def gen_output_flag(self, output_path: Path) -> str: + return self._OUTPUT_FLAG_MAP(self._normalize_path(output_path).is_dir()) - if self.language: - command.extend(["--language", self.language]) - if self.voice: - command.extend(["--voice", self.voice]) + def _build_command(self, input_source: PathLike, output_dest: PathLike) -> list[str]: + command = [self.backend_cmd] + command.extend(self.gen_input_flag(input_source)) + command.extend(self.gen_output_flag(output_dest)) + for flag_name, var_name in self.FLAGS.items(): + if getattr(self, flag_name, None) is not None: + command.extend([ + f"--{flag_name.replace('_', '-')}", + getattr(self, var_name)]) return command + + def run(self, + input_source: PathLike, + output_dest: PathLike, + **kwargs + ) -> subprocess.CompletedProcess: + + - def run( - self, input_source: PathLike, output_dest: PathLike - ) -> subprocess.CompletedProcess: - command = self._build_command(input_source, output_dest) - return subprocess.run(command, check=True) + completed = subprocess.run( + self._build_command(input_source, output_dest), + cwd=str(self._normalize_path(input_source).parent), + check=True) + + if self.INTERMEDIATE_TXT: + txt_file = self._normalize_path(input_source).with_suffix(".txt") + if txt_file.exists(): + completed = subprocess.run( + self._build_command(txt_file, output_dest), + cwd=str(self._normalize_path(input_source).parent), + check=True + ) + + + return completed + + \ No newline at end of file diff --git a/src/epub_tts/vendor_utils.py b/src/epub_tts/vendor_utils.py index 2fa3d10..9ea88ad 100644 --- a/src/epub_tts/vendor_utils.py +++ b/src/epub_tts/vendor_utils.py @@ -64,4 +64,10 @@ def build_run_command( if backend_cmd is None or backend_cmd == repo_name: python_executable = ensure_venv(repo_name) return [str(python_executable), "-m", module_name] - return [backend_cmd] + else: + ensure_venv(repo_name) + venv_path = get_venv_path(repo_name) + return [str( venv_path / "Scripts" / f"{backend_cmd}.exe") + if sys.platform == "win32" + else str(venv_path / "bin" / backend_cmd)] +