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
35 changes: 35 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,38 @@ jobs:
run: bundle exec rake push:all
- name: Wait for release
run: gem exec rubygems-await pkg/*.gem

pypi:
runs-on: ubuntu-latest
defaults:
run:
working-directory: python
steps:
# Set up
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: ".tool-versions"
- uses: actions/setup-node@v6
with:
node-version-file: '.tool-versions'
registry-url: 'https://registry.npmjs.org'
# uv resolves the python version from python/.python-version itself
- uses: astral-sh/setup-uv@v10.3.3
- run: npm ci
working-directory: .
- run: npm run build
working-directory: .
- run: npm ci
working-directory: entrypoint
- name: Set version
run: sed -i "s/^version = \"0.0.0\"/version = \"${GITHUB_REF_NAME#v}\"/" pyproject.toml
- name: Install dependencies
run: uv sync
- name: Build all wheels
run: uv run python tasks.py wheels

# Release
- uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: python/dist
38 changes: 38 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,41 @@ jobs:
run: bundle exec rake
- name: Build all gems
run: bundle exec rake package

python:
runs-on: ubuntu-latest
name: python ${{ matrix.python }}
strategy:
matrix:
python:
- '3.12'
defaults:
run:
working-directory: python

steps:
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: ".tool-versions"
- uses: actions/setup-node@v6
with:
node-version-file: '.tool-versions'
registry-url: 'https://registry.npmjs.org'
- uses: astral-sh/setup-uv@v10.3.3
with:
python-version: ${{ matrix.python }}
- run: npm ci
working-directory: .
- run: npm run build
working-directory: .
- run: npm ci
working-directory: entrypoint
- name: Install dependencies
run: uv sync
- name: Bundle the entrypoints
run: uv run python tasks.py entrypoints
- name: Run the tests
run: uv run pytest
- name: Build all wheels
run: uv run python tasks.py wheels
42 changes: 42 additions & 0 deletions bun.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"comment": "Shared bun release metadata for the ruby gem (gem/rakelib/bun.rb) and the python package (python/hatch_build.py). Bump the version and ALL checksums together.",
"version": "v1.3.11",
"platforms": [
{
"zip": "bun-darwin-aarch64.zip",
"sha256": "6f5a3467ed9caec4795bf78cd476507d9f870c7d57b86c945fcb338126772ffc",
"gem": "arm64-darwin",
"wheel": "macosx_11_0_arm64"
},
{
"zip": "bun-darwin-x64-baseline.zip",
"sha256": "fb6739b08bf54550edaa7c824cd5b2dca45b6a06afef408443087a63105f6f8d",
"gem": "x86_64-darwin",
"wheel": "macosx_10_12_x86_64"
},
{
"zip": "bun-linux-x64-baseline.zip",
"sha256": "abe346f63414547cdf6b35b7a649a490c728b93d006226156923918a84c0e59b",
"gem": "x86_64-linux-gnu",
"wheel": "manylinux_2_17_x86_64"
},
{
"zip": "bun-linux-x64-musl-baseline.zip",
"sha256": "2fa2b697f14ada86a28df771d3876ca7606d7453b2339454893b1937aa9c0c7e",
"gem": "x86_64-linux-musl",
"wheel": "musllinux_1_2_x86_64"
},
{
"zip": "bun-linux-aarch64.zip",
"sha256": "d13944da12a53ecc74bf6a720bd1d04c4555c038dfe422365356a7be47691fdf",
"gem": "aarch64-linux-gnu",
"wheel": "manylinux_2_17_aarch64"
},
{
"zip": "bun-linux-aarch64-musl.zip",
"sha256": "0f5bf5dc3f276053196274bb84f90a44e2fa40c9432bd6757e3247a8d9476a3d",
"gem": "aarch64-linux-musl",
"wheel": "musllinux_1_2_aarch64"
}
]
}
20 changes: 9 additions & 11 deletions gem/rakelib/bun.rb
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
# frozen_string_literal: true

BUN_VERSION = 'v1.3.11'
require 'json'

# Bun release metadata is shared with the python package, see <repo root>/bun.json
BUN_METADATA = JSON.parse(File.read(File.expand_path('../../bun.json', __dir__))).freeze

BUN_VERSION = BUN_METADATA['version']

# rubocop:disable Layout/LineLength
# rubygems platform name => [bun release zip filename, sha256 checksum]
BUN_PLATFORMS = {
'arm64-darwin' => %w[bun-darwin-aarch64.zip 6f5a3467ed9caec4795bf78cd476507d9f870c7d57b86c945fcb338126772ffc],
'x86_64-darwin' => %w[bun-darwin-x64-baseline.zip fb6739b08bf54550edaa7c824cd5b2dca45b6a06afef408443087a63105f6f8d],
'x86_64-linux-gnu' => %w[bun-linux-x64-baseline.zip abe346f63414547cdf6b35b7a649a490c728b93d006226156923918a84c0e59b],
'x86_64-linux-musl' => %w[bun-linux-x64-musl-baseline.zip 2fa2b697f14ada86a28df771d3876ca7606d7453b2339454893b1937aa9c0c7e],
'aarch64-linux-gnu' => %w[bun-linux-aarch64.zip d13944da12a53ecc74bf6a720bd1d04c4555c038dfe422365356a7be47691fdf],
'aarch64-linux-musl' => %w[bun-linux-aarch64-musl.zip 0f5bf5dc3f276053196274bb84f90a44e2fa40c9432bd6757e3247a8d9476a3d]
}.freeze
# rubocop:enable Layout/LineLength
BUN_PLATFORMS = BUN_METADATA['platforms'].to_h do |platform|
[platform['gem'], [platform['zip'], platform['sha256']]]
end.freeze

def bun_download_url(filename)
"https://github.com/oven-sh/bun/releases/download/bun-#{BUN_VERSION}/#{filename}"
Expand Down
10 changes: 10 additions & 0 deletions python/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Bundled JS entrypoints, built by `python tasks.py entrypoints`
/src/triangulum/js/*.js
# Bun binaries, downloaded by `python tasks.py download`
/vendor/
# Build output
/dist/
*.egg-info/
__pycache__/
.pytest_cache/
.venv/
1 change: 1 addition & 0 deletions python/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
75 changes: 75 additions & 0 deletions python/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Triangulum Python Package

Python bindings for the [Triangulum](https://github.com/code0-tech/triangulum) validation layer. This package wraps the TypeScript library and a bundled [Bun](https://bun.sh) runtime to validate flows using the TypeScript compiler.

## Installation

```bash
pip install triangulum # or: uv add triangulum
```

Platform specific wheels with a bundled bun binary are published for:

- `macosx_11_0_arm64` (macOS Apple Silicon)
- `macosx_10_12_x86_64` (macOS Intel)
- `manylinux_2_17_x86_64` (Linux x64)
- `musllinux_1_2_x86_64` (Linux x64 musl)
- `manylinux_2_17_aarch64` (Linux ARM64)
- `musllinux_1_2_aarch64` (Linux ARM64 musl)

For any other platform pip falls back to the pure python wheel, which requires `bun` on `PATH`.

## Usage

```python
from triangulum import Validation

result = Validation(flow, function_definitions, data_types).validate()

result.is_valid # => True / False
result.return_type # => "void"
result.diagnostics # => [Diagnostic, ...]
```

The arguments are [Tucana](https://github.com/code0-tech/tucana) protobuf messages:

- `flow` — `tucana.generated.shared.flow_pb2.ValidationFlow`
- `function_definitions` — `list[tucana.generated.shared.function_pb2.FunctionDefinition]`
- `data_types` — `list[tucana.generated.shared.data_type_pb2.DefinitionDataType]`

### Diagnostics

Each diagnostic contains:

| Field | Description |
|---|---|
| `message` | Human-readable error description |
| `code` | TypeScript diagnostic code |
| `severity` | `"error"` or `"warning"` |
| `node_id` | ID of the node that caused the error |
| `parameter_index` | Index of the parameter that caused the error |

## Development

Prerequisites: [Bun](https://bun.sh) installed locally, and the TypeScript package built in the repository root (`npm ci && npm run build`, plus `npm ci` in `entrypoint/`).

```bash
cd python
uv sync
uv run python tasks.py entrypoints # bundles the TypeScript entrypoint into JS
uv run pytest
```

### Building wheels

```bash
uv run python tasks.py wheels
```

This bundles the entrypoint, downloads the bun binaries (with SHA256 checksum verification) for all supported platforms and writes the sdist, the pure python wheel and one wheel per platform to `dist/`.

Bun's version and checksums live in [`bun.json`](../bun.json) in the repository root, shared with the ruby gem.

## License

See [LICENSE](../LICENSE).
134 changes: 134 additions & 0 deletions python/hatch_build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""Wheel build hook that bundles a platform specific bun binary.

The pure python wheel built without ``TRIANGULUM_WHEEL_PLATFORM`` expects ``bun``
on ``PATH``. Setting the variable to one of the keys of :data:`BUN_PLATFORMS`
produces a platform specific wheel with the matching bun binary embedded at
``triangulum/exe/bun``, which mirrors the platform gems built by ``gem/``.

Binaries are not downloaded during the build; run ``python tasks.py download``
first (``tasks.py wheels`` does both).
"""

import hashlib
import json
import os
import shutil
import stat
import urllib.request
import zipfile
from pathlib import Path

from hatchling.builders.hooks.plugin.interface import BuildHookInterface

HERE = Path(__file__).parent

# Bun release metadata is shared with the ruby gem, see <repo root>/bun.json.
# It only exists in a repository checkout, not in the published sdist, so the
# metadata is loaded lazily and only platform wheels depend on it.
BUN_METADATA_PATH = HERE.parent / "bun.json"

VENDOR_DIR = HERE / "vendor" / "exe"

ENTRYPOINTS = ("single-validation",)


def _load_metadata():
if not BUN_METADATA_PATH.is_file():
raise RuntimeError(
f"{BUN_METADATA_PATH} not found. Platform wheels can only be built "
f"from a triangulum repository checkout."
)
return json.loads(BUN_METADATA_PATH.read_text())


def bun_version():
return _load_metadata()["version"]


def bun_platforms():
"""wheel platform tag => (bun release zip filename, sha256 checksum)"""
return {p["wheel"]: (p["zip"], p["sha256"]) for p in _load_metadata()["platforms"]}


def bun_download_url(filename):
return f"https://github.com/oven-sh/bun/releases/download/bun-{bun_version()}/{filename}"


def bun_path(platform):
return VENDOR_DIR / platform / "bun"


def download_bun(platform):
"""Download and extract the bun binary for ``platform``, verifying its checksum."""
zip_filename, expected_checksum = bun_platforms()[platform]
destination = bun_path(platform)

if destination.is_file():
return destination

url = bun_download_url(zip_filename)
print(f"Downloading {destination} from {url} ...")

with urllib.request.urlopen(url) as response:
zip_data = response.read()

actual_checksum = hashlib.sha256(zip_data).hexdigest()
if actual_checksum != expected_checksum:
raise RuntimeError(
f"Checksum mismatch for {zip_filename}: "
f"expected {expected_checksum}, got {actual_checksum}"
)

destination.parent.mkdir(parents=True, exist_ok=True)
tmp_zip = destination.with_suffix(".zip")
tmp_zip.write_bytes(zip_data)
try:
with zipfile.ZipFile(tmp_zip) as archive:
entry = next(
(e for e in archive.namelist() if Path(e).name == "bun"),
None,
)
if entry is None:
raise RuntimeError(f"bun binary not found in {zip_filename}")

with archive.open(entry) as source, destination.open("wb") as target:
shutil.copyfileobj(source, target)
finally:
tmp_zip.unlink()

destination.chmod(destination.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)

return destination


class CustomBuildHook(BuildHookInterface):
def initialize(self, version, build_data):
# An editable install is used for development, where the entrypoints are
# bundled separately via `python tasks.py entrypoints`.
for entrypoint in ENTRYPOINTS if version != "editable" else ():
bundle = HERE / "src" / "triangulum" / "js" / f"{entrypoint}.js"
if not bundle.is_file():
raise RuntimeError(
f"{bundle} is missing. Run `python tasks.py entrypoints` before building."
)

platform = os.environ.get("TRIANGULUM_WHEEL_PLATFORM")
if not platform:
return

platforms = bun_platforms()
if platform not in platforms:
raise RuntimeError(
f"Unknown TRIANGULUM_WHEEL_PLATFORM {platform!r}, "
f"expected one of {', '.join(sorted(platforms))}"
)

binary = bun_path(platform)
if not binary.is_file():
raise RuntimeError(
f"{binary} is missing. Run `python tasks.py download` before building."
)

build_data["pure_python"] = False
build_data["tag"] = f"py3-none-{platform}"
build_data["force_include"][str(binary)] = "triangulum/exe/bun"
Loading
Loading