From 7b2c64108770e6a56bfd8b5caca02120f35779eb Mon Sep 17 00:00:00 2001 From: rockdu Date: Tue, 9 Jun 2026 23:47:08 -0700 Subject: [PATCH 1/2] ci: bootstrap CPU CI ported from radixark/miles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the AST-discovered test framework, reusable workflow, pre-commit config, and seed tests from the miles main repo, trimmed to CPU-only. Framework (tests/ci/, direct copy): - ci_register.py / ci_utils.py / run_suite.py — AST-parsed register_cpu_ci markers + LPT partitioning + pytest invocation - cpu_stubs/sgl_kernel — MagicMock stub so sglang's import chain succeeds on ubuntu-latest where sgl_kernel (GPU-only) cannot install Adaptations for miles-D (vs miles main): - labels.py: rewritten KNOWN_LABELS for miles-D domains - run_suite.py: PER_COMMIT_SUITES[HWBackend.CUDA] = [] (no GPU runners) - _run-ci.yml: stripped GPU run: job + Megatron checkout/install - pr-test.yml: removed all GPU stages - .pre-commit-config.yaml: dropped ban-mpu-get local hook; added exclude: ^flow_grpo/ on all 4 formatter hooks - pyproject.toml: asyncio_mode = "auto" + ruff extend-exclude flow_grpo Seed test: - tests/fast/utils/test_misc.py exercises FunctionRegistry, load_function, and should_run_periodic_action Support helper: - miles/utils/misc.py: add FunctionRegistry class used by the seed test for registering callables under test names requirements.txt: add pytest + pytest-asyncio for the test runner. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/_run-ci.yml | 137 ++++++++ .github/workflows/bot-slash-lint.yaml | 110 ++++++ .github/workflows/pr-test.yml | 69 ++++ .github/workflows/pre-commit.yml | 41 +++ .pre-commit-config.yaml | 50 +++ miles/utils/misc.py | 38 ++- pyproject.toml | 4 + requirements.txt | 4 +- tests/__init__.py | 0 tests/ci/__init__.py | 0 tests/ci/ci_register.py | 240 +++++++++++++ tests/ci/ci_utils.py | 372 +++++++++++++++++++++ tests/ci/cpu_stubs/pyproject.toml | 11 + tests/ci/cpu_stubs/sgl_kernel/__init__.py | 18 + tests/ci/cpu_stubs/sgl_kernel/kvcacheio.py | 9 + tests/ci/labels.py | 27 ++ tests/ci/run_suite.py | 357 ++++++++++++++++++++ tests/fast/__init__.py | 0 tests/fast/utils/__init__.py | 0 tests/fast/utils/test_misc.py | 84 +++++ 20 files changed, 1569 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/_run-ci.yml create mode 100644 .github/workflows/bot-slash-lint.yaml create mode 100644 .github/workflows/pr-test.yml create mode 100644 .github/workflows/pre-commit.yml create mode 100644 .pre-commit-config.yaml create mode 100644 tests/__init__.py create mode 100644 tests/ci/__init__.py create mode 100644 tests/ci/ci_register.py create mode 100644 tests/ci/ci_utils.py create mode 100644 tests/ci/cpu_stubs/pyproject.toml create mode 100644 tests/ci/cpu_stubs/sgl_kernel/__init__.py create mode 100644 tests/ci/cpu_stubs/sgl_kernel/kvcacheio.py create mode 100644 tests/ci/labels.py create mode 100644 tests/ci/run_suite.py create mode 100644 tests/fast/__init__.py create mode 100644 tests/fast/utils/__init__.py create mode 100644 tests/fast/utils/test_misc.py diff --git a/.github/workflows/_run-ci.yml b/.github/workflows/_run-ci.yml new file mode 100644 index 00000000..af0ca236 --- /dev/null +++ b/.github/workflows/_run-ci.yml @@ -0,0 +1,137 @@ +name: CI Job + +# Reusable workflow ported from radixark/miles (.github/workflows/_run-ci.yml). +# +# CPU-only path: GitHub-hosted ubuntu-latest. Trimmed from miles main: +# * GPU `run:` job removed entirely — miles-D has no self-hosted GPU +# runners. To re-add: copy the `run:` job block back from miles main, +# reintroduce `runs_on / container_image / skip_dependency_install / +# cpu_runner` inputs, and add a matching `if: !inputs.cpu_runner` gate. +# * Megatron-LM checkout/install removed: miles-D source has zero +# `from megatron` imports (verified via grep). +# * PR-body magic for `ci-megatron-pr:` removed for the same reason. +# * sglang checkout/install preserved (miles-D imports +# sglang.multimodal_gen.runtime.* and sglang.srt.*). + +on: + workflow_call: + inputs: + execute_command: + type: string + required: true + +# TODO: run gpu +jobs: + run-cpu: + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + PYTHONPATH: ${{ github.workspace }} + steps: + - name: Free disk space + shell: bash + run: | + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc + df -h + + - name: Checkout miles-diffusion + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + # 3.10 matches miles-D's setup.py / pyproject (python_requires=">=3.10"). + python-version: '3.10' + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Install system dependencies + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y protobuf-compiler + protoc --version + + # TODO: sglang-diffusion-rollout-test to be switched back to sgl main + - name: Resolve dependency refs + id: resolve-refs + shell: bash + env: + PR_BODY: ${{ github.event.pull_request.body || '' }} + INPUT_SGLANG_PR: ${{ github.event.inputs.ci_sglang_pr || '' }} + INPUT_SGLANG_REPO: ${{ github.event.inputs.ci_sglang_repo || '' }} + run: | + SGLANG_PR="${INPUT_SGLANG_PR}" + SGLANG_REPO="${INPUT_SGLANG_REPO}" + if [ -n "$PR_BODY" ]; then + PR_SGLANG_PR=$(echo "$PR_BODY" | grep -m1 -oP '^ci-sglang-pr:\s+\K\S+' || true) + [ -z "$SGLANG_PR" ] && [ -n "$PR_SGLANG_PR" ] && SGLANG_PR="$PR_SGLANG_PR" + PR_SGLANG_REPO=$(echo "$PR_BODY" | grep -m1 -oP '^ci-sglang-repo:\s+\K\S+' || true) + [ -z "$SGLANG_REPO" ] && [ -n "$PR_SGLANG_REPO" ] && SGLANG_REPO="$PR_SGLANG_REPO" + fi + [ -z "$SGLANG_PR" ] && SGLANG_PR="sglang-diffusion-rollout-test" + # TODO: default repo Rockdu/sglang to be switched back to sgl-project/sglang + [ -z "$SGLANG_REPO" ] && SGLANG_REPO="Rockdu/sglang" + resolve_fetch_ref() { + local ref="$1" + if [[ "$ref" =~ ^#([0-9]+)$ ]]; then + echo "refs/pull/${BASH_REMATCH[1]}/head" + else + echo "$ref" + fi + } + SGLANG_FETCH=$(resolve_fetch_ref "$SGLANG_PR") + echo "ci_sglang_pr=$SGLANG_FETCH" >> $GITHUB_OUTPUT + echo "sglang_repo=$SGLANG_REPO" >> $GITHUB_OUTPUT + echo "Resolved: sglang repo=$SGLANG_REPO ref=$SGLANG_PR -> fetch=$SGLANG_FETCH" + + # TODO: default sglang repo (Rockdu/sglang) to be switched back to sgl main + - name: Checkout temporary sglang + uses: actions/checkout@v4 + with: + repository: ${{ steps.resolve-refs.outputs.sglang_repo }} + ref: ${{ steps.resolve-refs.outputs.ci_sglang_pr }} + path: sglang + + # - name: Checkout sglang + # uses: actions/checkout@v4 + # with: + # repository: sgl-project/sglang + # ref: ${{ steps.resolve-refs.outputs.ci_sglang_pr }} + # path: sglang + + - name: Install dependencies + shell: bash + env: + UV_SYSTEM_PYTHON: "1" + run: | + uv pip install -e sglang/python --no-deps + uv pip install -r requirements.txt + uv pip install -e . --no-deps + # sglang is installed --no-deps above, but miles-D's import chain + # loads many sglang modules. Install sglang's pure-python runtime + # deps upfront; skip GPU-only ones (cuda-python, flashinfer, + # flash-attn-4, sglang-kernel, torchao, torchcodec, + # torch_memory_saver, quack-kernels, nvidia-cutlass-dsl, + # apache-tvm-ffi, kernels, decord2, av). torch itself comes via + # accelerate (in requirements.txt). + uv pip install \ + IPython aiohttp anthropic build compressed-tensors einops fastapi \ + gguf interegular jsonschema llguidance mistral_common modelscope \ + msgspec ninja nvidia-ml-py openai openai-harmony orjson outlines \ + partial_json_parser prometheus-client psutil py-spy pydantic \ + python-multipart pyzmq scipy sentencepiece setproctitle soundfile \ + tiktoken timm torchvision uvicorn uvloop watchfiles xgrammar + # sglang's memory_pool_host.py unconditionally imports sgl_kernel + # on non-NPU/XPU/MPS hardware. sgl_kernel is GPU-only — install a + # local stub so imports succeed at module load. + uv pip install tests/ci/cpu_stubs + + - name: Resolve suite plan + shell: bash + run: ${{ inputs.execute_command }} --list-only + + - name: Run tests + shell: bash + run: ${{ inputs.execute_command }} diff --git a/.github/workflows/bot-slash-lint.yaml b/.github/workflows/bot-slash-lint.yaml new file mode 100644 index 00000000..ea234876 --- /dev/null +++ b/.github/workflows/bot-slash-lint.yaml @@ -0,0 +1,110 @@ +name: Slash Command Handler + +on: + issue_comment: + types: [created, edited] + +permissions: + contents: write # Required to push commits back to PR branch + actions: write # Required to rerun workflows + issues: write # Required for comment reactions in some contexts + +jobs: + slash_lint_codebase: + # Only run if it is a PR comment with a recognized command + if: > + github.event_name == 'issue_comment' && + github.event.issue.pull_request && + ( + contains(github.event.comment.body, '/tag-run-lint') || + contains(github.event.comment.body, '/run-lint') + ) + runs-on: ubuntu-latest + steps: + - name: React to command comment (ack) + if: always() + uses: actions/github-script@v7 + with: + script: | + const commentId = context.payload.comment.id; + // Add an eyes reaction to acknowledge the command + await github.request('POST /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions', { + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + content: 'eyes' + }); + + - name: Check out Git repository + uses: actions/checkout@v4 + with: + repository: ${{ github.repository }} + ref: refs/pull/${{ github.event.issue.number }}/head + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Run pre-commit hooks + continue-on-error: true + uses: pre-commit/action@v3.0.1 + + - name: Get PR branch name + id: get_branch + run: | + BRANCH_NAME=$(gh pr view ${{ github.event.issue.number }} --json headRefName --jq '.headRefName') + echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check if there are any changes + id: verify_diff + run: | + git diff --quiet . || echo "changed=true" >> $GITHUB_OUTPUT + + - name: Commit files + if: steps.verify_diff.outputs.changed == 'true' + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git add . + git commit -m "[CI-Lint] Fix code style issues with pre-commit ${{ github.sha }}" -a + git push origin HEAD:refs/heads/${{ steps.get_branch.outputs.branch_name }} + + cleanup_reaction: + # Always run after the main job completes (success, failure, or cancelled) + if: always() + needs: slash_lint_codebase + runs-on: ubuntu-latest + steps: + - name: Remove initial ack reaction + uses: actions/github-script@v7 + with: + script: | + const commentId = context.payload.comment.id; + // List reactions on the comment + const reactions = await github.rest.reactions.listForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId + }).then(r => r.data); + // Find the 'eyes' reaction added by this workflow bot + const target = reactions.find(r => r.content === 'eyes' && r.user && r.user.login === 'github-actions[bot]'); + if (target) { + try { + await github.rest.reactions.deleteForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: commentId, + reaction_id: target.id + }); + core.info(`Successfully deleted eyes reaction (${target.id})`); + } catch (err) { + // Non-fatal: reaction may already be gone or inaccessible + core.info(`Could not delete eyes reaction (${target.id}): ${err.message || err.status || 'unknown error'}`); + } + } else { + core.info('No eyes reaction from github-actions[bot] found to remove.'); + } diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml new file mode 100644 index 00000000..5dd3d236 --- /dev/null +++ b/.github/workflows/pr-test.yml @@ -0,0 +1,69 @@ +name: PR Test + +# Ported from radixark/miles (.github/workflows/pr-test.yml). +# +# Differences vs miles main: +# * All GPU stages (stage-b-2-gpu-h200, stage-c-8-gpu-h100, +# stage-c-4-gpu-h200, stage-c-2-gpu-h200) removed — miles-D has no +# self-hosted GPU runner fleet yet. To re-enable, copy the job blocks +# back from miles main and provision matching runners. +# * `resolve-ci-image` job removed — only relevant for GPU stages that +# pull a `radixark/miles:` container. +# * `ci_megatron_pr` workflow_dispatch input removed — miles-D source has +# zero `from megatron` imports. + +on: + pull_request: + types: [synchronize, labeled, opened, reopened] + workflow_dispatch: + inputs: + ci_sglang_pr: + description: 'SGLang branch/commit (default: sglang-miles)' + required: false + type: string + default: 'sglang-miles-diffusion' + ci_sglang_repo: + description: 'SGLang repository owner/name (default: Rockdu/sglang)' + required: false + type: string + default: 'Rockdu/sglang' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +# PR labels are passed through to run_suite.py as raw `run-ci-` strings via +# `--labels`. run_suite.py strips the `run-ci-` prefix internally and ignores +# any label that does not start with `run-ci-` (see tests/ci/run_suite.py +# `strip_run_ci_prefix`). For `workflow_dispatch`, no PR labels exist, so the +# `--labels` list collapses to empty; `--match-all-labels` is then added +# unconditionally to bypass the labels predicate inside run_suite.py and run +# every enabled test in the suite. + +jobs: + # Stage A: CPU-only fast tests (always runs on PR) + stage-a-cpu: + if: (github.event_name == 'workflow_dispatch') || (github.event.pull_request) + uses: ./.github/workflows/_run-ci.yml + with: + execute_command: >- + python tests/ci/run_suite.py --hw cpu --suite stage-a-cpu + --labels ${{ join(github.event.pull_request.labels.*.name, ' ') }} + ${{ (github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'run-ci-image') || contains(github.event.pull_request.labels.*.name, 'run-ci-all')) && '--match-all-labels' || '' }} + secrets: inherit + + # Stage B: CPU-only slower bucket (currently empty; reserved for future + # CPU tests that don't fit stage-a-cpu's fast budget). Always runs on PR. + # Empty result exits 0 in run_suite.py. + stage-b-cpu: + if: (github.event_name == 'workflow_dispatch') || (github.event.pull_request) + uses: ./.github/workflows/_run-ci.yml + with: + execute_command: >- + python tests/ci/run_suite.py --hw cpu --suite stage-b-cpu + --labels ${{ join(github.event.pull_request.labels.*.name, ' ') }} + ${{ (github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'run-ci-image') || contains(github.event.pull_request.labels.*.name, 'run-ci-all')) && '--match-all-labels' || '' }} + secrets: inherit diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 00000000..d0e05b27 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,41 @@ +name: pre-commit + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + +jobs: + run-pre-commit: + name: Run pre-commit + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: 'pip' + + - name: Install pre-commit + run: pip install --upgrade pip pre-commit + + - name: Cache pre-commit environments + uses: actions/cache@v4 + with: + path: ~/.cache/pre-commit + key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }} + restore-keys: | + pre-commit-${{ runner.os }}- + + - name: Run pre-commit on all files + run: pre-commit run --all-files --show-diff-on-failure --color=always + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..c7ccbdcb --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,50 @@ +default_language_version: + python: python3 + +ci: + autofix_prs: true + autoupdate_commit_msg: '[pre-commit.ci] pre-commit suggestions' + autoupdate_schedule: quarterly + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-yaml + - id: check-case-conflict + - id: detect-private-key + - id: check-added-large-files + args: ['--maxkb=1000'] + - id: requirements-txt-fixer + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.7 + hooks: + - id: ruff-check + args: [ --fix ] + exclude: ^flow_grpo/ + + - repo: https://github.com/PyCQA/autoflake + rev: v2.0.2 + hooks: + - id: autoflake + args: [--remove-all-unused-imports, --in-place] + exclude: ^flow_grpo/ + + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + args: + - "--profile=black" + - "--filter-files" + additional_dependencies: [] + exclude: ^flow_grpo/ + + - repo: https://github.com/psf/black + rev: 24.3.0 + hooks: + - id: black + name: Format code + additional_dependencies: ['click==8.0.2'] + exclude: ^flow_grpo/ diff --git a/miles/utils/misc.py b/miles/utils/misc.py index 94407cb2..3afb6833 100644 --- a/miles/utils/misc.py +++ b/miles/utils/misc.py @@ -1,16 +1,52 @@ import importlib +from contextlib import contextmanager import ray from miles.utils.http_utils import is_port_available +# Mainly used for test purpose where `load_function` needs to load many in-flight generated functions +class FunctionRegistry: + def __init__(self): + self._registry: dict[str, object] = {} + + @contextmanager + def temporary(self, name: str, fn: object): + self._register(name, fn) + try: + yield + finally: + self._unregister(name) + + def get(self, name: str) -> object | None: + return self._registry.get(name) + + def _register(self, name: str, fn: object) -> None: + assert name not in self._registry + self._registry[name] = fn + + def _unregister(self, name: str) -> None: + assert name in self._registry + self._registry.pop(name) + + +function_registry = FunctionRegistry() + + def load_function(path): """ - Load a function from a module. + Load a function from registry or module. :param path: The path to the function, e.g. "module.submodule.function". :return: The function object. """ + if path is None: + return None + + registered = function_registry.get(path) + if registered is not None: + return registered + module_path, _, attr = path.rpartition(".") module = importlib.import_module(module_path) return getattr(module, attr) diff --git a/pyproject.toml b/pyproject.toml index 7d9ca6f6..b0f7628e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,9 @@ line_length = 119 [tool.ruff] line-length = 320 # TODO +extend-exclude = ["flow_grpo"] + +[tool.ruff.lint] select = [ "E", # Pycodestyle Errors (Structural/Fundamental Errors like bad indentation) "F", # Pyflakes (Core Errors: Unused imports, undefined names) @@ -40,6 +43,7 @@ ignore = [ # -vv will also display tests with duration = 0.00s addopts = "--verbose --pyargs --durations=0 --strict-markers" # always add these arguments to pytest testpaths = ["./tests"] # must be an explicit path to avoid importing another "tests" module +asyncio_mode = "auto" # async tests work without @pytest.mark.asyncio # directories to ignore when discovering tests norecursedirs = [ "external", diff --git a/requirements.txt b/requirements.txt index af922654..e0114142 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,13 +17,15 @@ peft==0.18.1 pillow==11.3.0 pydantic==2.12.5 pylatexenc==2.10 +pytest>=7.0.0 +pytest-asyncio python-Levenshtein==0.27.3 pyyaml==6.0.1 qwen_vl_utils==0.0.14 ray[default]==2.53.0 requests==2.32.5 -safetensors==0.7.0 ring_flash_attn==0.1.8 +safetensors==0.7.0 sglang-router==0.3.0 tensorboard==2.20.0 transformers==5.5.4 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/ci/__init__.py b/tests/ci/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/ci/ci_register.py b/tests/ci/ci_register.py new file mode 100644 index 00000000..d2cd7f73 --- /dev/null +++ b/tests/ci/ci_register.py @@ -0,0 +1,240 @@ +import ast +import warnings +from dataclasses import dataclass, field +from enum import Enum, auto + +from tests.ci.labels import KNOWN_LABELS + +__all__ = [ + "HWBackend", + "CIRegistry", + "collect_tests", + "register_cpu_ci", + "register_cuda_ci", + "ut_parse_one_file", +] + +# Only these two parameters may be passed positionally; everything else +# (labels, always_on, nightly, disabled) is keyword-only. +_POSITIONAL_PARAMS = ("est_time", "suite") + +# All accepted keyword arguments (in addition to the positional pair above). +_VALID_KWARGS = frozenset({"est_time", "suite", "labels", "nightly", "disabled"}) + +_REGISTER_NAMES = frozenset({"register_cpu_ci", "register_cuda_ci"}) + +_UNSET = object() + + +class HWBackend(Enum): + CPU = auto() + CUDA = auto() + + +@dataclass +class CIRegistry: + backend: HWBackend + filename: str + est_time: float + suite: str + labels: list[str] = field(default_factory=list) + nightly: bool = False + disabled: str | None = None # None = enabled, string = disabled reason + + +def register_cpu_ci( + est_time: float, + suite: str, + *, + labels: list[str] | None = None, + nightly: bool = False, + disabled: str | None = None, +): + """Marker for CPU CI registration (parsed via AST; runtime no-op). + + `labels=None` and `labels=[]` are equivalent: the test runs on every PR + regardless of `run-ci-*` labels. A non-empty `labels` list gates the test + on PR labels — the test runs when the PR carries `run-ci-` for any + `` in `labels`. + """ + return None + + +def register_cuda_ci( + est_time: float, + suite: str, + *, + labels: list[str] | None = None, + nightly: bool = False, + disabled: str | None = None, +): + """Marker for CUDA CI registration (parsed via AST; runtime no-op). + + See `register_cpu_ci` for label semantics. + """ + return None + + +_REGISTER_BACKEND_MAP = { + "register_cpu_ci": HWBackend.CPU, + "register_cuda_ci": HWBackend.CUDA, +} + + +def _extract_constant(node: ast.AST) -> object: + """Return the literal value of an ast.Constant; otherwise return _UNSET. + + Sentinel return (instead of raising) lets callers compose richer error + messages with parameter names and file paths. + """ + if isinstance(node, ast.Constant): + return node.value + return _UNSET + + +def _extract_list_constant(node: ast.AST, *, context: str = "value") -> list: + """Return a list of literal string constants from `ast.List`. + + Accepts `None` (as `ast.Constant(None)`) and treats it as an empty list, + so callers may write `labels=None` interchangeably with `labels=[]`. + + Raises ValueError when the node is neither a list literal of string + constants nor a literal `None`. + """ + if isinstance(node, ast.Constant) and node.value is None: + return [] + if not isinstance(node, ast.List): + raise ValueError(f"{context} must be a list of string literals or None (got {type(node).__name__})") + out: list = [] + for elt in node.elts: + v = _extract_constant(elt) + if v is _UNSET: + raise ValueError(f"{context} must be a list of string literals (non-literal element)") + if not isinstance(v, str): + raise ValueError(f"{context} must be a list of string literals (got {type(v).__name__} element)") + out.append(v) + return out + + +class RegistryVisitor(ast.NodeVisitor): + def __init__(self, filename: str): + self.filename = filename + self.registries: list[CIRegistry] = [] + + def _parse_call_args(self, func_call: ast.Call, func_name: str) -> CIRegistry: + if any(isinstance(arg, ast.Starred) for arg in func_call.args): + raise ValueError(f"{self.filename}: starred arguments are not supported in {func_name}()") + + if len(func_call.args) > len(_POSITIONAL_PARAMS): + raise ValueError( + f"{self.filename}: too many positional arguments in {func_name}(); " + f"only {list(_POSITIONAL_PARAMS)} may be positional " + f"(labels and later are keyword-only)" + ) + + parsed: dict[str, object] = {} + + for name, arg in zip(_POSITIONAL_PARAMS, func_call.args, strict=False): + v = _extract_constant(arg) + if v is _UNSET: + raise ValueError(f"{self.filename}: {name} in {func_name}() must be a literal constant") + parsed[name] = v + + for kw in func_call.keywords: + if kw.arg is None: + raise ValueError(f"{self.filename}: **kwargs are not supported in {func_name}()") + if kw.arg in parsed: + raise ValueError(f"{self.filename}: duplicated argument '{kw.arg}' in {func_name}()") + if kw.arg not in _VALID_KWARGS: + raise ValueError(f"{self.filename}: unknown argument '{kw.arg}' in {func_name}()") + if kw.arg == "labels": + parsed["labels"] = _extract_list_constant( + kw.value, context=f"{self.filename}: labels in {func_name}()" + ) + else: + v = _extract_constant(kw.value) + if v is _UNSET: + raise ValueError(f"{self.filename}: {kw.arg} in {func_name}() must be a literal constant") + parsed[kw.arg] = v + + if "est_time" not in parsed: + raise ValueError(f"{self.filename}: est_time is required in {func_name}()") + if "suite" not in parsed: + raise ValueError(f"{self.filename}: suite is required in {func_name}()") + + if not isinstance(parsed["est_time"], (int, float)): + raise ValueError(f"{self.filename}: est_time must be a number in {func_name}()") + if not isinstance(parsed["suite"], str): + raise ValueError(f"{self.filename}: suite must be a string in {func_name}()") + + # `labels` is optional. Missing / None / [] all mean "always run on + # every PR"; only a non-empty list gates the test on PR labels. + labels = parsed.get("labels", []) + if not isinstance(labels, list): + raise ValueError(f"{self.filename}: labels must be a list or None in {func_name}()") + + nightly = parsed.get("nightly", False) + if not isinstance(nightly, bool): + raise ValueError(f"{self.filename}: nightly must be a boolean in {func_name}()") + + disabled = parsed.get("disabled", None) + if disabled is not None and not isinstance(disabled, str): + raise ValueError(f"{self.filename}: disabled must be a string or None in {func_name}()") + + unknown = [label for label in labels if label not in KNOWN_LABELS] + if unknown: + valid_list = ", ".join(sorted(KNOWN_LABELS)) + raise ValueError( + f"{self.filename}: unknown labels {unknown} in {func_name}(); " + f"valid labels: [{valid_list}]. " + f"To add a new label: edit tests/ci/labels.py + create matching " + f"`run-ci-