Skip to content
Merged
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ All notable changes to **Pipecat** will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.2.2] - 2026-05-19

### Fixed

- `pipecat init` no longer fails on Windows when the system default
encoding is not UTF-8 (e.g. cp1252). Generated text files and template
reads in `generators/project.py` now explicitly use UTF-8, preserving
Unicode characters such as `→` that appear in cascade-mode templates.

## [1.2.1] - 2026-05-15

### Changed
Expand Down
22 changes: 11 additions & 11 deletions src/pipecat_cli/generators/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,12 +177,12 @@ def _generate_server_files(self, project_path: Path) -> None:
# Generate server.py
server_template = self.env.get_template(server_template_name)
server_content = server_template.render()
(project_path / "server.py").write_text(server_content)
(project_path / "server.py").write_text(server_content, encoding="utf-8")

# Generate server_utils.py
utils_template = self.env.get_template(utils_template_name)
utils_content = utils_template.render()
(project_path / "server_utils.py").write_text(utils_content)
(project_path / "server_utils.py").write_text(utils_content, encoding="utf-8")

def _needs_aiohttp_session(self) -> bool:
"""Check if any selected service requires an aiohttp session."""
Expand Down Expand Up @@ -281,7 +281,7 @@ def _generate_bot_file(self, project_path: Path) -> None:
# Render and write
content = template.render(**context)
bot_file = project_path / "bot.py"
bot_file.write_text(content)
bot_file.write_text(content, encoding="utf-8")

def _generate_pyproject(self, project_path: Path) -> None:
"""Generate pyproject.toml with dependencies."""
Expand Down Expand Up @@ -318,7 +318,7 @@ def _generate_pyproject(self, project_path: Path) -> None:
}

content = template.render(**context)
(project_path / "pyproject.toml").write_text(content)
(project_path / "pyproject.toml").write_text(content, encoding="utf-8")

def _generate_env_example(self, project_path: Path) -> None:
"""Generate .env.example with required API keys."""
Expand All @@ -337,7 +337,7 @@ def _generate_env_example(self, project_path: Path) -> None:
}

content = template.render(**context)
(project_path / ".env.example").write_text(content)
(project_path / ".env.example").write_text(content, encoding="utf-8")

def _generate_gitignore(self, project_path: Path) -> None:
"""Generate .gitignore file."""
Expand All @@ -346,7 +346,7 @@ def _generate_gitignore(self, project_path: Path) -> None:
"generate_client": self.config.generate_client,
}
content = template.render(**context)
(project_path / ".gitignore").write_text(content)
(project_path / ".gitignore").write_text(content, encoding="utf-8")

def _get_service_label(self, service_value: str | None, service_list: list) -> str | None:
"""Get human-readable label for a service value."""
Expand Down Expand Up @@ -412,7 +412,7 @@ def _generate_readme(self, project_path: Path) -> None:
}

content = template.render(**context)
(project_path / "README.md").write_text(content)
(project_path / "README.md").write_text(content, encoding="utf-8")

def _generate_dockerfile(self, project_path: Path) -> None:
"""Generate Dockerfile for Pipecat Cloud deployment."""
Expand All @@ -425,7 +425,7 @@ def _generate_dockerfile(self, project_path: Path) -> None:
}

content = template.render(**context)
(project_path / "Dockerfile").write_text(content)
(project_path / "Dockerfile").write_text(content, encoding="utf-8")

def _generate_pcc_deploy(self, project_path: Path) -> None:
"""Generate pcc-deploy.toml for Pipecat Cloud deployment."""
Expand All @@ -437,7 +437,7 @@ def _generate_pcc_deploy(self, project_path: Path) -> None:
}

content = template.render(**context)
(project_path / "pcc-deploy.toml").write_text(content)
(project_path / "pcc-deploy.toml").write_text(content, encoding="utf-8")

def print_next_steps(self, project_path: Path) -> None:
"""Print next steps for the user."""
Expand Down Expand Up @@ -654,7 +654,7 @@ def _render_client_template(self, template_file: Path, dest_file: Path) -> None:
dest_file: Destination file path (without .jinja2)
"""
# Create Jinja2 environment for this specific file
template_content = template_file.read_text()
template_content = template_file.read_text(encoding="utf-8")
from jinja2 import Template

try:
Expand All @@ -675,7 +675,7 @@ def _render_client_template(self, template_file: Path, dest_file: Path) -> None:

# Render and write
rendered = template.render(**context)
dest_file.write_text(rendered)
dest_file.write_text(rendered, encoding="utf-8")

def _format_python_files(self, project_path: Path) -> None:
"""Format generated Python files with Ruff."""
Expand Down
67 changes: 64 additions & 3 deletions tests/test_project_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,9 @@ def test_project_generation(config_data, temp_output_dir):

# Verify core files exist (in monorepo structure)
assert (project_path / "server" / "bot.py").exists(), "server/bot.py should exist"
assert (project_path / "server" / "pyproject.toml").exists(), "server/pyproject.toml should exist"
assert (project_path / "server" / "pyproject.toml").exists(), (
"server/pyproject.toml should exist"
)
assert (project_path / "server" / ".env.example").exists(), "server/.env.example should exist"
assert (project_path / ".gitignore").exists(), ".gitignore should exist"
assert (project_path / "README.md").exists(), "README.md should exist"
Expand Down Expand Up @@ -511,12 +513,14 @@ def test_project_generation(config_data, temp_output_dir):
elif config.video_service == "heygen_video":
assert "HeyGenVideoService" in bot_content, "HeyGenVideoService should be imported"
assert "heygen" in pyproject_content, "heygen extra should be in dependencies"
assert "NewSessionRequest" in bot_content, "NewSessionRequest should be imported for HeyGen"
assert "NewSessionRequest" in bot_content, (
"NewSessionRequest should be imported for HeyGen"
)
assert "AvatarQuality" in bot_content, "AvatarQuality should be imported for HeyGen"
elif config.video_service == "simli_video":
assert "SimliVideoService" in bot_content, "SimliVideoService should be imported"
assert "simli" in pyproject_content, "simli extra should be in dependencies"

# Video service should be initialized in bot.py
assert "video" in bot_content.lower(), "video service variable should be present"

Expand Down Expand Up @@ -594,6 +598,63 @@ def test_project_name_conflict(temp_output_dir):
# that interactively here. This test just verifies the first generation works.


def test_generation_uses_utf8_on_windows_locale(monkeypatch, temp_output_dir):
"""Regression test for pipecat-ai/pipecat#4523.

On Windows, ``Path.write_text(data)`` without an explicit ``encoding``
falls back to the locale codec (cp1252), which cannot encode the ``→``
characters in cascade-mode templates such as ``bot_cascade.py.jinja2``
and ``README.md.jinja2``. This test simulates that environment by
patching ``Path.write_text`` / ``Path.read_text`` to fail when
``encoding`` is omitted, then runs the quickstart configuration end to
end and verifies the arrow-bearing files were written intact.
"""
from pathlib import Path

original_write = Path.write_text
original_read = Path.read_text

def patched_write(self, data, *args, **kwargs):
if kwargs.get("encoding") is None and (len(args) == 0 or args[0] is None):
# Simulate Windows cp1252 fallback — fails on '→' (U+2192).
data.encode("cp1252")
return original_write(self, data, *args, **kwargs)

def patched_read(self, *args, **kwargs):
if kwargs.get("encoding") is None and (len(args) == 0 or args[0] is None):
# Reading a UTF-8 template containing '→' as cp1252 raises
# UnicodeDecodeError on Windows. Force the same failure mode.
return original_read(self, *args, encoding="cp1252", **kwargs)
return original_read(self, *args, **kwargs)

monkeypatch.setattr(Path, "write_text", patched_write)
monkeypatch.setattr(Path, "read_text", patched_read)

# Mirror the quickstart_command config from commands/init.py.
config = ProjectConfig(
project_name="pipecat-quickstart",
bot_type="web",
transports=["smallwebrtc", "daily"],
mode="cascade",
stt_service="deepgram_stt",
llm_service="openai_responses_llm",
tts_service="cartesia_tts",
deploy_to_cloud=True,
)
ProjectGenerator(config).generate(output_dir=temp_output_dir, non_interactive=True)

project = temp_output_dir / "pipecat-quickstart"
bot = project / "server" / "bot.py"
readme = project / "README.md"

assert bot.exists(), "bot.py was not written"
assert readme.exists(), "README.md was not written"
# The cascade-mode template carries '→' in a code comment; confirm it
# survived the cp1252-simulating environment.
assert "→" in bot.read_text(encoding="utf-8")
assert "→" in readme.read_text(encoding="utf-8")


def test_invalid_service_combination():
"""Test that invalid service combinations are caught."""
# This test would check validation logic if we add it
Expand Down
Loading