diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a6e777d --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore index 3b1cd9e..3150fe2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ __pycache__/ *.py[cod] *.so +*.dylib +*.dll *.egg-info/ tags target/ @@ -11,3 +13,5 @@ venv/ .env .DS_Store .ipynb_checkpoints/ +pyghostty/_lib/* +!pyghostty/_lib/.gitkeep diff --git a/LICENSE.ghostty b/LICENSE.ghostty new file mode 100644 index 0000000..0a07a66 --- /dev/null +++ b/LICENSE.ghostty @@ -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. diff --git a/MANIFEST.in b/MANIFEST.in index 7af66d0..e30bf98 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,5 @@ include README.md include LICENSE +include LICENSE.ghostty include CHANGELOG.md +recursive-exclude pyghostty/_lib * diff --git a/README.md b/README.md index d9e7709..12d7403 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/build_lib.py b/build_lib.py index 0a72b4c..a7a4303 100755 --- a/build_lib.py +++ b/build_lib.py @@ -1,19 +1,38 @@ #!/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 @@ -21,30 +40,41 @@ 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() diff --git a/pyghostty/_lib/.gitkeep b/pyghostty/_lib/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/pyghostty/_lib/.gitkeep @@ -0,0 +1 @@ + diff --git a/pyghostty/_lib/libghostty-vt.dylib b/pyghostty/_lib/libghostty-vt.dylib deleted file mode 100755 index bdae300..0000000 Binary files a/pyghostty/_lib/libghostty-vt.dylib and /dev/null differ diff --git a/pyproject.toml b/pyproject.toml index 7a45a45..e198725 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=68", "wheel"] +requires = ["setuptools>=77", "wheel", "ziglang==0.15.2"] build-backend = "setuptools.build_meta" [project] @@ -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", @@ -21,8 +22,8 @@ dependencies = ["cffi"] dev = [ "fastship", "build", - "twine", - "ziglang~=0.15.2", + "cibuildwheel~=3.4", + "ziglang==0.15.2", "pytest", ] @@ -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" } diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..9434f5c --- /dev/null +++ b/setup.py @@ -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})