diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b8a5acf..04dca8c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -71,7 +71,14 @@ jobs: - name: Install dev dependencies run: hatch env create dev - - name: Download TeamTalk SDK + - name: Download TeamTalk SDK (Standard) + if: runner.os != 'Windows' + run: hatch run sdk-download + + - name: Download TeamTalk SDK (Pro on Windows) + if: runner.os == 'Windows' + env: + PYTALK_TTSDK_EDITION: pro run: hatch run sdk-download - name: Run a basic import test @@ -80,6 +87,11 @@ jobs: - name: Run precommit and all checks run: hatch run dev:check + - name: Validate Pro DLL copied (Windows) + if: runner.os == 'Windows' + run: | + test -f pytalk/implementation/TeamTalk_DLL/TeamTalk5.dll || (echo "TeamTalk5.dll missing" && exit 1) + build_pytalk: name: Build pytalk needs: [run_tests] diff --git a/.gitignore b/.gitignore index 371e00f..6f347d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ example.py run.bat +.gitconfig pytalk/implementation/TeamTalk* pytalk/ttsdk* diff --git a/README.md b/README.md index b0f8ff8..d9adf4f 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,25 @@ hatch run docs:build hatch run sdk-download ``` +### SDK edition/version + +- By default, Pytalk downloads the latest **Standard** edition published by BearWare. +- To target the **Professional** edition or a specific version, add to `pyproject.toml`: + +```toml +[tool.pytalk] +# sdk_edition: "standard" (default) or "pro" +sdk_edition = "pro" +# sdk_version can be omitted for latest, or fixed (e.g. "v5.19a") +sdk_version = "v5.19a" +``` + +- In CI or temporarily, you can override via environment variables: + - `PYTALK_TTSDK_EDITION=pro` + - `PYTALK_TTSDK_VERSION=v5.19a` + +If you change editions, delete `pytalk/implementation` to force a new download. The Pro edition is subject to TeamTalk Pro licensing terms. + ### Usage ```python diff --git a/pyproject.toml b/pyproject.toml index 003f9c6..3d2e3cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -142,6 +142,7 @@ format = "ruff format ." typecheck = "mypy pytalk" check = "pre-commit run --all-files" hooks = "pre-commit install" +test = "python -m unittest discover -s tests -p 'test*.py'" [tool.hatch.build.targets.wheel] packages = ["pytalk"] diff --git a/pytalk/__init__.py b/pytalk/__init__.py index 5707480..88540cf 100644 --- a/pytalk/__init__.py +++ b/pytalk/__init__.py @@ -3,9 +3,41 @@ import os import sys +from pathlib import Path +from typing import Optional, Tuple +import tomllib from ctypes import * + +def _load_config() -> Tuple[Optional[str], str]: + """Resolve desired SDK version/edition with precedence: env > pyproject > defaults.""" + env_version = os.getenv("PYTALK_TTSDK_VERSION") + env_edition = os.getenv("PYTALK_TTSDK_EDITION") + + cfg_version = None + cfg_edition = None + try: + root = Path(__file__).resolve().parent.parent + pyproject = root / "pyproject.toml" + if pyproject.exists(): + data = tomllib.loads(pyproject.read_text()) + tool = data.get("tool", {}) + pytalk_cfg = tool.get("pytalk", {}) + cfg_version = pytalk_cfg.get("sdk_version") + cfg_edition = pytalk_cfg.get("sdk_edition") + except Exception: + # pyproject is optional at runtime; ignore parse errors gracefully. + pass + + edition = (env_edition or cfg_edition or "standard").lower() + version = env_version or cfg_version + return version, edition + + +_requested_version, _requested_edition = _load_config() + + try: if sys.platform.startswith("linux"): libpath = os.path.join( @@ -19,7 +51,7 @@ except: from .download_sdk import download_sdk - download_sdk() + download_sdk(version=_requested_version, edition=_requested_edition) if sys.platform.startswith("linux"): libpath = os.path.join( os.path.dirname(__file__), diff --git a/pytalk/download_sdk.py b/pytalk/download_sdk.py index 2ee8d75..4a1e9f9 100644 --- a/pytalk/download_sdk.py +++ b/pytalk/download_sdk.py @@ -1,8 +1,17 @@ """Download the TeamTalk SDK and extract it to the implementation directory.""" +import os + from .tools import ttsdk_downloader -def download_sdk() -> None: - """Download the TeamTalk SDK and extract it to the implementation directory.""" - ttsdk_downloader.install() +def download_sdk(version: str | None = None, edition: str | None = None) -> None: + """Download the TeamTalk SDK and extract it to the implementation directory. + + The version and edition can be provided directly or via environment variables: + - PYTALK_TTSDK_VERSION (e.g. 'v5.19a' or '5.19a') + - PYTALK_TTSDK_EDITION ('standard' | 'pro') + """ + requested_version = version or os.getenv("PYTALK_TTSDK_VERSION") + requested_edition = edition or os.getenv("PYTALK_TTSDK_EDITION") + ttsdk_downloader.install(requested_version, requested_edition) diff --git a/pytalk/tools/ttsdk_downloader.py b/pytalk/tools/ttsdk_downloader.py index e2a71d6..f506d60 100644 --- a/pytalk/tools/ttsdk_downloader.py +++ b/pytalk/tools/ttsdk_downloader.py @@ -8,6 +8,7 @@ import shutil import sys from pathlib import Path +from typing import Iterable, List, Optional, Set import bs4 import patoolib @@ -16,9 +17,15 @@ from . import downloader url = "https://bearware.dk/teamtalksdk" -VERSION_IDENTIFIER = "5.19" cd = Path(__file__).parent.parent.resolve() +HEADERS = { + "user-agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36" + ), +} +DEFAULT_EDITION = "standard" def get_url_suffix_from_platform() -> str: @@ -44,27 +51,53 @@ def get_url_suffix_from_platform() -> str: sys.exit("Your architecture is not supported") -def download() -> None: - """Download the TeamTalk SDK from the official website.""" - headers = { - "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36", - } - r = requests.get(url, headers=headers, timeout=10) +def _extract_versions(page: bs4.BeautifulSoup) -> List[str]: + """Return the list of version directory names (e.g. 'v5.19a') ordered as on the page.""" + found: Set[str] = set() + ordered: List[str] = [] + for anchor in page.select("li > a"): + href = anchor.get("href", "").rstrip("/") + if href.startswith("v") and len(href) > 1: + if href not in found: + found.add(href) + ordered.append(href) + return ordered + + +def _normalize_version(requested: Optional[str], available: Iterable[str]) -> str: + versions = list(available) + if not versions: + sys.exit("No TeamTalk SDK versions found on bearware.dk") + if requested is None: + return versions[0] + candidate = requested if requested.startswith("v") else f"v{requested}" + if candidate in versions: + return candidate + sys.exit(f"Version '{requested}' not available. Found: {', '.join(versions)}") + + +def _normalize_edition(edition: Optional[str]) -> str: + if edition is None: + return DEFAULT_EDITION + normalized = edition.lower() + if normalized in {"standard", "std", "community"}: + return "standard" + if normalized in {"professional", "pro"}: + return "pro" + sys.exit("Edition must be 'standard' or 'pro'") + + +def download(version: Optional[str] = None, edition: Optional[str] = None) -> None: + """Download the TeamTalk SDK (Standard or Professional) from the official website.""" + r = requests.get(url, headers=HEADERS, timeout=10) r.raise_for_status() page = bs4.BeautifulSoup(r.text, features="html.parser") - versions = page.find_all("li") - version = [i for i in versions if VERSION_IDENTIFIER in i.text][-1].a.get("href")[ - 0:-1 - ] - download_url = ( - url - + "/" - + version - + "/" - + f"tt5sdk_{version}_{get_url_suffix_from_platform()}.7z" - ) - print("Downloading from " + download_url) + chosen_version = _normalize_version(version, _extract_versions(page)) + chosen_edition = _normalize_edition(edition) + suffix = get_url_suffix_from_platform() + prefix = "tt5prosdk" if chosen_edition == "pro" else "tt5sdk" + download_url = f"{url}/{chosen_version}/{prefix}_{chosen_version}_{suffix}.7z" + print(f"Downloading {chosen_edition} edition from {download_url}") downloader.download_file(download_url, str(cd / "ttsdk.7z")) @@ -78,7 +111,7 @@ def extract() -> None: patoolib.extract_archive(str(cd / "ttsdk.7z"), outdir=str(cd / "ttsdk")) -def move() -> None: +def move(edition: str = DEFAULT_EDITION) -> None: """Move the extracted SDK files to their final destination.""" path = cd / "ttsdk" / next((cd / "ttsdk").iterdir()) libraries = ["TeamTalk_DLL", "TeamTalkPy"] @@ -98,7 +131,21 @@ def move() -> None: except OSError: pass finally: - (cd / "implementation" / "__init__.py").open("w").write("") + with (cd / "implementation" / "__init__.py").open("w") as f: + f.write("") + # Pro edition ships libraries named *Pro; duplicate to legacy names expected by TeamTalkPy. + if edition == "pro": + dll_dir = cd / "implementation" / "TeamTalk_DLL" + copies = [ + ("TeamTalk5Pro.dll", "TeamTalk5.dll"), + ("libTeamTalk5Pro.so", "libTeamTalk5.so"), + ("libTeamTalk5Pro.dylib", "libTeamTalk5.dylib"), + ] + for src, dst in copies: + src_path = dll_dir / src + dst_path = dll_dir / dst + if src_path.exists() and not dst_path.exists(): + shutil.copy2(src_path, dst_path) def clean() -> None: @@ -108,12 +155,12 @@ def clean() -> None: shutil.rmtree(cd / "implementation" / "TeamTalkPy" / "test") -def install() -> None: +def install(version: Optional[str] = None, edition: Optional[str] = None) -> None: """Install the TeamTalk SDK components.""" print("Installing TeamTalk sdk components") try: print("Downloading latest sdk version") - download() + download(version, edition) except requests.exceptions.RequestException as e: print("Failed to download sdk. Error: ", e) sys.exit(1) @@ -133,7 +180,7 @@ def install() -> None: print("On Windows, you need to have 7zip installed and added to your PATH") sys.exit(1) print("Extracted. moving") - move() + move(_normalize_edition(edition)) if not (cd / "implementation" / "TeamTalk_DLL").exists(): print("Failed to move TeamTalk_DLL") sys.exit(1) diff --git a/tests/test_ttsdk_downloader.py b/tests/test_ttsdk_downloader.py new file mode 100644 index 0000000..bc139a9 --- /dev/null +++ b/tests/test_ttsdk_downloader.py @@ -0,0 +1,58 @@ +import shutil +import sys +import tempfile +import types +from pathlib import Path +import unittest + +# Avoid optional runtime deps during import. +sys.modules.setdefault("uvloop", types.SimpleNamespace()) +sys.modules.setdefault( + "patoolib", + types.SimpleNamespace( + extract_archive=lambda *args, **kwargs: None, + util=types.SimpleNamespace(PatoolError=Exception), + ), +) + +from pytalk.tools import ttsdk_downloader + + +class MoveProEditionTest(unittest.TestCase): + def setUp(self) -> None: + self.tempdir = Path(tempfile.mkdtemp()) + # Backup and override module-level cd used by the downloader. + self.original_cd = ttsdk_downloader.cd + ttsdk_downloader.cd = self.tempdir + + # Build minimal extracted layout expected by move(). + extracted = self.tempdir / "ttsdk" / "tt5prosdk_v5.19a_win64" + dll_dir = extracted / "Library" / "TeamTalk_DLL" + dll_dir.mkdir(parents=True, exist_ok=True) + # Create pro-only binaries. + (dll_dir / "TeamTalk5Pro.dll").write_bytes(b"dummy") + (dll_dir / "libTeamTalk5Pro.so").write_bytes(b"dummy") + (dll_dir / "libTeamTalk5Pro.dylib").write_bytes(b"dummy") + # Minimal TeamTalkPy folder to satisfy move(). + tpy_dir = extracted / "Library" / "TeamTalkPy" + tpy_dir.mkdir(parents=True, exist_ok=True) + (tpy_dir / "__init__.py").write_bytes(b"") + + def tearDown(self) -> None: + ttsdk_downloader.cd = self.original_cd + shutil.rmtree(self.tempdir) + + def test_move_copies_pro_binaries_to_legacy_names(self) -> None: + ttsdk_downloader.move("pro") + impl_dir = self.tempdir / "implementation" / "TeamTalk_DLL" + self.assertTrue((impl_dir / "TeamTalk5.dll").exists()) + self.assertTrue((impl_dir / "libTeamTalk5.so").exists()) + self.assertTrue((impl_dir / "libTeamTalk5.dylib").exists()) + # Ensure originals are preserved. + self.assertTrue((impl_dir / "TeamTalk5Pro.dll").exists()) + self.assertTrue((impl_dir / "libTeamTalk5Pro.so").exists()) + self.assertTrue((impl_dir / "libTeamTalk5Pro.dylib").exists()) + + +if __name__ == "__main__": + unittest.main()