Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
example.py
run.bat
.gitconfig
pytalk/implementation/TeamTalk*
pytalk/ttsdk*

Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
34 changes: 33 additions & 1 deletion pytalk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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__),
Expand Down
15 changes: 12 additions & 3 deletions pytalk/download_sdk.py
Original file line number Diff line number Diff line change
@@ -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)
97 changes: 72 additions & 25 deletions pytalk/tools/ttsdk_downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import shutil
import sys
from pathlib import Path
from typing import Iterable, List, Optional, Set

import bs4
import patoolib
Expand All @@ -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:
Expand All @@ -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"))


Expand All @@ -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"]
Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -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)
Expand Down
58 changes: 58 additions & 0 deletions tests/test_ttsdk_downloader.py
Original file line number Diff line number Diff line change
@@ -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()