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
24 changes: 24 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Publish to PyPI

on:
release:
types: [published]

jobs:
build-and-publish:
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write # required for trusted publishing

steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v3

- name: Build package
run: uv build

- name: Publish to PyPI
run: uv publish
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ It's repetitive. It's inconsistent. And you haven't written a single line of *re
## Prerequisites

- **Python 3.12+** — [Download here](https://python.org/downloads)
> `pip install spawnio` will silently return "no matching distribution" on Python < 3.12. If you're using uv, run `uv python install 3.12` to get a compatible interpreter in seconds.
- **uv** — [Install guide](https://github.com/astral-sh/uv)
- **Git** — [Download here](https://git-scm.com/downloads)

Expand Down Expand Up @@ -765,6 +766,7 @@ All tests should pass. If they don't, please [open an issue](https://github.com/

**Recently shipped**

- Fixed UTF-8 console output crash on Windows (non-terminal stdout), added Windows reserved device name validation, clarified Python 3.12 prerequisite (v1.0.7)
- Removed duplicate banner from `spawn create`; `spawn` alone now shows the banner and command overview (v1.0.6)
- Published to PyPI as `spawnio` — install with `pip install spawnio` (v1.0.5)
- Arrow-key menus, no-args banner, and consistent Ctrl+C handling (v1.0.5)
Expand Down
19 changes: 19 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,25 @@
All notable changes to Spawn are documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## v1.0.7 — 2026

### Fixed

- **UTF-8 console output crash on Windows** — `spawn create` and `spawn doctor` no
longer raise `UnicodeEncodeError` when stdout is redirected or piped (e.g.
`spawn create ... > out.txt`). stdout/stderr are now reconfigured to UTF-8 with
`errors="replace"` at startup, so glyphs like ✨ and 🏥 degrade to `?` instead
of crashing the process.
- **Windows reserved device name validation** — project names matching Windows
reserved device names (`CON`, `PRN`, `AUX`, `NUL`, `COM1`–`COM9`, `LPT1`–`LPT9`,
case-insensitive) are now rejected upfront by `validate_project_name()` with a
clear `SpawnError`. Previously `PRN` raised a raw `NotADirectoryError` traceback.

### Documentation

- Clarified that `pip install spawnio` fails silently with "no matching distribution"
on Python < 3.12, and that `uv python install 3.12` resolves it in seconds.

## v1.0.6 — 2026

### Fixed
Expand Down
4 changes: 3 additions & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
| uv | any | `pip install uv` or [github.com/astral-sh/uv](https://github.com/astral-sh/uv) |
| Git | any | [git-scm.com/downloads](https://git-scm.com/downloads/) |

> **Python 3.12 is required.** `pip install spawnio` will silently return "no matching distribution" on Python < 3.12. If you're using uv, run `uv python install 3.12` to get a compatible interpreter in seconds.

---

## 2. Installation
Expand Down Expand Up @@ -47,7 +49,7 @@ After installation, `spawn` is available anywhere on your machine.

```bash
spawn version
# Spawn v1.0.6
# Spawn v1.0.7
```

Or just run `spawn` with no arguments to see the command overview:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "spawnio"
version = "1.0.6"
version = "1.0.7"
description = "Spawn a ready-to-use Python project in seconds"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
2 changes: 1 addition & 1 deletion src/spawn/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
try:
__version__ = version("spawnio")
except PackageNotFoundError:
__version__ = "1.0.6"
__version__ = "1.0.7"
8 changes: 7 additions & 1 deletion src/spawn/utils/console.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import sys
from rich.console import Console

console = Console()
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace")

console = Console()
16 changes: 15 additions & 1 deletion src/spawn/utils/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,22 @@

from spawn.core.exceptions import SpawnError

_WINDOWS_RESERVED = {
"CON", "PRN", "AUX", "NUL",
*(f"COM{i}" for i in range(1, 10)),
*(f"LPT{i}" for i in range(1, 10)),
}


def validate_project_name(name: str) -> None:
# Check for Windows reserved device names (case-insensitive, strip any
# extension so e.g. "NUL.txt" is caught too).
stem = name.split(".")[0].upper()
if stem in _WINDOWS_RESERVED:
raise SpawnError(
f"'{name}' is a reserved Windows device name and can't be used as a project name."
)

if not re.search(r"[a-zA-Z0-9]", name):
raise SpawnError(
"Project name must contain at least one letter or number."
Expand All @@ -12,4 +26,4 @@ def validate_project_name(name: str) -> None:
if not re.match(r"^[a-zA-Z0-9_-]+$", name):
raise SpawnError(
"Project name can only contain letters, numbers, hyphens (-), and underscores (_)."
)
)
33 changes: 33 additions & 0 deletions tests/test_console.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Tests for spawn.utils.console — encoding safety."""

import io

import pytest

from rich.console import Console


def test_console_prints_unicode_glyphs_without_error():
"""Printing ✨ and 🏥 to a non-terminal byte stream must not raise UnicodeEncodeError.

This simulates the Windows pipe condition (stdout redirected, encoding=cp1252)
by constructing a Console that writes to a BytesIO with latin-1 — characters
outside that range would crash a naive encoder. The fix in console.py uses
errors='replace', so the test confirms no exception is raised.
"""
# BytesIO with a wrapper that rejects non-latin-1 bytes unless replaced
buf = io.BytesIO()
wrapper = io.TextIOWrapper(buf, encoding="latin-1", errors="replace")

# Build a Console pointing at this restricted stream — same parameters
# the real console uses after the reconfigure fix.
test_console = Console(file=wrapper, highlight=False)

# Neither of these characters is representable in latin-1; they must
# degrade to '?' rather than raising UnicodeEncodeError.
try:
test_console.print("✨ Project Created Successfully 🏥")
except UnicodeEncodeError as exc:
pytest.fail(f"UnicodeEncodeError raised when printing glyphs: {exc}")
finally:
wrapper.flush()
22 changes: 22 additions & 0 deletions tests/test_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,25 @@ def test_dot_gives_bad_chars_message():
def test_at_sign_gives_bad_chars_message():
with pytest.raises(SpawnError, match=_BAD_CHARS):
validate_project_name("my@project")


# ---------------------------------------------------------------------------
# Windows reserved device names → "reserved Windows device name"
# ---------------------------------------------------------------------------

_RESERVED = "reserved Windows device name"


@pytest.mark.parametrize("name", [
"CON", "PRN", "AUX", "NUL",
"COM1", "LPT1",
"con", "prn",
])
def test_reserved_windows_name_raises(name):
with pytest.raises(SpawnError, match=_RESERVED):
validate_project_name(name)


def test_valid_name_is_not_caught_as_reserved():
"""A normal name must never be mistaken for a reserved device name."""
validate_project_name("my-project")
Loading