Skip to content
Merged
51 changes: 50 additions & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,65 @@ on:
workflow_dispatch:

jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24.4'

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

- name: Build package
run: |
uv venv
uv build

- name: Install package and test
if: matrix.os != 'windows-latest'
run: |
source .venv/bin/activate
uv pip install --no-deps --force-reinstall dist/*.whl
python tests/test_yamlfmt.py

- name: Install package on Windows and test
if: matrix.os == 'windows-latest'
run: |
.venv\Scripts\activate.ps1
$whl = Get-ChildItem dist\*.whl | Select-Object -First 1
uv pip install --no-deps --force-reinstall $whl.FullName
python tests\test_yamlfmt.py


release-build:
runs-on: ${{ matrix.platform.runner }}
strategy:
matrix:
platform:
- {"runner": "ubuntu-latest", "platform_tag": "musllinux_1_2", "arch": "x86_64"}
- {"runner": "ubuntu-latest", "platform_tag": "musllinux_1_2", "arch": "i686"}
- {"runner": "ubuntu-latest", "platform_tag": "musllinux_1_2", "arch": "armv7l"}
- {"runner": "ubuntu-latest", "platform_tag": "musllinux_1_2", "arch": "aarch64"}
- {"runner": "ubuntu-latest", "platform_tag": "manylinux_2_17", "arch": "x86_64"}
- {"runner": "ubuntu-latest", "platform_tag": "manylinux_2_17", "arch": "aarch64"}
- {"runner": "windows-latest", "platform_tag": "win", "arch": "amd64"}
- {"runner": "windows-latest", "platform_tag": "win", "arch": "arm64"}
- {"runner": "macos-latest", "platform_tag": "macosx_10_9", "arch": "x86_64"}
- {"runner": "macos-latest", "platform_tag": "macosx_10_12", "arch": "x86_64"}
- {"runner": "macos-latest", "platform_tag": "macosx_11_0", "arch": "arm64"}
steps:
- name: Checkout
uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: '1.24.4'

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

Expand All @@ -50,6 +92,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: '1.24.4'

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

Expand All @@ -75,6 +122,7 @@ jobs:
name: Publish to PyPI
if: startsWith(github.ref, 'refs/tags/')
needs:
- test
- release-build
- sdist
permissions:
Expand All @@ -98,6 +146,7 @@ jobs:
name: Publish to GitHub
if: startsWith(github.ref, 'refs/tags/')
needs:
- test
- release-build
permissions:
contents: write
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ wheels/

# Virtual environments
.venv
src/yamlfmt/_version.py
.python-version
.ruff_cache

# Configuration files
src/yamlfmt/yamlfmt
src/yamlfmt/_version.py
160 changes: 109 additions & 51 deletions hatch_build.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,45 @@
from __future__ import annotations

import os
import platform
import re
import shutil
import tarfile
import subprocess
import tempfile
from pathlib import Path
from typing import Any
from urllib import request

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

PY_PLATFORM_MAPPING = {
("Linux", "x86_64"): ("musllinux_1_2", "x86_64"),
("Linux", "i686"): ("musllinux_1_2", "i686"),
("Linux", "armv7l"): ("musllinux_1_2", "armv7l"),
("Linux", "aarch64"): ("musllinux_1_2", "aarch64"),
("Darwin", "x86_64"): ("macosx_10_12", "x86_64"),
("Darwin", "arm64"): ("macosx_11_0", "arm64"),
("Windows", "AMD64"): ("win", "amd64"),
("Windows", "ARM64"): ("win", "arm64"),
}

# key 为 pypi 分发的系统和架构组合
BUILD_TARGET = {
("musllinux_1_2", "x86_64"): {"download_file": ("linux", "x86_64")},
("musllinux_1_2", "aarch64"): {"download_file": ("linux", "arm64")},
("manylinux_2_17", "x86_64"): {"download_file": ("linux", "x86_64")},
("manylinux_2_17", "aarch64"): {"download_file": ("linux", "arm64")},
("macosx_10_9", "x86_64"): {"download_file": ("darwin", "x86_64")},
("macosx_11_0", "arm64"): {"download_file": ("darwin", "arm64")},
("win", "amd64"): {"download_file": ("windows", "x86_64")},
("win", "arm64"): {"download_file": ("windows", "arm64")},
("musllinux_1_2", "x86_64"): ("linux", "amd64"),
("musllinux_1_2", "i686"): ("linux", "386"),
("musllinux_1_2", "armv7l"): ("linux", "arm"),
("musllinux_1_2", "aarch64"): ("linux", "arm64"),
("manylinux_2_17", "x86_64"): ("linux", "amd64"),
("manylinux_2_17", "aarch64"): ("linux", "arm64"),
("macosx_10_12", "x86_64"): ("darwin", "amd64"),
("macosx_11_0", "arm64"): ("darwin", "arm64"),
("win", "amd64"): ("windows", "amd64"),
("win", "arm64"): ("windows", "arm64"),
}


class SpecialBuildHook(BuildHookInterface):
BIN_NAME = "yamlfmt"
YAMLFMT_REPO = "https://github.com/google/yamlfmt/releases/download/v{version}/yamlfmt_{version}_{target_os_info}_{target_arch}.tar.gz"
YAMLFMT_REPO = "https://github.com/google/yamlfmt.git"

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
Expand All @@ -37,56 +50,101 @@ def initialize(self, version: str, build_data: dict[str, Any]) -> None:
if self.target_name != "wheel":
return

target_arch = os.environ.get("CIBW_ARCHS", None)
target_os_info = os.environ.get("CIBW_PLATFORM", None)
# 获取系统信息
system_info = get_system_info()
default_os_mapping = (None, None)

assert target_arch is not None, f"CIBW_ARCHS not set see: {BUILD_TARGET}"
assert target_os_info is not None, f"CIBW_PLATFORM not set see: {BUILD_TARGET}"
# 获取目标架构和平台信息
target_arch = os.environ.get("CIBW_ARCHS") or PY_PLATFORM_MAPPING.get(system_info, default_os_mapping)[1]
target_os_info = os.environ.get("CIBW_PLATFORM") or PY_PLATFORM_MAPPING.get(system_info, default_os_mapping)[0]

if (target_os_info, target_arch) not in BUILD_TARGET:
raise ValueError(f"Unsupported target: {target_os_info}, {target_arch}")
# 确保目标架构和平台信息有效
assert target_arch is not None, (
f"CIBW_ARCHS not set and no mapping found in PY_PLATFORM_MAPPING for: {system_info}"
)
assert target_os_info is not None, (
f"CIBW_PLATFORM not set and no mapping found in PY_PLATFORM_MAPPING for: {system_info}"
)

assert (target_os_info, target_arch) in BUILD_TARGET, f"Unsupported target: {target_os_info}, {target_arch}"

# 构建完整的 Wheel 标签
full_wheel_tag = f"py3-none-{target_os_info}_{target_arch}"
build_data["tag"] = full_wheel_tag

# 下载 yamlfmt 可执行文件
tar_gz_file = self.download_yamlfmt(target_os_info, target_arch)

# 解压缩文件
with tarfile.open(tar_gz_file, "r:gz") as tar:
if target_os_info == "win":
# Windows 上的文件名是 yamlfmt.exe
assert f"{self.BIN_NAME}.exe" in tar.getnames()
tar.extract(f"{self.BIN_NAME}.exe", path=self.temp_dir)
# 重命名为 yamlfmt
(self.temp_dir / f"{self.BIN_NAME}.exe").rename(self.temp_dir / self.BIN_NAME)
else:
assert self.BIN_NAME in tar.getnames()
tar.extract(self.BIN_NAME, path=self.temp_dir)

# TODO: 加一个 sum 校验
# 构建 yamlfmt 二进制文件
self.build_yamlfmt(target_os_info, target_arch)

# 将构建好的二进制文件添加到 wheel 中
bin_path = self.temp_dir / self.BIN_NAME
assert bin_path.is_file(), f"{self.BIN_NAME} not found"
build_data["force_include"][f"{bin_path.resolve()}"] = f"yamlfmt/{self.BIN_NAME}"

def download_yamlfmt(self, target_os_info: str, target_arch: str) -> None:
"""Download the yamlfmt binary for the specified OS and architecture."""
download_target = BUILD_TARGET[(target_os_info, target_arch)]["download_file"]
file_path = self.temp_dir / f"{self.BIN_NAME}_{download_target[0]}_{download_target[1]}.tar.gz"
request.urlretrieve(
self.YAMLFMT_REPO.format(
version=re.sub(
r"(?:a|b|rc)\d+|\.post\d+|\.dev\d+$", "", self.metadata.version
), # 去掉版本号中的后缀, alpha/beta/rc/post/dev
target_os_info=download_target[0],
target_arch=download_target[1],
),
file_path,

assert bin_path.is_file(), f"{self.BIN_NAME} not found after build"
build_data["force_include"][str(bin_path.resolve())] = f"yamlfmt/{self.BIN_NAME}"

def build_yamlfmt(self, target_os_info: str, target_arch: str) -> None:
"""Build the yamlfmt binary for the specified OS and architecture."""
# 确认环境安装
for command in ["go", "make", "git"]:
assert shutil.which(command), f"{command} is not installed or not found in PATH"
Copy link

Copilot AI Jul 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using assert for runtime validations can be disabled with optimizations. Replace with explicit exception (e.g., if not shutil.which(...) raise RuntimeError(...)) to enforce required tools are present.

Suggested change
assert shutil.which(command), f"{command} is not installed or not found in PATH"
if not shutil.which(command):
raise RuntimeError(f"{command} is not installed or not found in PATH")

Copilot uses AI. Check for mistakes.

build_target = BUILD_TARGET[(target_os_info, target_arch)]

# 编译逻辑可以在这里添加
version = re.sub(
r"(?:a|b|rc)\d+|\.post\d+|\.dev\d+$", "", self.metadata.version
) # 去掉版本号中的后缀, alpha/beta/rc/post/dev

# clone repo
subprocess.run(
[
"git",
"clone",
"--depth",
"1",
"--branch",
f"v{version}",
self.YAMLFMT_REPO,
str(self.temp_dir / f"yamlfmt-{version}"),
],
check=True,
)
return file_path

# 编译
env = os.environ.copy()
env.update({"GOOS": build_target[0], "GOARCH": build_target[1]})
if target_arch == "armv7l":
env.update({"GOARM": "7"})

# 检查工作目录是否存在
work_dir = self.temp_dir / f"yamlfmt-{version}"
assert work_dir.exists(), f"Working directory {work_dir} does not exist"

subprocess.run(
["make", "build"],
env=env,
cwd=work_dir,
capture_output=True,
text=True,
check=True,
)

# 检查生成的二进制文件是否存在
bin_path = work_dir / "dist" / self.BIN_NAME
Copy link

Copilot AI Jul 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On Windows the built binary is likely yamlfmt.exe; update this path or branch logic to handle the .exe extension.

Suggested change
bin_path = work_dir / "dist" / self.BIN_NAME
# Adjust binary name for Windows
binary_name = self.BIN_NAME + ".exe" if build_target[0] == "windows" else self.BIN_NAME
bin_path = work_dir / "dist" / binary_name

Copilot uses AI. Check for mistakes.
assert bin_path.exists(), f"Binary file {bin_path} was not created"

# 将二进制文件复制到临时目录的根目录,供后续使用
shutil.copy2(bin_path, self.temp_dir / self.BIN_NAME)

def finalize(self, version, build_data, artifact_path):
# 清理临时目录
shutil.rmtree(self.temp_dir)
try:
shutil.rmtree(self.temp_dir)
except (OSError, PermissionError) as e:
print(f"Warning: Failed to remove temp directory {self.temp_dir}: {e}")
super().finalize(version, build_data, artifact_path)


def get_system_info():
system = platform.system()
machine = platform.machine()
return system, machine
52 changes: 52 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Test Configuration for yamlfmt

This directory contains tests for the yamlfmt cross-platform functionality.

## Test Files

- `test_yamlfmt.py`: Main test suite that validates yamlfmt functionality across different platforms

## Running Tests

### Local Testing

Run tests locally with:

```bash
python -m pytest tests/ -v
```

Or run the test script directly:

```bash
python tests/test_yamlfmt.py
```

### CI Testing

The tests are automatically run in GitHub Actions across multiple platforms:

- Linux (Ubuntu)
- macOS
- Windows

## Test Coverage

The test suite covers:

1. **Platform Detection**: Verifies the current platform can be detected
2. **Version Output**: Tests that yamlfmt can output version information
3. **Basic Formatting**: Tests basic YAML formatting functionality
4. **Executable Permissions**: Verifies the yamlfmt executable has correct permissions
5. **Help Output**: Tests that yamlfmt can show help information
6. **Module Import**: Tests that the yamlfmt module can be imported correctly
7. **System Information**: Displays system information for debugging

## Adding New Tests

When adding new tests:

1. Follow the existing naming convention (`test_*`)
2. Include appropriate error handling for cross-platform compatibility
3. Add descriptive docstrings
4. Use appropriate assertions for validation
39 changes: 39 additions & 0 deletions tests/test_input.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Test YAML file for yamlfmt testing
name: test-project
version: 1.0.0
description: A test project for validating yamlfmt functionality
# Dependencies section with poor formatting
dependencies:
- pytest>=6.0
- black>=22.0
- ruff>=0.1.0
# Configuration with inconsistent indentation
config:
debug: true
log_level: info
timeout: 30
max_retries: 3
# List with inconsistent formatting
scripts:
- build
- test
- format
- lint
# Nested structure
database:
host: localhost
port: 5432
credentials:
username: admin
password: secret123
database: test_db
# Array of objects
servers:
- name: web-1
ip: 192.168.1.10
ports:
- 80
- 443
- name: web-2
ip: 192.168.1.11
ports: [8080, 8443]
Loading
Loading