diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a7f6c34..3a3dcd7 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -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 @@ -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 @@ -75,6 +122,7 @@ jobs: name: Publish to PyPI if: startsWith(github.ref, 'refs/tags/') needs: + - test - release-build - sdist permissions: @@ -98,6 +146,7 @@ jobs: name: Publish to GitHub if: startsWith(github.ref, 'refs/tags/') needs: + - test - release-build permissions: contents: write diff --git a/.gitignore b/.gitignore index aa7129e..571a7fa 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/hatch_build.py b/hatch_build.py index 2ea6a81..54d2dfc 100644 --- a/hatch_build.py +++ b/hatch_build.py @@ -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) @@ -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" + + 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 + 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 diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..69c31a5 --- /dev/null +++ b/tests/README.md @@ -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 diff --git a/tests/test_input.yaml b/tests/test_input.yaml new file mode 100644 index 0000000..b888084 --- /dev/null +++ b/tests/test_input.yaml @@ -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] diff --git a/tests/test_yamlfmt.py b/tests/test_yamlfmt.py new file mode 100644 index 0000000..b813a46 --- /dev/null +++ b/tests/test_yamlfmt.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +""" +Test suite for yamlfmt functionality across different platforms. +""" + +from __future__ import annotations + +import platform +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +class TestYamlfmtOutput(unittest.TestCase): + """Test yamlfmt output across different platforms.""" + + def setUp(self): + """Set up test fixtures.""" + self.test_yaml_content = """ +# Test YAML file +name: test +version: 1.0.0 +dependencies: + - package1 + - package2 +config: + setting1: value1 + setting2: value2 +list: +- item1 +- item2 +- item3 +""" + + def test_platform_detection(self): + """Test that we can detect the current platform.""" + current_platform = platform.system() + self.assertIn(current_platform, ["Linux", "Darwin", "Windows"]) + + def test_yamlfmt_version(self): + """Test that yamlfmt can output version information.""" + # Test version output with different possible flags + result = subprocess.run( + [sys.executable, "-m", "yamlfmt", "-version"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + self.assertEqual(result.returncode, 0, f"yamlfmt version command failed: {result.stderr}") + + def test_yamlfmt_format_basic(self): + """Test basic YAML formatting functionality.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write(self.test_yaml_content) + temp_file = Path(f.name) + + try: + # Try to format the file + result = subprocess.run( + [sys.executable, "-m", "yamlfmt", str(temp_file)], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + self.assertEqual(result.returncode, 0, f"yamlfmt formatting failed: {result.stderr}") + + # Check that the formatted file still exists + self.assertTrue(temp_file.is_file(), f"Temporary file {temp_file} does not exist after formatting") + + finally: + # Clean up temporary file + if temp_file.exists(): + temp_file.unlink() + + def test_help_output(self): + """Test that yamlfmt can show help information.""" + result = subprocess.run( + [sys.executable, "-m", "yamlfmt", "-h"], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + self.assertEqual(result.returncode, 0, f"yamlfmt help command failed: {result.stderr}") + + def test_module_import(self): + """Test that the yamlfmt module can be imported correctly.""" + # Add src directory to Python path + test_dir = Path(__file__).parent + src_dir = test_dir.parent / "src" + + if src_dir.exists() and str(src_dir) not in sys.path: + sys.path.insert(0, str(src_dir)) + + try: + # Try to import yamlfmt module + import yamlfmt + + # Check that BIN_NAME exists and is correct + self.assertTrue(hasattr(yamlfmt, "BIN_NAME"), "yamlfmt module should have BIN_NAME attribute") + self.assertEqual(yamlfmt.BIN_NAME, "yamlfmt", f"Expected BIN_NAME to be 'yamlfmt', got {yamlfmt.BIN_NAME}") + + # Check that __version__ exists + self.assertTrue(hasattr(yamlfmt, "__version__"), "yamlfmt module should have __version__ attribute") + self.assertIsNotNone(yamlfmt.__version__, "yamlfmt version should not be None") + + except ImportError as e: + self.fail(f"Failed to import yamlfmt module: {e}") + finally: + # Remove src directory from path if we added it + if src_dir.exists() and str(src_dir) in sys.path: + sys.path.remove(str(src_dir)) + + def test_system_info(self): + """Display system information for debugging.""" + info = { + "Platform": platform.system(), + "Platform Release": platform.release(), + "Platform Version": platform.version(), + "Architecture": platform.machine(), + "Processor": platform.processor(), + "Python Version": sys.version, + "Python Executable": sys.executable, + } + + print("\n" + "=" * 50) + print("SYSTEM INFORMATION") + print("=" * 50) + for key, value in info.items(): + print(f"{key:20}: {value}") + print("=" * 50) + + +if __name__ == "__main__": + unittest.main()