diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a7ba093..5b4bca48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,25 +9,66 @@ on: permissions: {} jobs: - lint-test-and-build: - name: test (py${{ matrix.python-version }}) + lint-and-typecheck: + name: Lint and typecheck runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: false + + - name: Sync dependencies + run: uv sync --all-packages --locked --python 3.12 + + - name: Lint + run: uv run poe lint + + - name: Typecheck + run: uv run poe typecheck + + test: + name: Test (${{ matrix.os.name }}, py${{ matrix.python-version }}) + runs-on: ${{ matrix.os.runner }} environment: ci permissions: contents: read + defaults: + run: + shell: bash strategy: fail-fast: false matrix: + os: + - name: Linux + runner: ubuntu-latest + redis-image: redis:7-alpine + redis-url: redis://localhost:6379/9 + - name: macOS + runner: macos-latest + redis-image: "" + redis-url: "" + - name: Windows + runner: windows-latest + redis-image: "" + redis-url: "" python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] services: # The vercel-apscheduler driver tests exercise their Lua scripts against - # a real Redis; without this service they skip silently. + # a real Redis on Linux; without this service they skip silently. redis: - image: redis:7-alpine + image: ${{ matrix.os.redis-image }} # zizmor: ignore[unpinned-images] ports: - 6379:6379 env: - APSCHEDULER_TEST_REDIS_URL: redis://localhost:6379/9 + APSCHEDULER_TEST_REDIS_URL: ${{ matrix.os.redis-url }} BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} @@ -82,14 +123,8 @@ jobs: - name: Sync dependencies run: uv sync --all-packages --locked --python ${{ matrix.python-version }} - - name: Lint - run: ./scripts/lint.sh - - - name: Typecheck - run: ./scripts/typecheck.sh - - name: Test - run: ./scripts/test.sh + run: uv run poe test env: VERCEL_DEVALUE_JS: ${{ runner.temp }}/devalue VERCEL_WORKFLOW_CLI: ${{ runner.temp }}/workflow-cli/node_modules/workflow @@ -98,5 +133,41 @@ jobs: if: matrix.python-version == '3.12' run: uv run poe test-examples + build: + name: Build packages + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: false + + - name: Sync dependencies + run: uv sync --all-packages --locked --python 3.12 + - name: Build package - run: ./scripts/build.sh + run: uv run poe dist + + overall: + name: CI Overall + if: always() + needs: + - build + - lint-and-typecheck + - test + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Check CI results + if: >- + needs.build.result != 'success' || + needs.lint-and-typecheck.result != 'success' || + needs.test.result != 'success' + run: exit 1 diff --git a/changes/vercel-queue/windows-devserver-shutdown.bugfix.md b/changes/vercel-queue/windows-devserver-shutdown.bugfix.md new file mode 100644 index 00000000..82d10a04 --- /dev/null +++ b/changes/vercel-queue/windows-devserver-shutdown.bugfix.md @@ -0,0 +1 @@ +Force embedded development servers to exit when graceful shutdown stalls. diff --git a/changes/vercel-sandbox/windows-process-kill.bugfix.md b/changes/vercel-sandbox/windows-process-kill.bugfix.md new file mode 100644 index 00000000..dc7ba8a3 --- /dev/null +++ b/changes/vercel-sandbox/windows-process-kill.bugfix.md @@ -0,0 +1 @@ +Expose Linux process signals consistently on every SDK host platform. diff --git a/changes/vercel-workflow/linux-sandbox-platform.bugfix.md b/changes/vercel-workflow/linux-sandbox-platform.bugfix.md new file mode 100644 index 00000000..820cfb9c --- /dev/null +++ b/changes/vercel-workflow/linux-sandbox-platform.bugfix.md @@ -0,0 +1 @@ +Make the workflow sandbox expose Linux paths, platform identity, and clock constants on every host. diff --git a/pyproject.toml b/pyproject.toml index d4d6c8c0..900756a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dev = [ "hypothesis>=6.0.0,<7", "respx>=0.21.0,<1", "cryptography>=48.0.1", - "uvloop<1", + "uvloop<1; sys_platform != 'win32'", "mypy>=1.20.2,<2", "django-stubs>=6.0.6,<7", "build<2", @@ -31,7 +31,7 @@ dev = [ "tomli>=2.0.0,<3; python_version < '3.11'", "vendoring>=1,<2", "lograil~=0.7.0", - "ggt>=1.5.5; python_version >= '3.11'", + "ggt>=1.5.6; python_version >= '3.11'", ] [tool.uv] diff --git a/scripts/poe/README.md b/scripts/poe/README.md index 63ea2ef2..ae6995ea 100644 --- a/scripts/poe/README.md +++ b/scripts/poe/README.md @@ -238,8 +238,8 @@ When changing this system, verify all shell code with system's default bash (helps catching new bash-isms on macOS). ```sh -shellcheck -x scripts/build.sh scripts/poe/tasks/poe scripts/poe/tasks/tool -/bin/bash -n scripts/build.sh scripts/poe/tasks/poe scripts/poe/tasks/tool +shellcheck -x scripts/build.sh +/bin/bash -n scripts/build.sh python3 -m py_compile scripts/poe/workspace_poe.py scripts/poe/workspace_poe_resolve.py scripts/workspace-task.sh scripts/qa.sh scripts/workspace-root-task.sh ``` diff --git a/scripts/poe/poe.toml b/scripts/poe/poe.toml index 193b752c..d4f1457b 100644 --- a/scripts/poe/poe.toml +++ b/scripts/poe/poe.toml @@ -1,12 +1,12 @@ [tool.poe.env] -POE = { default = "${POE_CONF_DIR}/tasks/poe" } -PYTEST = { default = "${POE_CONF_DIR}/tasks/pytest" } -RUFF_CHECK = { default = "${POE_CONF_DIR}/tasks/ruff-check" } -RUFF_CHECK_FIX = { default = "${POE_CONF_DIR}/tasks/ruff-check-fix" } -RUFF_FORMAT = { default = "${POE_CONF_DIR}/tasks/ruff-format" } -RUFF_FORMAT_FIX = { default = "${POE_CONF_DIR}/tasks/ruff-format-fix" } -TY = { default = "${POE_CONF_DIR}/tasks/ty" } -MYPY = { default = "${POE_CONF_DIR}/tasks/mypy" } +POE = { default = "python ${POE_CONF_DIR}/tasks/poe" } +PYTEST = { default = "python ${POE_CONF_DIR}/tasks/tool pytest" } +RUFF_CHECK = { default = "python ${POE_CONF_DIR}/tasks/tool ruff-check" } +RUFF_CHECK_FIX = { default = "python ${POE_CONF_DIR}/tasks/tool ruff-check-fix" } +RUFF_FORMAT = { default = "python ${POE_CONF_DIR}/tasks/tool ruff-format" } +RUFF_FORMAT_FIX = { default = "python ${POE_CONF_DIR}/tasks/tool ruff-format-fix" } +TY = { default = "python ${POE_CONF_DIR}/tasks/tool ty" } +MYPY = { default = "python ${POE_CONF_DIR}/tasks/tool mypy" } [tool.poe.tasks.fix] cmd = "$POE_CONF_DIR/workspace_poe.py tool-group fix" diff --git a/scripts/poe/tasks/poe b/scripts/poe/tasks/poe index f3a5f4ae..dd441fff 100755 --- a/scripts/poe/tasks/poe +++ b/scripts/poe/tasks/poe @@ -1,13 +1,28 @@ -#!/usr/bin/env bash -set -euo pipefail +#!/usr/bin/env python +from __future__ import annotations -if (($# == 0)); then - echo "usage: $0 [args ...]" >&2 - exit 2 -fi +import subprocess +import sys +from collections.abc import Sequence -task="$1" -shift -poe --dry-run "$task" "$@" 2>&1 | sed 's/^Poe => //' -exec poe -q "$task" "$@" +def main(argv: Sequence[str] | None = None) -> int: + args = list(argv if argv is not None else sys.argv[1:]) + if not args: + raise SystemExit(f"usage: {sys.argv[0]} [args ...]") + + preview = subprocess.run( + ("poe", "--dry-run", *args), + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + sys.stdout.write(preview.stdout.replace("Poe => ", "", 1)) + if preview.returncode: + return preview.returncode + return subprocess.call(("poe", "-q", *args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/poe/tasks/tool b/scripts/poe/tasks/tool index c95dd708..08acf95a 100755 --- a/scripts/poe/tasks/tool +++ b/scripts/poe/tasks/tool @@ -1,310 +1,185 @@ -#!/usr/bin/env bash -set -euo pipefail +#!/usr/bin/env python +from __future__ import annotations -task_name="$(basename "$0")" -script_dir="$(cd "$(dirname "$0")" && pwd -P)" -workspace_root="$(cd "$script_dir/../../.." && pwd -P)" +import os +import shlex +import shutil +import subprocess +import sys +from collections.abc import Iterable, Sequence +from pathlib import Path -workspace_poe_parallel_enabled() { - case "${WORKSPACE_POE_PARALLEL:-}" in - 0|false|no) - return 1 - ;; - *) - return 0 - ;; - esac -} +FALSE_VALUES = {"0", "false", "no"} +TRUE_VALUES = {"1", "true", "yes"} +WORKSPACE_ROOT = Path(__file__).resolve().parents[3] -workspace_poe_pytest_forced() { - case "${FORCE_PYTEST:-}" in - 1|true|yes) - return 0 - ;; - *) - return 1 - ;; - esac -} -workspace_poe_mypy_cache_package() { - if [[ -n "${WORKSPACE_POE_PACKAGE:-}" ]]; then - printf '%s\n' "$WORKSPACE_POE_PACKAGE" - elif [[ "$(pwd -P)" == "$workspace_root" ]]; then - printf 'root\n' - else - basename "$(pwd -P)" - fi -} +def enabled(name: str, *, default: bool = False) -> bool: + value = os.environ.get(name) + if value is None: + return default + return value.lower() not in FALSE_VALUES -workspace_poe_drop_duplicate_extra_args() { - local start - local index - local matched - if ((${#extra_args[@]} == 0 || ${#scope_args[@]} < ${#extra_args[@]})); then - return - fi +def split_env(name: str) -> list[str]: + value = os.environ.get(name, "") + return shlex.split(value) if value else [] - for ((start = 0; start <= ${#scope_args[@]} - ${#extra_args[@]}; start++)); do - matched=1 - for ((index = 0; index < ${#extra_args[@]}; index++)); do - if [[ "${scope_args[start + index]}" != "${extra_args[index]}" ]]; then - matched=0 - break - fi - done - if ((matched)); then - extra_args=() - return - fi - done -} -case "$task_name" in - pytest) - if [[ "${WORKSPACE_POE_TEST_RUNNER:-}" == ggt ]]; then - command=(ggt) - test_runner=ggt - elif [[ "${WORKSPACE_POE_TEST_RUNNER:-}" == pytest ]]; then - command=(pytest) - test_runner=pytest - elif ! workspace_poe_pytest_forced && command -v ggt >/dev/null 2>&1; then - command=(ggt) - test_runner=ggt - else - command=(pytest) - test_runner=pytest - fi - if [[ "$test_runner" == ggt ]] && - [[ "${WORKSPACE_POE_LOGRAIL_PROGRESS:-}" == 1 ]]; then - command+=(--output-format json) - fi - ;; - ruff-check) - command=(ruff check) - ;; - ruff-check-fix) - command=(ruff check --fix) - ;; - ruff-format) - command=(ruff format --check) - ;; - ruff-format-fix) - command=(ruff format) - ;; - ty) - command=(ty check) - ;; - mypy) - command=(mypy) - ;; - *) - echo "unsupported poe tool wrapper: $task_name" >&2 - exit 2 - ;; -esac +def has_option( + args: Iterable[str], + *, + flags: set[str], + prefixes: tuple[str, ...], +) -> bool: + return any(arg in flags or arg.startswith(prefixes) for arg in args) -extra_args=() -if [[ -n "${POE_EXTRA_ARGS:-}" ]]; then - eval "extra_args=(${POE_EXTRA_ARGS})" -fi -if [[ "${extra_args[0]:-}" == -- ]]; then - extra_args=("${extra_args[@]:1}") -fi -scope_args=("$@") -if [[ "$task_name" == ruff-* && -z "${RUFF_CACHE_DIR:-}" ]]; then - export RUFF_CACHE_DIR="$workspace_root/.ruff_cache" -fi -if [[ "$task_name" == mypy ]]; then - cache_dir="" - has_config_file=0 - has_cache_dir=0 - option_value=0 - if ((${#scope_args[@]})); then - for arg in "${scope_args[@]}"; do - if ((option_value)); then - option_value=0 - continue - fi - case "$arg" in - --config-file) - has_config_file=1 - option_value=1 - ;; - --config-file=*) - has_config_file=1 - ;; - --cache-dir) - has_cache_dir=1 - option_value=1 - ;; - --cache-dir=*) - has_cache_dir=1 - ;; - esac - done - fi - if ((${#extra_args[@]})); then - for arg in "${extra_args[@]}"; do - if ((option_value)); then - option_value=0 - continue - fi - case "$arg" in - --config-file) - has_config_file=1 - option_value=1 - ;; - --config-file=*) - has_config_file=1 - ;; - --cache-dir) - has_cache_dir=1 - option_value=1 - ;; - --cache-dir=*) - has_cache_dir=1 - ;; - esac - done - fi - if ((has_config_file == 0)); then - if ((${#scope_args[@]})); then - scope_args=(--config-file "$workspace_root/pyproject.toml" "${scope_args[@]}") - else - scope_args=(--config-file "$workspace_root/pyproject.toml") - fi - fi - if ((has_cache_dir == 0)); then - cache_dir="$workspace_root/.mypy_cache/$(workspace_poe_mypy_cache_package)" - if ((${#scope_args[@]})); then - scope_args=(--cache-dir "$cache_dir" "${scope_args[@]}") - else - scope_args=(--cache-dir "$cache_dir") - fi - fi -fi -if ((${#scope_args[@]} == 0)) && [[ -n "${WORKSPACE_POE_SCOPE_ARGS:-}" ]]; then - eval "scope_args=(${WORKSPACE_POE_SCOPE_ARGS})" -fi -if [[ "$task_name" == mypy ]]; then - has_target=0 - option_value=0 - if ((${#scope_args[@]})); then - for arg in "${scope_args[@]}"; do - if ((option_value)); then - option_value=0 - continue - fi - case "$arg" in - --cache-dir|--config-file|--python-version) - option_value=1 - ;; - --cache-dir=*|--config-file=*|--python-version=*) - ;; - --*) - ;; - *) - has_target=1 - ;; - esac - done - fi - if ((has_target == 0)); then - if [[ -n "${WORKSPACE_POE_SCOPE_ARGS:-}" ]]; then - eval "scope_args+=( ${WORKSPACE_POE_SCOPE_ARGS} )" - else - scope_args+=(.) - fi - fi -fi -if [[ "$task_name" == pytest ]]; then - has_workers=0 - has_verbosity=0 - option_value=0 - if ((${#scope_args[@]})); then - for arg in "${scope_args[@]}"; do - if ((option_value)); then - option_value=0 - continue - fi - case "$arg" in - -n|--numprocesses|-j|--jobs) - has_workers=1 - option_value=1 - ;; - -n*|--numprocesses=*|-j*|--jobs=*) - has_workers=1 - ;; - -q|--quiet|-v|--verbose) - has_verbosity=1 - ;; - -q*|-v*) - has_verbosity=1 - ;; - esac - done - fi - if ((${#extra_args[@]})); then - for arg in "${extra_args[@]}"; do - if ((option_value)); then - option_value=0 - continue - fi - case "$arg" in - -n|--numprocesses|-j|--jobs) - has_workers=1 - option_value=1 - ;; - -n*|--numprocesses=*|-j*|--jobs=*) - has_workers=1 - ;; - -q|--quiet|-v|--verbose) - has_verbosity=1 - ;; - -q*|-v*) - has_verbosity=1 - ;; - esac - done - fi - if ((has_verbosity == 0)) && - [[ "${WORKSPACE_POE_LOGRAIL_PROGRESS:-}" == 1 ]] && - [[ "$test_runner" == pytest ]]; then - if ((${#scope_args[@]})); then - scope_args=(-v "${scope_args[@]}") - else - scope_args=(-v) - fi - fi - if ((has_workers == 0)); then - if [[ "$test_runner" == pytest ]] && workspace_poe_parallel_enabled; then - scope_args=(-n auto "${scope_args[@]}") - elif [[ "$test_runner" == ggt ]] && ! workspace_poe_parallel_enabled; then - scope_args=(-j 1 "${scope_args[@]}") - fi - fi -fi -if ((${#scope_args[@]} == 0)); then - if [[ "$task_name" == ruff-* && "$(pwd -P)" == "$workspace_root" ]]; then - scope_args=(tests examples) - else - scope_args=(.) - fi -fi +def has_mypy_target(args: Sequence[str]) -> bool: + takes_value = False + for arg in args: + if takes_value: + takes_value = False + continue + if arg in {"--cache-dir", "--config-file", "--python-version"}: + takes_value = True + elif arg.startswith(("--cache-dir=", "--config-file=", "--python-version=")): + continue + elif not arg.startswith("--"): + return True + return False -workspace_poe_drop_duplicate_extra_args -run_args=("${command[@]}") -if ((${#scope_args[@]})); then - run_args+=("${scope_args[@]}") -fi -if ((${#extra_args[@]})); then - run_args+=("${extra_args[@]}") -fi +def drop_duplicate_args(scope_args: list[str], extra_args: list[str]) -> list[str]: + size = len(extra_args) + if not size or len(scope_args) < size: + return extra_args + if any( + scope_args[index : index + size] == extra_args + for index in range(len(scope_args) - size + 1) + ): + return [] + return extra_args -printf '%q ' "${run_args[@]}" -printf '\n' -exec "${run_args[@]}" + +def pytest_command() -> tuple[list[str], str]: + selected = os.environ.get("WORKSPACE_POE_TEST_RUNNER") + force_pytest = os.environ.get("FORCE_PYTEST", "").lower() in TRUE_VALUES + if selected == "ggt" or (selected != "pytest" and not force_pytest and shutil.which("ggt")): + command = ["ggt"] + runner = "ggt" + else: + command = ["pytest"] + runner = "pytest" + if runner == "ggt" and os.environ.get("WORKSPACE_POE_LOGRAIL_PROGRESS") == "1": + command.extend(("--output-format", "json")) + return command, runner + + +def build_command(task_name: str, argv: Sequence[str]) -> list[str]: + test_runner: str | None = None + if task_name == "pytest": + command, test_runner = pytest_command() + else: + commands = { + "ruff-check": ["ruff", "check"], + "ruff-check-fix": ["ruff", "check", "--fix"], + "ruff-format": ["ruff", "format", "--check"], + "ruff-format-fix": ["ruff", "format"], + "ty": ["ty", "check"], + "mypy": ["mypy"], + } + try: + command = commands[task_name] + except KeyError: + raise SystemExit(f"unsupported poe tool wrapper: {task_name}") from None + + extra_args = split_env("POE_EXTRA_ARGS") + if extra_args[:1] == ["--"]: + extra_args.pop(0) + scope_args = list(argv) + + if task_name.startswith("ruff-"): + os.environ.setdefault("RUFF_CACHE_DIR", str(WORKSPACE_ROOT / ".ruff_cache")) + + if task_name == "mypy": + all_args = [*scope_args, *extra_args] + if not has_option( + all_args, + flags={"--config-file"}, + prefixes=("--config-file=",), + ): + scope_args[:0] = [ + "--config-file", + str(WORKSPACE_ROOT / "pyproject.toml"), + ] + if not has_option( + all_args, + flags={"--cache-dir"}, + prefixes=("--cache-dir=",), + ): + package = os.environ.get("WORKSPACE_POE_PACKAGE") + if not package: + package = "root" if Path.cwd().resolve() == WORKSPACE_ROOT else Path.cwd().name + scope_args[:0] = [ + "--cache-dir", + str(WORKSPACE_ROOT / ".mypy_cache" / package), + ] + + if not scope_args: + scope_args = split_env("WORKSPACE_POE_SCOPE_ARGS") + + if task_name == "mypy" and not has_mypy_target(scope_args): + scope_args.extend(split_env("WORKSPACE_POE_SCOPE_ARGS") or ["."]) + + if task_name == "pytest": + all_args = [*scope_args, *extra_args] + has_workers = has_option( + all_args, + flags={"-n", "--numprocesses", "-j", "--jobs"}, + prefixes=("-n", "--numprocesses=", "-j", "--jobs="), + ) + has_verbosity = has_option( + all_args, + flags={"-q", "--quiet", "-v", "--verbose"}, + prefixes=("-q", "-v"), + ) + parallel = enabled("WORKSPACE_POE_PARALLEL", default=True) + if ( + not has_verbosity + and os.environ.get("WORKSPACE_POE_LOGRAIL_PROGRESS") == "1" + and test_runner == "pytest" + ): + scope_args.insert(0, "-v") + if not has_workers: + if test_runner == "pytest" and parallel: + scope_args[:0] = ["-n", "auto"] + elif test_runner == "ggt" and not parallel: + scope_args[:0] = ["-j", "1"] + + if not scope_args: + if task_name.startswith("ruff-") and Path.cwd().resolve() == WORKSPACE_ROOT: + scope_args = ["tests", "examples"] + else: + scope_args = ["."] + + extra_args = drop_duplicate_args(scope_args, extra_args) + return [*command, *scope_args, *extra_args] + + +def main(argv: Sequence[str] | None = None) -> int: + args = list(argv if argv is not None else sys.argv[1:]) + invoked_name = Path(sys.argv[0]).name + if invoked_name == "tool": + if not args: + raise SystemExit("missing poe tool name") + task_name = args.pop(0) + else: + task_name = invoked_name + command = build_command(task_name, args) + print(shlex.join(command), flush=True) + return subprocess.call(command) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/poe/workspace_poe.py b/scripts/poe/workspace_poe.py index 731e27ac..af5e6f3e 100755 --- a/scripts/poe/workspace_poe.py +++ b/scripts/poe/workspace_poe.py @@ -8,7 +8,7 @@ import subprocess import sys import tempfile -from collections.abc import Sequence +from collections.abc import Callable, Sequence from dataclasses import dataclass from pathlib import Path from typing import Any @@ -18,6 +18,7 @@ PARALLEL_FALSE = {"0", "false", "no"} FAILURE_TAIL_LINES = 20 QUIET_FAILURE_DETAIL = "workspace_poe.failure.detail" +TEST_FAILURE_DETAILS = "workspace_poe.test.failure_details" ROOT_TASKS = { "fix": "fix-root", "lint": "lint-root", @@ -402,28 +403,28 @@ def run_root_task(task: str, argv: Sequence[str]) -> int: else: args.append("tests/unit") args.extend(extra_args) - return run_command( - env_command( - "pytest", - shlex.join((os.environ["PYTEST"], *args)), - category="test", - parser="pytest", - ) - ) + return run_command(root_pytest_command(args, category="test")) if task == "test-examples-root": extra_args = list(argv) or poe_extra_args() args = ["tests/test_examples.py", *extra_args] - return run_command( - env_command( - "pytest", - shlex.join((os.environ["PYTEST"], *args)), - category="test-examples", - parser="pytest", - ) - ) + return run_command(root_pytest_command(args, category="test-examples")) raise SystemExit(f"unsupported root task: {task}") +def root_pytest_command(args: Sequence[str], *, category: str) -> CommandSpec: + subject = os.environ.get("WORKSPACE_POE_PACKAGE") or Path.cwd().name + return CommandSpec( + label=lograil_name("pytest", subject), + argv=(sys.executable, str(SCRIPT_DIR / "tasks" / "tool"), "pytest", *args), + cwd=Path.cwd(), + env=os.environ.copy(), + display_label="pytest", + category=category, + subject=subject, + parser="pytest", + ) + + def env_command( label: str, command: str, @@ -470,7 +471,7 @@ def run_group( for command in commands: remaps = list(DEFAULT_REMAPS) if command.category in PYTEST_TASKS and command.parser == "generic": - remaps.append(_preserve_test_scope_identity) + remaps.append(_ggt_entry_remap()) if command.suppress_output: remaps.append(_quiet_entry) specs.append( @@ -518,7 +519,13 @@ def print_failure_summary(processes: Sequence[Any]) -> None: def failure_tail_lines(entries: Sequence[dict[str, Any]]) -> list[str]: lines: list[str] = [] + retained: list[str] = [] for entry in entries: + details = entry.get(TEST_FAILURE_DETAILS) + if isinstance(details, (list, tuple)): + for detail in details: + if isinstance(detail, str) and detail and detail not in retained: + retained.append(detail) message = entry.get("message") or entry.get(QUIET_FAILURE_DETAIL) if not isinstance(message, str): continue @@ -528,7 +535,10 @@ def failure_tail_lines(entries: Sequence[dict[str, Any]]) -> list[str]: if is_low_signal_failure_tail_line(message): continue lines.append(message) - return lines[-FAILURE_TAIL_LINES:] + retained = retained[-FAILURE_TAIL_LINES:] + lines = [line for line in lines if line not in retained] + available = FAILURE_TAIL_LINES - len(retained) + return [*retained, *(lines[-available:] if available else [])] def is_low_signal_failure_tail_line(message: str) -> bool: @@ -539,6 +549,8 @@ def is_low_signal_failure_tail_line(message: str) -> bool: return True if stripped.startswith("tests/") and "::" in stripped and "PASSED" in stripped: return True + if stripped.startswith("PASSED "): + return True return False @@ -552,11 +564,32 @@ def _quiet_entry(entry: dict[str, Any]) -> dict[str, Any]: return entry -def _preserve_test_scope_identity(entry: dict[str, Any]) -> dict[str, Any]: - """Keep the package label while accepting native ggt progress detail.""" - entry.pop("lograil.progress.process", None) - entry.pop("lograil.progress.subject", None) - return entry +def _ggt_entry_remap() -> Callable[[dict[str, Any]], dict[str, Any]]: + """Keep package identity and retain failures past lograil's bounded tail.""" + failures: list[str] = [] + + def remap(entry: dict[str, Any]) -> dict[str, Any]: + entry.pop("lograil.progress.process", None) + entry.pop("lograil.progress.subject", None) + if entry.get("levelname") in {"ERROR", "CRITICAL"}: + message = entry.get("message") + detail = entry.get("ggt.detail") + detail_messages = ( + tuple( + detail.get(field) + for field in ("error_message", "server_traceback", "stdout", "stderr") + ) + if isinstance(detail, dict) + else () + ) + for failure in (message, *detail_messages): + if isinstance(failure, str) and failure and failure not in failures: + failures.append(failure) + if failures: + entry[TEST_FAILURE_DETAILS] = tuple(failures[-FAILURE_TAIL_LINES:]) + return entry + + return remap def run_sequential( diff --git a/scripts/release.py b/scripts/release.py index 43aad53e..475a1d46 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -431,7 +431,14 @@ def _release_pr_numbers(releases: list[Release]) -> dict[Path, int]: def _fragment_pr_number(path: Path) -> int | None: try: log = subprocess.check_output( - ["git", "log", "--full-history", "--format=%s", "--", str(path.relative_to(ROOT))], + [ + "git", + "log", + "--full-history", + "--format=%s", + "--", + path.relative_to(ROOT).as_posix(), + ], cwd=ROOT, text=True, ) diff --git a/src/vercel-queue/tests/unit/test_queue_asgi.py b/src/vercel-queue/tests/unit/test_queue_asgi.py index 06bc6c04..f9434e4d 100644 --- a/src/vercel-queue/tests/unit/test_queue_asgi.py +++ b/src/vercel-queue/tests/unit/test_queue_asgi.py @@ -4,9 +4,12 @@ import contextlib import logging +import threading from collections.abc import AsyncIterable, Iterator from dataclasses import dataclass +from types import SimpleNamespace +import anyio import httpx import pytest @@ -64,6 +67,66 @@ def _server(**kwargs: object) -> Iterator[_Server]: assert capsys.readouterr().out == '{"baseUrl": "http://127.0.0.1:54321"}\n' +def test_devserver_shutdown_cancels_a_stuck_server() -> None: + class _Server: + should_exit = False + force_exit = False + cancelled = False + + def cancel(self) -> None: + self.cancelled = True + + class _Thread(threading.Thread): + def __init__(self, server: _Server) -> None: + super().__init__() + self._server = server + self.join_timeouts: list[float] = [] + + def join(self, timeout: float | None = None) -> None: + assert timeout is not None + self.join_timeouts.append(timeout) + + def is_alive(self) -> bool: + return not self._server.cancelled + + server = _Server() + thread = _Thread(server) + + queue_devserver_internal._stop_server(server, thread, "test server") + + assert server.should_exit is True + assert server.force_exit is True + assert server.cancelled is True + assert thread.join_timeouts == [5, 5] + + +def test_cancellable_devserver_stops_its_event_loop_task() -> None: + started = threading.Event() + + class _Config: + def get_loop_factory(self) -> None: + return None + + class _Server: + def __init__(self, config: object) -> None: + self.config = config + + async def serve(self, sockets: list[object] | None = None) -> None: + started.set() + await anyio.sleep_forever() + + uvicorn = SimpleNamespace(Server=_Server) + server = queue_devserver_internal._cancellable_server(uvicorn, _Config()) + thread = threading.Thread(target=server.run) + thread.start() + + assert started.wait(timeout=5) + server.cancel() + thread.join(timeout=5) + + assert not thread.is_alive() + + class _FakeClient: def __init__(self, *, exc: BaseException | None = None) -> None: self.exc = exc diff --git a/src/vercel-queue/tests/unit/test_queue_lease.py b/src/vercel-queue/tests/unit/test_queue_lease.py index 73a31961..516a8a4c 100644 --- a/src/vercel-queue/tests/unit/test_queue_lease.py +++ b/src/vercel-queue/tests/unit/test_queue_lease.py @@ -1850,7 +1850,7 @@ def _wait_for_sync_lease_deadline( condition: Callable[[], bool] | None = None, ) -> None: expected = server.state.now + timedelta(seconds=seconds) - deadline = time.monotonic() + 1 + deadline = time.monotonic() + 5 while server.state.by_id[message_id].lease_deadline_by_consumer[consumer] != expected or ( condition is not None and not condition() ): diff --git a/src/vercel-queue/vercel/queue/_internal/devserver.py b/src/vercel-queue/vercel/queue/_internal/devserver.py index 92da5f79..fcf00acb 100644 --- a/src/vercel-queue/vercel/queue/_internal/devserver.py +++ b/src/vercel-queue/vercel/queue/_internal/devserver.py @@ -15,6 +15,9 @@ from collections.abc import Callable, Iterator, Mapping from dataclasses import dataclass +import anyio +from anyio import from_thread + from ..embedded import create_embedded_queue_app from .asgi import QueueClientAsgiApp, asgi_app from .client import QueueClient @@ -55,8 +58,9 @@ def embedded_queue_dev_server( log_level="warning", lifespan="off", ws="none", + timeout_graceful_shutdown=5, ) - server = uvicorn.Server(config) + server = _cancellable_server(uvicorn, config) thread = threading.Thread(target=_profiled_server_run(server, profile), daemon=True) thread.start() _wait_for_server(server) @@ -69,11 +73,10 @@ def embedded_queue_dev_server( _thread=thread, ) finally: - server.should_exit = True - thread.join(timeout=5) - if thread.is_alive(): - raise RuntimeError("embedded queue dev server did not stop") - app.state.close() + try: + _stop_server(server, thread, "embedded queue dev server") + finally: + app.state.close() @contextlib.contextmanager @@ -110,8 +113,9 @@ def queue_client_asgi_dev_server( # noqa: PLR0913 log_level="warning", lifespan="on", ws="none", + timeout_graceful_shutdown=5, ) - server = uvicorn.Server(config) + server = _cancellable_server(uvicorn, config) thread = threading.Thread(target=_profiled_server_run(server, profile), daemon=True) thread.start() _wait_for_server(server, "queue client ASGI dev server") @@ -123,10 +127,7 @@ def queue_client_asgi_dev_server( # noqa: PLR0913 _thread=thread, ) finally: - server.should_exit = True - thread.join(timeout=5) - if thread.is_alive(): - raise RuntimeError("queue client ASGI dev server did not stop") + _stop_server(server, thread, "queue client ASGI dev server") def main(argv: list[str] | None = None) -> int: @@ -181,6 +182,46 @@ def _wait_for_server(server: Any, name: str = "embedded queue dev server") -> No raise RuntimeError(f"{name} did not start") +def _stop_server(server: Any, thread: threading.Thread, name: str) -> None: + server.should_exit = True + thread.join(timeout=5) + if thread.is_alive(): + server.force_exit = True + server.cancel() + thread.join(timeout=5) + if thread.is_alive(): + raise RuntimeError(f"{name} did not stop") + + +def _cancellable_server(uvicorn: Any, config: Any) -> Any: + class CancellableServer(uvicorn.Server): + _cancel_scope: anyio.CancelScope | None = None + _portal: from_thread.BlockingPortal | None = None + + def run(self, sockets: list[Any] | None = None) -> None: + backend_options: dict[str, Any] = {} + get_loop_factory = getattr(self.config, "get_loop_factory", None) + if get_loop_factory is not None: + backend_options["loop_factory"] = get_loop_factory() + else: + self.config.setup_event_loop() + anyio.run(self._run, sockets, backend_options=backend_options) + + async def _run(self, sockets: list[Any] | None) -> None: + async with from_thread.BlockingPortal() as self._portal: + with anyio.CancelScope() as self._cancel_scope: + await self.serve(sockets=sockets) + + def cancel(self) -> None: + if self._portal is not None and self._cancel_scope is not None: + try: + self._portal.start_task_soon(self._cancel_scope.cancel) + except RuntimeError: + pass + + return CancellableServer(config) + + def _server_port(server: Any) -> int: for asyncio_server in server.servers: sockets = asyncio_server.sockets or () diff --git a/src/vercel-sandbox/tests/test_sandbox_process.py b/src/vercel-sandbox/tests/test_sandbox_process.py index 9613aac5..f9ef5eac 100644 --- a/src/vercel-sandbox/tests/test_sandbox_process.py +++ b/src/vercel-sandbox/tests/test_sandbox_process.py @@ -166,6 +166,7 @@ def test_public_process_exports() -> None: for name in ( "CompletedProcess", "Process", + "ProcessSignal", "ProcessStatus", "SandboxCredentials", "SandboxCredentialsFactory", @@ -174,6 +175,7 @@ def test_public_process_exports() -> None: assert name in sandbox.__all__ for name in ( "CompletedProcess", + "ProcessSignal", "ProcessStatus", "SandboxCredentials", "SyncProcess", @@ -233,12 +235,22 @@ def signal_handler(request: httpx.Request) -> httpx.Response: await process.terminate() await process.kill() await process.send_signal(signal.SIGINT) + await process.send_signal(sandbox.ProcessSignal.SIGUSR1) + await process.send_signal("USR1") + with pytest.raises(ValueError, match="Unknown signal"): + await process.send_signal(32) assert get_process.calls[0].request.url.params["wait"] == "false" assert get_process.calls[1].request.url.params["wait"] == "true" assert logs.call_count == 1 assert all(call.request.headers["connection"] == "close" for call in logs.calls) - assert signals == [signal.SIGTERM, signal.SIGKILL, signal.SIGINT] + assert signals == [ + sandbox.ProcessSignal.SIGTERM, + sandbox.ProcessSignal.SIGKILL, + sandbox.ProcessSignal.SIGINT, + sandbox.ProcessSignal.SIGUSR1, + sandbox.ProcessSignal.SIGUSR1, + ] @respx.mock @@ -273,7 +285,7 @@ def signal_handler(request: httpx.Request) -> httpx.Response: process.terminate() process.kill() - assert signals == [signal.SIGTERM, signal.SIGKILL] + assert signals == [sandbox.ProcessSignal.SIGTERM, sandbox.ProcessSignal.SIGKILL] @respx.mock diff --git a/src/vercel-sandbox/tests/test_sandbox_public_flow.py b/src/vercel-sandbox/tests/test_sandbox_public_flow.py index 4d62b3eb..87c4e9eb 100644 --- a/src/vercel-sandbox/tests/test_sandbox_public_flow.py +++ b/src/vercel-sandbox/tests/test_sandbox_public_flow.py @@ -5,7 +5,7 @@ from concurrent.futures import ThreadPoolExecutor from datetime import timedelta from itertools import islice -from threading import Event +from threading import Condition, Event from typing import Any import anyio @@ -3516,9 +3516,18 @@ async def managed() -> None: @respx.mock -def test_sync_session_acquisition_shares_implicit_resume(mock_env_clear: None) -> None: +def test_sync_session_acquisition_shares_implicit_resume( + mock_env_clear: None, +) -> None: resume_started = Event() + acquire_joined = Event() release_resume = Event() + + class ObservedCondition(Condition): + def wait(self, timeout: float | None = None) -> bool: + acquire_joined.set() + return super().wait(timeout) + respx.get("https://sandbox.test/v2/sandboxes/preview", params={"resume": "false"}).mock( return_value=httpx.Response(200, json=_sandbox_response(session_id="sbx_old")) ) @@ -3543,10 +3552,12 @@ def resume_handler(_request: httpx.Request) -> httpx.Response: with session(service_options=_session_options()): box = sandbox_sync.get_sandbox(name="preview") + box._recovery_condition = ObservedCondition() with ThreadPoolExecutor(max_workers=2) as executor: implicit = executor.submit(box.query_processes) assert resume_started.wait(timeout=5) explicit = executor.submit(box.session) + assert acquire_joined.wait(timeout=5) release_resume.set() assert implicit.result(timeout=5) == [] acquired = explicit.result(timeout=5) diff --git a/src/vercel-sandbox/vercel/sandbox/__init__.py b/src/vercel-sandbox/vercel/sandbox/__init__.py index 52b1f325..14a99447 100644 --- a/src/vercel-sandbox/vercel/sandbox/__init__.py +++ b/src/vercel-sandbox/vercel/sandbox/__init__.py @@ -60,6 +60,7 @@ NetworkPolicyRule, NetworkPolicySubnets, NetworkPolicyTransform, + ProcessSignal, ProcessStatus, SandboxQuery, SandboxQueryByCreatedAt, @@ -499,6 +500,7 @@ async def get_snapshot(*, snapshot_id: str) -> Snapshot: "SandboxApiError", "SandboxCleanupError", "ProcessStatus", + "ProcessSignal", "Process", "CompletedProcess", "SandboxCredentials", diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/async_runtime.py b/src/vercel-sandbox/vercel/sandbox/_internal/async_runtime.py index 6adcaae9..33ca60d4 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/async_runtime.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/async_runtime.py @@ -45,6 +45,7 @@ FailoverRegionsInput, NetworkPolicy, ProcessLog, + ProcessSignal, SandboxQuery, SandboxResources, SandboxSource, @@ -182,12 +183,17 @@ async def communicate(self, input: None = None) -> tuple[str | None, str | None] await self.wait() return stdout, stderr - async def send_signal(self, signal: int | str | signal_module.Signals) -> None: + async def send_signal(self, signal: int | str | signal_module.Signals | ProcessSignal) -> None: """Send a signal to the running process. Args: - signal: Numeric signal, ``Signals`` member, or name such as + signal: Numeric signal, ``ProcessSignal`` member, or name such as ``"TERM"`` or ``"SIGTERM"``. + + Note: + Passing ``signal.Signals`` directly is deprecated. Use + ``ProcessSignal`` so signal availability does not depend on the + SDK host platform. """ payload = await self._service.send_process_signal( session_id=self._session_id, @@ -198,11 +204,11 @@ async def send_signal(self, signal: int | str | signal_module.Signals) -> None: async def terminate(self) -> None: """Request graceful process termination with ``SIGTERM``.""" - await self.send_signal(signal_module.SIGTERM) + await self.send_signal(ProcessSignal.SIGTERM) async def kill(self) -> None: """Terminate the process immediately with ``SIGKILL``.""" - await self.send_signal(signal_module.SIGKILL) + await self.send_signal(ProcessSignal.SIGKILL) class Snapshot(SnapshotHandleBase): diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/models.py b/src/vercel-sandbox/vercel/sandbox/_internal/models.py index f7cd9b0a..1a31c4a8 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/models.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/models.py @@ -3,6 +3,7 @@ from collections.abc import Iterable, Mapping from dataclasses import dataclass from datetime import timedelta +from enum import IntEnum from subprocess import CalledProcessError from types import MappingProxyType from typing import Any, Literal, TypeAlias, cast @@ -579,6 +580,45 @@ class ProcessStatus(StrEnum): EXITED = "exited" +class ProcessSignal(IntEnum): + """Signals supported by remote Linux sandbox processes.""" + + SIGHUP = 1 + SIGINT = 2 + SIGQUIT = 3 + SIGILL = 4 + SIGTRAP = 5 + SIGABRT = 6 + SIGIOT = 6 + SIGBUS = 7 + SIGFPE = 8 + SIGKILL = 9 + SIGUSR1 = 10 + SIGSEGV = 11 + SIGUSR2 = 12 + SIGPIPE = 13 + SIGALRM = 14 + SIGTERM = 15 + SIGSTKFLT = 16 + SIGCHLD = 17 + SIGCLD = 17 + SIGCONT = 18 + SIGSTOP = 19 + SIGTSTP = 20 + SIGTTIN = 21 + SIGTTOU = 22 + SIGURG = 23 + SIGXCPU = 24 + SIGXFSZ = 25 + SIGVTALRM = 26 + SIGPROF = 27 + SIGWINCH = 28 + SIGIO = 29 + SIGPOLL = 29 + SIGPWR = 30 + SIGSYS = 31 + + @dataclass(frozen=True, slots=True) class CompletedProcess: """The captured result of one completed remote process.""" diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/runtime_common.py b/src/vercel-sandbox/vercel/sandbox/_internal/runtime_common.py index 6c5fc3df..b55079a6 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/runtime_common.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/runtime_common.py @@ -16,6 +16,7 @@ from vercel.sandbox._internal.models import ( JSONObject, NetworkPolicy, + ProcessSignal, ProcessStatus, SandboxStatus, _WriteFile, @@ -173,19 +174,20 @@ def _validate_file_mode(mode: object) -> int | None: return mode -def _signal_number(value: int | str | signal_module.Signals | None) -> int: +def _signal_number(value: int | str | signal_module.Signals | ProcessSignal | None) -> int: if value is None: - return int(signal_module.Signals.SIGTERM) - if isinstance(value, signal_module.Signals): - return int(value) - if isinstance(value, int): - return value - normalized = value.upper() - if not normalized.startswith("SIG"): - normalized = f"SIG{normalized}" + return int(ProcessSignal.SIGTERM) + if isinstance(value, str): + normalized = value.upper() + if not normalized.startswith("SIG"): + normalized = f"SIG{normalized}" + try: + return int(ProcessSignal[normalized]) + except KeyError as exc: + raise ValueError(f"Unknown signal: {value!r}") from exc try: - return int(signal_module.Signals[normalized]) - except KeyError as exc: + return int(ProcessSignal(value)) + except (TypeError, ValueError) as exc: raise ValueError(f"Unknown signal: {value!r}") from exc diff --git a/src/vercel-sandbox/vercel/sandbox/_internal/sync_runtime.py b/src/vercel-sandbox/vercel/sandbox/_internal/sync_runtime.py index e4653e4f..0e3602c0 100644 --- a/src/vercel-sandbox/vercel/sandbox/_internal/sync_runtime.py +++ b/src/vercel-sandbox/vercel/sandbox/_internal/sync_runtime.py @@ -39,6 +39,7 @@ FailoverRegionsInput, NetworkPolicy, ProcessLog, + ProcessSignal, SandboxQuery, SandboxResources, SandboxSource, @@ -184,12 +185,17 @@ def communicate(self, input: None = None) -> tuple[str | None, str | None]: self.wait() return stdout, stderr - def send_signal(self, signal: int | str | signal_module.Signals) -> None: + def send_signal(self, signal: int | str | signal_module.Signals | ProcessSignal) -> None: """Send a signal to the running process. Args: - signal: Numeric signal, ``Signals`` member, or name such as + signal: Numeric signal, ``ProcessSignal`` member, or name such as ``"TERM"`` or ``"SIGTERM"``. + + Note: + Passing ``signal.Signals`` directly is deprecated. Use + ``ProcessSignal`` so signal availability does not depend on the + SDK host platform. """ payload = iter_coroutine( self._service.send_process_signal( @@ -202,11 +208,11 @@ def send_signal(self, signal: int | str | signal_module.Signals) -> None: def terminate(self) -> None: """Request graceful process termination with ``SIGTERM``.""" - self.send_signal(signal_module.SIGTERM) + self.send_signal(ProcessSignal.SIGTERM) def kill(self) -> None: """Terminate the process immediately with ``SIGKILL``.""" - self.send_signal(signal_module.SIGKILL) + self.send_signal(ProcessSignal.SIGKILL) class SyncSnapshot(SnapshotHandleBase): diff --git a/src/vercel-sandbox/vercel/sandbox/sync.py b/src/vercel-sandbox/vercel/sandbox/sync.py index 8a364f72..4d24f150 100644 --- a/src/vercel-sandbox/vercel/sandbox/sync.py +++ b/src/vercel-sandbox/vercel/sandbox/sync.py @@ -33,6 +33,7 @@ NetworkPolicyRule, NetworkPolicySubnets, NetworkPolicyTransform, + ProcessSignal, ProcessStatus, SandboxQuery, SandboxQueryByCreatedAt, @@ -487,6 +488,7 @@ def get_snapshot(*, snapshot_id: str) -> SyncSnapshot: "SandboxApiError", "SandboxCleanupError", "ProcessStatus", + "ProcessSignal", "CompletedProcess", "SandboxCredentials", "SandboxCredentialsError", diff --git a/src/vercel-workflow/tests/integration/test_devalue.py b/src/vercel-workflow/tests/integration/test_devalue.py index b46d9f45..3493cfca 100644 --- a/src/vercel-workflow/tests/integration/test_devalue.py +++ b/src/vercel-workflow/tests/integration/test_devalue.py @@ -564,6 +564,7 @@ def interop(devalue_entry: Path) -> dict[str, _Outcome]: input=json.dumps(payload), capture_output=True, text=True, + encoding="utf-8", timeout=120, check=False, env={**os.environ, "DEVALUE_ENTRY": str(devalue_entry)}, @@ -775,7 +776,7 @@ def flush() -> None: ) pending.clear() - for line in test_file.read_text().split("\n"): + for line in test_file.read_text(encoding="utf-8").split("\n"): match = field.match(line) if match is None: continue @@ -862,6 +863,7 @@ def corpus_results( input=json.dumps(pairs), capture_output=True, text=True, + encoding="utf-8", timeout=120, check=False, env={**os.environ, "DEVALUE_ENTRY": str(devalue_entry)}, diff --git a/src/vercel-workflow/tests/integration/test_workflow_cli_interop.py b/src/vercel-workflow/tests/integration/test_workflow_cli_interop.py index e7a5185d..3e443fe6 100644 --- a/src/vercel-workflow/tests/integration/test_workflow_cli_interop.py +++ b/src/vercel-workflow/tests/integration/test_workflow_cli_interop.py @@ -96,6 +96,7 @@ def _npm_install_into(prefix: Path) -> bool: str(prefix), ], capture_output=True, + encoding="utf-8", text=True, timeout=600, check=False, @@ -191,6 +192,7 @@ def _inspect_raw(cli: Path, data_dir: Path, *args: str) -> str: result = subprocess.run( ["node", str(cli), "inspect", *args, "--backend", "local", "--json"], capture_output=True, + encoding="utf-8", text=True, timeout=120, cwd=data_dir, diff --git a/src/vercel-workflow/tests/unit/test_py_sandbox.py b/src/vercel-workflow/tests/unit/test_py_sandbox.py index 102fcdb4..d0257a71 100644 --- a/src/vercel-workflow/tests/unit/test_py_sandbox.py +++ b/src/vercel-workflow/tests/unit/test_py_sandbox.py @@ -12,12 +12,13 @@ from __future__ import annotations -import platform +import struct import sys from contextlib import contextmanager import pytest +from vercel.workflow._internal import py_sandbox from vercel.workflow._internal.py_sandbox import Sandbox, SandboxRestrictionError from vercel.workflow.sandbox import ( ALL_CLEANUPS, @@ -135,13 +136,17 @@ def test_os_path_allowed(self): ns = _run_in_sandbox("import os; result = os.path.join('a', 'b')") assert ns["result"] == "a/b" + def test_os_path_alias_allowed(self): + ns = _run_in_sandbox("from os.path import dirname; result = dirname('a/b')") + assert ns["result"] == "a" + def test_os_sep_allowed(self): ns = _run_in_sandbox("import os; result = os.sep") assert ns["result"] == "/" def test_os_name_allowed(self): ns = _run_in_sandbox("import os; result = os.name") - assert isinstance(ns["result"], str) + assert ns["result"] == "posix" def test_os_fspath_allowed(self): ns = _run_in_sandbox("import os; result = os.fspath('/tmp')") @@ -208,15 +213,9 @@ def test_os_environ_is_copy(self): class TestPlatformRestrictions: - def test_platform_system_answered_by_the_host(self): - """The sandbox's own `platform` cannot answer: it reaches `os.uname()`. - - Which matters beyond anyone calling it directly -- before 3.13 `uuid` - calls it at import on everything but Windows and macOS, so blocking it - means a workflow cannot import `uuid` at all on Linux. - """ + def test_platform_system_reports_the_sandbox_runtime(self): ns = _run_in_sandbox("import platform; result = platform.system()") - assert ns["result"] == platform.system() + assert ns["result"] == "Linux" def test_platform_host_identity_blocked(self): # `node` and `uname` report which *machine* this is, and two replays of @@ -277,7 +276,15 @@ def test_time_struct_time_allowed(self): _run_in_sandbox("import time; _ = time.struct_time") def test_time_constants_allowed(self): - _run_in_sandbox("import time; _ = time.CLOCK_MONOTONIC") + constants = { + "CLOCK_REALTIME": 0, + "CLOCK_MONOTONIC": 1, + "CLOCK_PROCESS_CPUTIME_ID": 2, + "CLOCK_THREAD_CPUTIME_ID": 3, + } + for name, expected in constants.items(): + ns = _run_in_sandbox(f"import time; result = time.{name}") + assert ns["result"] == expected @pytest.mark.asyncio async def test_concurrent_coroutine_not_affected_by_sandbox(self): @@ -825,9 +832,44 @@ def test_math_works(self): ns = _run_in_sandbox("import math; result = math.sqrt(16)") assert ns["result"] == 4.0 - def test_shutil_works(self): - _run_in_sandbox("import os; lurr = os.supports_dir_fd") - _run_in_sandbox("import shutil") + def test_zipimport_bootstrap_module_bypasses_finders(self, monkeypatch: pytest.MonkeyPatch): + sandbox = Sandbox() + assert sandbox.table["struct"] is struct + + def unexpected_import(*args, **kwargs): + pytest.fail(f"preloaded module reached importlib: {args!r} {kwargs!r}") + + monkeypatch.setattr(py_sandbox.importlib, "__import__", unexpected_import) + with sandbox.enter(): + assert __import__("struct") is struct + + def test_real_spec_finders_run_against_host_modules(self, monkeypatch: pytest.MonkeyPatch): + sandbox = Sandbox() + sandbox.table.pop("struct") + finder = next(f for f in sys.meta_path if isinstance(f, py_sandbox._SandboxFinder)) + seen: list[object] = [] + + class RecordingFinder: + @staticmethod + def find_spec(fullname, path, target): + import struct as finder_struct + + assert not py_sandbox.in_sandbox() + seen.append(finder_struct) + return None + + index = sys.meta_path.index(finder) + monkeypatch.setattr(sys, "meta_path", [*sys.meta_path[: index + 1], RecordingFinder()]) + with sandbox.enter(): + assert finder._find_real_spec("missing", None, None) is None + + assert seen == [struct] + assert "struct" not in sandbox.table + + def test_shutil_works_on_a_non_posix_host(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(py_sandbox, "_SANDBOX_MARKER_MODULES", {"posix"}) + ns = _run_in_sandbox("import posix, shutil; result = hasattr(posix, 'open')") + assert ns["result"] is False def test_pathlib_works(self): ns = _run_in_sandbox("import pathlib; result = isinstance(0, pathlib.Path)") diff --git a/src/vercel-workflow/tests/unit/test_workflow_manifest_command.py b/src/vercel-workflow/tests/unit/test_workflow_manifest_command.py index 0ce177b0..7f610a6f 100644 --- a/src/vercel-workflow/tests/unit/test_workflow_manifest_command.py +++ b/src/vercel-workflow/tests/unit/test_workflow_manifest_command.py @@ -19,6 +19,7 @@ from __future__ import annotations import json +import os import subprocess import sys from pathlib import Path @@ -232,7 +233,7 @@ def test_the_command_line_works_as_a_command_line(tmp_path) -> None: text=True, timeout=120, env={ - "PATH": "/usr/bin:/bin", + **os.environ, "WORKFLOW_TARGET_WORLD": "local", "PYTHONPATH": str(tmp_path), }, diff --git a/src/vercel-workflow/vercel/workflow/_internal/py_sandbox.py b/src/vercel-workflow/vercel/workflow/_internal/py_sandbox.py index 40d3d458..5bf6c0db 100644 --- a/src/vercel-workflow/vercel/workflow/_internal/py_sandbox.py +++ b/src/vercel-workflow/vercel/workflow/_internal/py_sandbox.py @@ -7,7 +7,9 @@ import importlib import logging import os +import posixpath import random +import struct import sys import threading import types @@ -283,8 +285,8 @@ def _current_task(loop: Any = None) -> Any: proxy.__dict__["current_task"] = _current_task -def _host_system() -> str: - return _host_import("platform").system() +def _sandbox_system() -> str: + return "Linux" _RESTRICTIONS: dict[str, _ModulePolicy] = { @@ -292,7 +294,7 @@ def _host_system() -> str: "datetime": _blocklist("datetime", datetime=_RestrictedDatetime, date=_RestrictedDate), "platform": _allowlist( "platform", - system=_host_system, + system=_sandbox_system, ), "os": _allowlist( "os", @@ -315,6 +317,16 @@ def _host_system() -> str: "getcwd", "_get_exports_list", "PathLike", + path=posixpath, + sep="/", + altsep=None, + extsep=".", + pathsep=":", + curdir=".", + pardir="..", + devnull="/dev/null", + linesep="\n", + name="posix", environ=os.environ.copy(), allow_if=str.isupper, drops=["fork", "register_at_fork"], @@ -327,6 +339,10 @@ def _host_system() -> str: "get_clock_info", "clock_getres", "struct_time", + CLOCK_REALTIME=0, + CLOCK_MONOTONIC=1, + CLOCK_PROCESS_CPUTIME_ID=2, + CLOCK_THREAD_CPUTIME_ID=3, allow_if=str.isupper, ), "socket": _allowlist( @@ -382,6 +398,13 @@ def _host_system() -> str: "readline", # terminal input } +# The sandbox advertises a Linux userspace even when the host is Windows. +# Some standard-library modules use ``os.name`` to conditionally import the +# Linux syscall module, but exposing the real module would bypass the sandbox. +# Supply only the module marker so those imports remain portable without +# making any syscalls available. +_SANDBOX_MARKER_MODULES: set[str] = {"posix"} if "posix" not in sys.builtin_module_names else set() + _PASSTHROUGHS: set[str] = { # Carefully selected stdlib modules that do not import any restricted modules "abc", @@ -421,6 +444,10 @@ def _host_system() -> str: "math", "numbers", "operator", + # Re-executing os.py against the sandbox's Linux marker module on Windows + # would require real POSIX syscall exports. Wrap the initialized host + # module with the restrictions above instead. + "os", "posixpath", "pprint", "quopri", @@ -602,6 +629,12 @@ def find_spec( # If we aren't actually in a sandbox, defer to the normal finders. if not _in_sandbox.get(False): return None + if fullname in _SANDBOX_MARKER_MODULES: + return spec_from_loader( + fullname, + _PreloadedLoader(types.ModuleType(fullname)), + origin="sandbox-marker", + ) if fullname in self._blocked: # Return a stub module instead of raising — other modules may # ``import subprocess`` at module level but never call it. @@ -650,12 +683,18 @@ def _find_real_spec( path: Sequence[str] | None, target: types.ModuleType | None, ) -> ModuleSpec | None: - if self in sys.meta_path: - for finder in sys.meta_path[sys.meta_path.index(self) + 1 :]: - if hasattr(finder, "find_spec"): - spec = finder.find_spec(fullname, path, target) - if spec is not None: - return spec + table_token = _sandbox_sys_modules.set(None) + sandbox_token = _in_sandbox.set(False) + try: + if self in sys.meta_path: + for finder in sys.meta_path[sys.meta_path.index(self) + 1 :]: + if hasattr(finder, "find_spec"): + spec = finder.find_spec(fullname, path, target) + if spec is not None: + return spec + finally: + _in_sandbox.reset(sandbox_token) + _sandbox_sys_modules.reset(table_token) return None @@ -811,6 +850,15 @@ def _sandbox_import( ) -> types.ModuleType: if not _in_sandbox.get(False): return real_import(name, globals, locals, fromlist or (), level) + table = _sandbox_sys_modules.get() + if level == 0 and table is not None and name in table: + module = table[name] + if fromlist and all(item == "*" or hasattr(module, item) for item in fromlist): + return module + if not fromlist: + top_level = table.get(name.partition(".")[0]) + if top_level is not None: + return top_level return importlib.__import__(name, globals, locals, fromlist or (), level) builtins.__import__ = _sandbox_import @@ -853,7 +901,18 @@ def _new_sandbox_table() -> dict[str, types.ModuleType]: """ _ensure_installed() - table: dict[str, types.ModuleType] = {"sys": sys} + table: dict[str, types.ModuleType] = { + "sys": sys, + # Python 3.13+'s frozen zipimport imports struct lazily while probing + # ZIP64 entries. Windows console-script executables are zip candidates, + # so resolving struct through the sandbox finder can recursively probe + # the same executable before zipimport has cached its directory. + "struct": struct, + # os.py normally installs this alias while it initializes. The + # sandbox wraps the already initialized host os module, so install the + # Linux path alias explicitly in every fresh module table. + "os.path": posixpath, + } # Snapshot atomically (list() over the view is a single C op) so a # concurrent import in another thread can't trip "dict changed size". for key, mod in list(_real_sys_modules.items()): diff --git a/tests/unit/test_clogapp.py b/tests/unit/test_clogapp.py index b37d4c64..a354d8cb 100644 --- a/tests/unit/test_clogapp.py +++ b/tests/unit/test_clogapp.py @@ -370,7 +370,10 @@ async def test_clogedit_escape_returns_to_previous_step() -> None: assert pilot.app.return_value == [] -async def test_clogedit_single_escape_on_package_step_arms_exit() -> None: +async def test_clogedit_single_escape_on_package_step_arms_exit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(clogedit, "ESC_EXIT_SECONDS", 30) selection = clogedit.ChangelogSelection( { "pkg": clogedit.PackageNewsState( @@ -397,7 +400,10 @@ async def test_clogedit_single_escape_on_package_step_arms_exit() -> None: assert pilot.app.return_value == [] -async def test_clogedit_double_escape_on_package_step_exits() -> None: +async def test_clogedit_double_escape_on_package_step_exits( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(clogedit, "ESC_EXIT_SECONDS", 30) selection = clogedit.ChangelogSelection( { "pkg": clogedit.PackageNewsState( @@ -415,7 +421,8 @@ async def test_clogedit_double_escape_on_package_step_exits() -> None: ) async with app.run_test() as pilot: - await pilot.press("escape", "escape") + await pilot.press("escape") + await pilot.press("escape") assert pilot.app.return_value == [] @@ -443,7 +450,7 @@ async def test_clogedit_escape_exit_timeout_resets(monkeypatch: pytest.MonkeyPat await pilot.pause(0.03) assert app.escape_exit_timer is None assert app.status == clogedit.PACKAGE_STATUS - monkeypatch.setattr(clogedit, "ESC_EXIT_SECONDS", 0.5) + monkeypatch.setattr(clogedit, "ESC_EXIT_SECONDS", 30) await pilot.press("escape") assert app.escape_exit_timer is not None await pilot.press("ctrl+d") diff --git a/tests/unit/test_poe_tool.py b/tests/unit/test_poe_tool.py index 8a8253c7..78b36b76 100644 --- a/tests/unit/test_poe_tool.py +++ b/tests/unit/test_poe_tool.py @@ -1,105 +1,90 @@ from __future__ import annotations -import os -import subprocess +import importlib.machinery +import importlib.util +import sys from pathlib import Path -from typing import TYPE_CHECKING -if TYPE_CHECKING: - from collections.abc import Sequence +TOOL = Path(__file__).resolve().parents[2] / "scripts" / "poe" / "tasks" / "tool" +LOADER = importlib.machinery.SourceFileLoader("poe_tool", str(TOOL)) +SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER) +assert SPEC is not None +poe_tool = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = poe_tool +LOADER.exec_module(poe_tool) -TOOL = Path(__file__).resolve().parents[2] / "scripts" / "poe" / "tasks" / "pytest" - - -def _runner(path: Path, name: str) -> None: - executable = path / name - executable.write_text('#!/bin/sh\nprintf \'%s\\n\' "$0" "$@"\n') - executable.chmod(0o755) - - -def _run( - tmp_path: Path, - args: Sequence[str], +def _command( + monkeypatch, + args: tuple[str, ...], *, + ggt_available: bool = True, force_pytest: bool = False, lograil_progress: bool = False, parallel: bool = True, ) -> list[str]: - env = os.environ.copy() - env["PATH"] = os.pathsep.join((str(tmp_path), "/usr/bin", "/bin")) - env.pop("POE_EXTRA_ARGS", None) - env.pop("WORKSPACE_POE_TEST_RUNNER", None) + monkeypatch.delenv("POE_EXTRA_ARGS", raising=False) + monkeypatch.delenv("WORKSPACE_POE_TEST_RUNNER", raising=False) + monkeypatch.delenv("WORKSPACE_POE_SCOPE_ARGS", raising=False) + monkeypatch.setattr( + poe_tool.shutil, + "which", + lambda command: f"/bin/{command}" if command == "ggt" and ggt_available else None, + ) if lograil_progress: - env["WORKSPACE_POE_LOGRAIL_PROGRESS"] = "1" + monkeypatch.setenv("WORKSPACE_POE_LOGRAIL_PROGRESS", "1") else: - env.pop("WORKSPACE_POE_LOGRAIL_PROGRESS", None) - env.pop("WORKSPACE_POE_SCOPE_ARGS", None) + monkeypatch.delenv("WORKSPACE_POE_LOGRAIL_PROGRESS", raising=False) if force_pytest: - env["FORCE_PYTEST"] = "1" + monkeypatch.setenv("FORCE_PYTEST", "1") else: - env.pop("FORCE_PYTEST", None) + monkeypatch.delenv("FORCE_PYTEST", raising=False) if parallel: - env.pop("WORKSPACE_POE_PARALLEL", None) + monkeypatch.delenv("WORKSPACE_POE_PARALLEL", raising=False) else: - env["WORKSPACE_POE_PARALLEL"] = "0" - result = subprocess.run( - (TOOL, *args), - check=True, - capture_output=True, - cwd=tmp_path, - env=env, - text=True, - ) - return result.stdout.splitlines() + monkeypatch.setenv("WORKSPACE_POE_PARALLEL", "0") + return poe_tool.build_command("pytest", args) -def test_pytest_wrapper_prefers_ggt(tmp_path: Path) -> None: - _runner(tmp_path, "ggt") - _runner(tmp_path, "pytest") - - output = _run(tmp_path, ("tests",)) - - assert output[-2:] == [str(tmp_path / "ggt"), "tests"] +def test_pytest_wrapper_prefers_ggt(monkeypatch) -> None: + assert _command(monkeypatch, ("tests",)) == ["ggt", "tests"] def test_pytest_wrapper_runs_ggt_sequentially_when_parallel_is_disabled( - tmp_path: Path, + monkeypatch, ) -> None: - _runner(tmp_path, "ggt") - - output = _run(tmp_path, ("tests",), parallel=False) - - assert output[-4:] == [str(tmp_path / "ggt"), "-j", "1", "tests"] + assert _command(monkeypatch, ("tests",), parallel=False) == [ + "ggt", + "-j", + "1", + "tests", + ] def test_pytest_wrapper_uses_structured_ggt_output_for_lograil( - tmp_path: Path, + monkeypatch, ) -> None: - _runner(tmp_path, "ggt") - - output = _run(tmp_path, ("tests",), lograil_progress=True) - - assert output[-4:] == [ - str(tmp_path / "ggt"), + assert _command(monkeypatch, ("tests",), lograil_progress=True) == [ + "ggt", "--output-format", "json", "tests", ] -def test_pytest_wrapper_falls_back_to_parallel_pytest(tmp_path: Path) -> None: - _runner(tmp_path, "pytest") - - output = _run(tmp_path, ("tests",)) - - assert output[-4:] == [str(tmp_path / "pytest"), "-n", "auto", "tests"] - - -def test_pytest_wrapper_can_force_pytest(tmp_path: Path) -> None: - _runner(tmp_path, "ggt") - _runner(tmp_path, "pytest") +def test_pytest_wrapper_falls_back_to_parallel_pytest(monkeypatch) -> None: + assert _command(monkeypatch, ("tests",), ggt_available=False) == [ + "pytest", + "-n", + "auto", + "tests", + ] - output = _run(tmp_path, ("tests",), force_pytest=True) - assert output[-4:] == [str(tmp_path / "pytest"), "-n", "auto", "tests"] +def test_pytest_wrapper_can_force_pytest(monkeypatch) -> None: + assert _command(monkeypatch, ("tests",), force_pytest=True) == [ + "pytest", + "-n", + "auto", + "tests", + ] diff --git a/tests/unit/test_workspace_poe.py b/tests/unit/test_workspace_poe.py index 62eec506..9e15e97b 100644 --- a/tests/unit/test_workspace_poe.py +++ b/tests/unit/test_workspace_poe.py @@ -105,6 +105,7 @@ def test_scope_command_lograil_name_includes_task_and_package() -> None: def test_example_scope_command_maps_root_to_internal_task(monkeypatch) -> None: + monkeypatch.delenv("FORCE_PYTEST", raising=False) monkeypatch.setattr( workspace_poe.shutil, "which", @@ -131,6 +132,7 @@ def test_example_scope_command_maps_root_to_internal_task(monkeypatch) -> None: def test_example_scope_command_preserves_package_passthrough(monkeypatch) -> None: + monkeypatch.delenv("FORCE_PYTEST", raising=False) monkeypatch.setattr( workspace_poe.shutil, "which", @@ -152,6 +154,28 @@ def test_example_scope_command_preserves_package_passthrough(monkeypatch) -> Non assert command.parser == "generic" +def test_root_test_builds_native_pytest_wrapper_argv(monkeypatch) -> None: + recorded = [] + monkeypatch.setenv("PYTEST", "python scripts/poe/tasks/tool pytest") + monkeypatch.delenv("POE_EXTRA_ARGS", raising=False) + monkeypatch.delenv("WORKSPACE_POE_SCOPE_ARGS", raising=False) + + def run_command(command): + recorded.append(command) + return 0 + + monkeypatch.setattr(workspace_poe, "run_command", run_command) + + assert workspace_poe.run_root_task("test-root", ()) == 0 + + assert recorded[0].argv[:4] == ( + sys.executable, + str(workspace_poe.SCRIPT_DIR / "tasks" / "tool"), + "pytest", + "tests/unit", + ) + + def test_test_scope_uses_pytest_parser_for_pytest_fallback(monkeypatch) -> None: monkeypatch.delenv("FORCE_PYTEST", raising=False) monkeypatch.setattr(workspace_poe.shutil, "which", lambda *args, **kwargs: None) @@ -242,6 +266,48 @@ def fake_run_process_group(specs): assert mapped["lograil.progress.description"] == "tests/test_api.py::test_get" +def test_ggt_remap_retains_failure_after_later_passes() -> None: + remap = workspace_poe._ggt_entry_remap() + entries = [ + remap( + { + "levelname": "ERROR", + "message": "ERROR test_verify.test_refresh: RuntimeError: refresh failed", + } + ) + ] + entries.extend( + remap({"levelname": "INFO", "message": f"PASSED test_verify.test_{index}"}) + for index in range(60) + ) + + lines = workspace_poe.failure_tail_lines(entries[-50:]) + + assert lines == ["ERROR test_verify.test_refresh: RuntimeError: refresh failed"] + assert workspace_poe.failure_tail_lines(entries) == lines + + +def test_ggt_remap_retains_teardown_traceback_without_a_message() -> None: + remap = workspace_poe._ggt_entry_remap() + entries = [ + remap( + { + "levelname": "ERROR", + "ggt.detail": { + "kind": "error", + "error_message": "fixture teardown failed\nRuntimeError: cleanup failed", + }, + } + ), + remap({"levelname": "INFO", "message": "FAILURE: 85 tests, 1 errors"}), + ] + + assert workspace_poe.failure_tail_lines(entries) == [ + "fixture teardown failed\nRuntimeError: cleanup failed", + "FAILURE: 85 tests, 1 errors", + ] + + def test_run_sequential_can_stop_after_first_failure(monkeypatch) -> None: commands = [ workspace_poe.CommandSpec("first", ("first",), Path.cwd(), {}), diff --git a/uv.lock b/uv.lock index 3e0dc781..0ec45c3f 100644 --- a/uv.lock +++ b/uv.lock @@ -40,7 +40,7 @@ dev = [ { name = "cryptography", specifier = ">=48.0.1" }, { name = "django-stubs", specifier = ">=6.0.6,<7" }, { name = "fastapi", specifier = ">=0.115.0,<1" }, - { name = "ggt", marker = "python_full_version >= '3.11'", specifier = ">=1.5.5" }, + { name = "ggt", marker = "python_full_version >= '3.11'", specifier = ">=1.5.6" }, { name = "hatchling", specifier = ">=1.27.0,<2" }, { name = "hypothesis", specifier = ">=6.0.0,<7" }, { name = "lograil", specifier = "~=0.7.0" }, @@ -60,7 +60,7 @@ dev = [ { name = "twine", specifier = "<7" }, { name = "ty", specifier = "==0.0.55" }, { name = "uvicorn", specifier = ">=0.30.0,<1" }, - { name = "uvloop", specifier = "<1" }, + { name = "uvloop", marker = "sys_platform != 'win32'", specifier = "<1" }, { name = "vendoring", specifier = ">=1,<2" }, { name = "zizmor", specifier = ">=1.24.1,<2" }, ] @@ -729,14 +729,14 @@ wheels = [ [[package]] name = "ggt" -version = "1.5.5" +version = "1.5.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/ca/4fe1070242edd6f1981ca53ebe6c0175f32eaa72e046462c0cc84ce1cfb4/ggt-1.5.5.tar.gz", hash = "sha256:20a671444ee1b26aadb623ab951ac1ea4f17023b333dff7cbb4feed75227a903", size = 199433, upload-time = "2026-08-26T16:46:34.585Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/40/f4943f0de73a99c98595f23b0127ae56acb75d076671af171946d474d5be/ggt-1.5.6.tar.gz", hash = "sha256:7a43ed482b1b1741bbdeb798e8379b0d0cda189889ed354220363df0531bb475", size = 199752, upload-time = "2026-08-26T19:22:40.21Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/2f/15d2f9bea4df60080ff19bf7ae7c16d4e82c5cbe8338f46d829a260e5632/ggt-1.5.5-py3-none-any.whl", hash = "sha256:0b0d9beec4290f382ac210c2479bb6782c66082d40ff23d17582e9e1f57f7ddc", size = 124216, upload-time = "2026-08-26T16:46:33.353Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/12027320cf0a6f7017f87b6a4c02c3532bf51b03d8e281f7f64aa20009e5/ggt-1.5.6-py3-none-any.whl", hash = "sha256:a39a01ac509438573cbdff3a13528d059183d7b485e9ccd25e04f2a979690ff4", size = 124283, upload-time = "2026-08-26T19:22:38.997Z" }, ] [[package]]