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
69 changes: 69 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: CI

on:
push:
branches: [main]
tags: ['v*']
pull_request:

permissions:
contents: read

jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- name: linux-x86_64
os: ubuntu-latest
arch: x86_64
- name: linux-aarch64
os: ubuntu-24.04-arm
arch: aarch64
- name: macos-arm64
os: macos-latest
arch: arm64
- name: macos-x86_64
os: macos-15-intel
arch: x86_64
- name: windows-amd64
os: windows-latest
arch: AMD64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v6
with:
repository: ghostty-org/ghostty
ref: 88b4cd047fa627cdca6781bc7e7dc8b75a2cecb9
path: ghostty-src
- uses: pypa/cibuildwheel@v3.4.1
env:
CIBW_ARCHS: ${{ matrix.arch }}
GHOSTTY_SRC: ghostty-src
with:
output-dir: wheelhouse
- uses: actions/upload-artifact@v7
with:
name: wheels-${{ matrix.name }}
path: wheelhouse/*.whl

publish:
if: startsWith(github.ref, 'refs/tags/v')
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/download-artifact@v8
with:
pattern: wheels-*
path: dist
merge-multiple: true
- uses: softprops/action-gh-release@v3
with:
files: dist/*.whl
generate_release_notes: true
- uses: pypa/gh-action-pypi-publish@release/v1
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
__pycache__/
*.py[cod]
*.so
*.dylib
*.dll
*.egg-info/
tags
target/
Expand All @@ -11,3 +13,5 @@ venv/
.env
.DS_Store
.ipynb_checkpoints/
pyghostty/_lib/*
!pyghostty/_lib/.gitkeep
21 changes: 21 additions & 0 deletions LICENSE.ghostty
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
2 changes: 2 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
include README.md
include LICENSE
include LICENSE.ghostty
include CHANGELOG.md
recursive-exclude pyghostty/_lib *
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Unofficial (but complete) python bindings for [libghostty-vt](https://mitchellh.com/writing/libghostty-is-coming). `libghostty` is Ghostty's embeddable terminal emulation core, a headless, high-fidelity VT emulator for Python. These bindings cover terminal state, screen and scrollback snapshots, kitty graphics, and everything else Ghostty's production terminal core handles.

The binding is ABI-stable: pure Python (cffi ABI mode) over a bundled `libghostty-vt` shared library, so one wheel per platform covers every Python version. No compiler is needed at install time.
The binding is Python-ABI-independent: pure Python (cffi ABI mode) over a bundled `libghostty-vt` shared library, so one wheel per platform covers every Python version. No compiler is needed at install time.

## Usage

Expand All @@ -29,14 +29,20 @@ The shared library is built from a ghostty checkout with the Zig toolchain (inst
GHOSTTY_SRC=/path/to/ghostty python build_lib.py
```

This runs `zig build -Demit-lib-vt=true` in the checkout and copies the resulting shared library into `pyghostty/_lib/`, where the package loader and wheel builds pick it up.
The checkout must be at the revision pinned in `pyproject.toml`. This runs `zig build -Demit-lib-vt=true` and copies the resulting shared library into `pyghostty/_lib/`, where the package loader and wheel builds pick it up.

## Development

```bash
pip install -e .[dev]
GHOSTTY_SRC=/path/to/ghostty python build_lib.py
pytest -q
```

### Versioning

Version lives in `pyghostty/__init__.py` as `__version__`.

### Releases

Pushing a `v*` tag runs `.github/workflows/release.yml`, which builds and tests one Python-ABI-independent wheel for each supported platform and publishes the wheels to GitHub and PyPI. Releases are wheel-only; source remains available from the corresponding GitHub tag.
90 changes: 60 additions & 30 deletions build_lib.py
Original file line number Diff line number Diff line change
@@ -1,50 +1,80 @@
#!/usr/bin/env python3
"Build libghostty-vt from a ghostty checkout and bundle the shared lib into pyghostty/_lib."
import os,shutil,subprocess,sys,tempfile
import importlib.metadata,os,re,shlex,shutil,subprocess,sys,tempfile,tomllib
from pathlib import Path

def _shim_developer_dir(tmp):
# zig 0.15.x can't link against Xcode >=26.4 SDKs (arm64e TBD entries; ziglang/zig#31658,
# fixed only in 0.16, which ghostty doesn't build with yet). Workaround: an xcrun shim that
# answers SDK queries with a pre-26.4 SDK, reached via DEVELOPER_DIR since /usr/bin/xcrun
# re-execs $DEVELOPER_DIR/usr/bin/xcrun. Delete when ghostty moves to zig >=0.16.
sdk = Path(os.environ.get('GHOSTTY_SDK', '/Library/Developer/CommandLineTools/SDKs/MacOSX15.4.sdk'))
ROOT = Path(__file__).parent

def build_config():
with open(ROOT/'pyproject.toml', 'rb') as f: return tomllib.load(f)['tool']['pyghostty']

def _sdk_version(path):
m = re.search(r'MacOSX(\d+(?:\.\d+)*)\.sdk$', str(path.resolve()))
return tuple(int(o) for o in m.group(1).split('.')) if m else ()

def _current_sdk_version():
res = subprocess.run(['xcrun', '--sdk', 'macosx', '--show-sdk-version'], capture_output=True, text=True)
if res.returncode: sys.exit(res.stderr.strip())
return tuple(int(o) for o in res.stdout.strip().split('.'))

# Zig 0.15 cannot link against macOS SDK 26.4+, so route xcrun to the newest older SDK.
def _legacy_sdk():
if p := os.environ.get('GHOSTTY_SDK'): candidates = [Path(p).expanduser()]
else:
candidates = list(Path('/Applications').glob('Xcode*.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX*.sdk'))
candidates += list(Path('/Library/Developer/CommandLineTools/SDKs').glob('MacOSX*.sdk'))
candidates = [p.resolve() for p in candidates if p.exists() and _sdk_version(p) and _sdk_version(p) < (26,4)]
if not candidates: sys.exit("No pre-26.4 macOS SDK found; set GHOSTTY_SDK")
return max(candidates, key=_sdk_version)

def _shim_developer_dir(tmp, sdk):
if not sdk.exists(): sys.exit(f"No pre-26.4 SDK at {sdk}; set GHOSTTY_SDK")
bindir = tmp/'usr'/'bin'
bindir.mkdir(parents=True)
xcrun = bindir/'xcrun'
xcrun.write_text(f"#!/bin/sh\necho {sdk}\n")
xcrun.write_text(f"#!/bin/sh\nprintf '%s\\n' {shlex.quote(str(sdk))}\n")
xcrun.chmod(0o755)
return tmp

def ghostty_src():
"The ghostty checkout to build from: $GHOSTTY_SRC, defaulting to a sibling clone."
src = Path(os.environ.get('GHOSTTY_SRC', '../ghostty')).expanduser().resolve()
if not (src/'build.zig').exists(): sys.exit(f"No ghostty checkout at {src}; set GHOSTTY_SRC")
res = subprocess.run(['git', '-C', str(src), 'rev-parse', 'HEAD'], capture_output=True, text=True)
if res.returncode: sys.exit(res.stderr.strip())
expected = build_config()['ghostty-rev']
if (rev := res.stdout.strip()) != expected: sys.exit(f"Ghostty checkout is {rev}; expected {expected}")
return src

def _built_lib(prefix, returncode):
if sys.platform == 'win32': root,pattern,name = prefix/'bin','ghostty-vt.dll','libghostty-vt.dll'
elif sys.platform == 'darwin': root,pattern,name = prefix/'lib','libghostty-vt*.dylib','libghostty-vt.dylib'
else: root,pattern,name = prefix/'lib','libghostty-vt.so*','libghostty-vt.so'
found = [p for p in root.glob(pattern) if p.is_file() and not p.is_symlink()]
if len(found) != 1: sys.exit(f"Expected one shared library in {root}, found {found} (zig exit {returncode})")
return found[0],name

def _zig_build(src, env, prefix):
return subprocess.run([sys.executable, '-m', 'ziglang', 'build', '-Demit-lib-vt=true', '-Doptimize=ReleaseFast', '--prefix', str(prefix)],
cwd=src, env=env)

def main():
src = ghostty_src()
env = dict(os.environ)
if sys.platform=='darwin':
with tempfile.TemporaryDirectory() as tmp:
env['DEVELOPER_DIR'] = str(_shim_developer_dir(Path(tmp)))
res = _zig_build(src, env)
else: res = _zig_build(src, env)
# The xcrun shim breaks the static-lib/xcframework packaging steps (they need real lipo);
# only the shared lib matters here, so success is judged by the artifact, not the exit code.
dest = Path(__file__).parent/'pyghostty'/'_lib'
dest.mkdir(exist_ok=True)
libs = [p for p in (src/'zig-out'/'lib').iterdir()
if p.suffix in ('.so','.dylib','.dll') and p.is_file() and not p.is_symlink()]
if not libs: sys.exit(f"No shared library found in {src/'zig-out'/'lib'} (zig exit {res.returncode})")
for p in libs:
name = 'libghostty-vt'+p.suffix
shutil.copy2(p, dest/name)
print(f'Bundled: {name} (from {p.name})')

def _zig_build(src, env):
return subprocess.run([sys.executable, '-m', 'ziglang', 'build', '-Demit-lib-vt=true', '-Doptimize=ReleaseFast'],
cwd=src, env=env)
cfg = build_config()
try: zig_version = importlib.metadata.version('ziglang')
except importlib.metadata.PackageNotFoundError: sys.exit(f"Install ziglang=={cfg['zig-version']}")
if zig_version != cfg['zig-version']: sys.exit(f"ziglang is {zig_version}; expected {cfg['zig-version']}")
src,env = ghostty_src(),dict(os.environ)
with tempfile.TemporaryDirectory() as tmp:
tmp,prefix = Path(tmp),Path(tmp)/'out'
if sys.platform == 'darwin' and _current_sdk_version() >= (26,4):
env['DEVELOPER_DIR'] = str(_shim_developer_dir(tmp/'developer', _legacy_sdk()))
# Ghostty also builds static/xcframework outputs which may fail; this package only needs the shared artifact.
res = _zig_build(src, env, prefix)
lib,name = _built_lib(prefix, res.returncode)
dest = ROOT/'pyghostty'/'_lib'
dest.mkdir(exist_ok=True)
for old in dest.glob('libghostty-vt*'): old.unlink()
shutil.copy2(lib, dest/name)
print(f'Bundled: {name} (from {lib.name})')

if __name__=='__main__': main()
1 change: 1 addition & 0 deletions pyghostty/_lib/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Binary file removed pyghostty/_lib/libghostty-vt.dylib
Binary file not shown.
22 changes: 18 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
requires = ["setuptools>=77", "wheel", "ziglang==0.15.2"]
build-backend = "setuptools.build_meta"

[project]
Expand All @@ -8,7 +8,8 @@ dynamic = ["version"]
description = "Python bindings for libghostty"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "Apache-2.0" }
license = "Apache-2.0"
license-files = ["LICENSE", "LICENSE.ghostty"]
authors = [{ name = "pyghostty contributors" }]
classifiers = [
"Programming Language :: Python :: 3",
Expand All @@ -21,8 +22,8 @@ dependencies = ["cffi"]
dev = [
"fastship",
"build",
"twine",
"ziglang~=0.15.2",
"cibuildwheel~=3.4",
"ziglang==0.15.2",
"pytest",
]

Expand All @@ -37,3 +38,16 @@ include = ["pyghostty"]

[tool.setuptools.package-data]
pyghostty = ["_lib/*"]

[tool.pyghostty]
ghostty-rev = "88b4cd047fa627cdca6781bc7e7dc8b75a2cecb9"
zig-version = "0.15.2"

[tool.cibuildwheel]
build = "cp310-*"
skip = "*-musllinux_* *-win32"
test-requires = "pytest"
test-command = "pytest {project}/tests"

[tool.cibuildwheel.macos]
environment = { MACOSX_DEPLOYMENT_TARGET = "13.0" }
19 changes: 19 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import subprocess,sys
from pathlib import Path
from setuptools import setup
from setuptools.command.bdist_wheel import bdist_wheel as _bdist_wheel

class BinaryWheel(_bdist_wheel):
def finalize_options(self):
super().finalize_options()
self.root_is_pure = False

def get_tag(self):
_,_,plat = super().get_tag()
return 'py3','none',plat

def run(self):
subprocess.run([sys.executable, str(Path(__file__).with_name('build_lib.py'))], check=True)
super().run()

setup(cmdclass={'bdist_wheel': BinaryWheel})
Loading