Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions tools/stronghold/src/api/compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import pathlib
import tempfile
from collections.abc import Iterable, Mapping, Sequence
import ast

import api
import api.ast
Expand Down Expand Up @@ -71,10 +72,25 @@ def check(
before_api = api.ast.extract(before)
after_api = api.ast.extract(after)

before_raw = api.ast.extract_raw(before)
Copy link
Contributor

Choose a reason for hiding this comment

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

extract already calls extract_raw internally.

Ideally instead of parsing AST two times we need to refactor extract and extract_raw to:

  1. preserve the current semantics of the existing functions
  2. have another function that returns both raw and mapped result reusing the implementation internally

alternatively, some use some kind of (short-term) memoization?

after_raw = api.ast.extract_raw(after)

disabled_funcs = {
name
for name, node in before_raw.items()
if _decorator_disables(node)
} | {
name
for name, node in after_raw.items()
if _decorator_disables(node)
}

violations: list[api.violations.Violation] = []
for name, before_def in before_api.items():
if any(token.startswith("_") for token in name.split(".")):
continue
if name in disabled_funcs:
continue

after_def = after_api.get(name)
if after_def is None:
Expand Down Expand Up @@ -320,3 +336,43 @@ def _check_type_compatibility(
return False

return True


def _decorator_disables(node: ast.FunctionDef) -> bool:
Copy link
Contributor

@izaitsevfb izaitsevfb Jul 23, 2025

Choose a reason for hiding this comment

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

Looks like the current decorator behavior is "opt-out" — the default behavior (public function is checked) is changed only when:

@bc_linter.check_compat(enable=False)

Is this the intended behavior? If yes, can't we just simplify the opt-out syntax to something like @bc_linter.skip.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

"""Returns True if the bc_linter.check_compat decorator disables checks."""

for deco in node.decorator_list:
name = _decorator_name(deco)
if name != "bc_linter.check_compat":
continue

enable = True
if isinstance(deco, ast.Call):
# Look for keyword argument ``enable`` first
for kw in deco.keywords:
if kw.arg == "enable" and isinstance(kw.value, ast.Constant):
enable = bool(kw.value.value)
break
else:
if len(deco.args) == 1 and isinstance(deco.args[0], ast.Constant):
enable = bool(deco.args[0].value)

return not enable

return False


def _decorator_name(expr: ast.expr) -> str | None:
"""Returns dotted name of decorator if easily determined."""

if isinstance(expr, ast.Call):
expr = expr.func

if isinstance(expr, ast.Name):
return expr.id
if isinstance(expr, ast.Attribute):
value = _decorator_name(expr.value)
if value is None:
return None
return value + "." + expr.attr
return None
22 changes: 22 additions & 0 deletions tools/stronghold/src/bc_linter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Utilities for marking API compatibility checks."""

from __future__ import annotations

from typing import Callable, TypeVar, Any

F = TypeVar("F", bound=Callable[..., Any])


def check_compat(*, enable: bool = True) -> Callable[[F], F]:
"""Decorator used by stronghold to toggle API compatibility checks.

When ``enable`` is ``False`` the decorated function will be skipped by the
backward compatibility linter.
"""

def decorator(func: F) -> F:
Copy link
Contributor

@izaitsevfb izaitsevfb Jul 23, 2025

Choose a reason for hiding this comment

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

If I'm understanding the intention here correctly, this decorator should be defined in the client code (unless we require bc-linter clients to import its codebase in runtime, which we should not!). The current implementation appears to support this (i.e. any decorator in VLLM codebase that sets _bc_linter_enable will be recognized by the bc linter), however, the way how the code is structured makes it seem like @check_compat is the "official" annotation that needs to be used.

I'd suggest to change this:

  1. move check_compat definition into test files
  2. document the way to do setattr(func, "_bc_linter_enable", enable) in the client code instead (maybe pointing to check_compat as an example)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh sounds good, I was thinking about importing this actually, but write the logic in client code makes more sense actually, thanks for the suggestion!

setattr(func, "_bc_linter_enable", enable)
Copy link
Contributor

Choose a reason for hiding this comment

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

hm, actually, looks like _bc_linter_enable is not used anywhere currently

return func

return decorator

34 changes: 34 additions & 0 deletions tools/stronghold/tests/api/test_compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import api.violations
import pytest
from testing import git, source
import bc_linter


def test_deleted_function(tmp_path: pathlib.Path) -> None:
Expand Down Expand Up @@ -520,3 +521,36 @@ def will_be_deleted():
api.violations.FunctionDeleted(func="will_be_deleted", line=1)
],
}


def test_check_disable_decorator(tmp_path: pathlib.Path) -> None:
@bc_linter.check_compat(enable=False)
def func(x: int) -> None:
pass # pragma: no cover

before = source.make_file(tmp_path, func)

@bc_linter.check_compat(enable=False)
def func(x: int, y: int) -> None: # type: ignore[no-redef]
pass # pragma: no cover

after = source.make_file(tmp_path, func)

assert api.compatibility.check(before, after) == []

def test_check_enable_decorator(tmp_path: pathlib.Path) -> None:
@bc_linter.check_compat(enable=True)
def func(x: int) -> None:
pass # pragma: no cover

before = source.make_file(tmp_path, func)

@bc_linter.check_compat(enable=True)
def func(x: int, y: int) -> None: # type: ignore[no-redef]
pass # pragma: no cover

after = source.make_file(tmp_path, func)

assert api.compatibility.check(before, after) == [
api.violations.ParameterNowRequired(func=func.__name__, parameter="y", line=2)
]
13 changes: 13 additions & 0 deletions tools/stronghold/tests/bc_linter_vllm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# BC Linter for vLLM
Copy link
Contributor Author

Choose a reason for hiding this comment

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

discussed with @izaitsevfb offline, we will try to make bc decorator more generalized by:

  • adding support for custom rules, where users can provide code path, black/white list
  • explore checking bc in classes

PR: https://github.com/vllm-project/vllm/pull/21234

## Code Path
Cover the following code path:
- vllm/v1/attetion/**
- vllm/v1/core/**

Additionally, we should have flexibility to cover other code path in the future.

## Lint Rules
- Check backward compatibility for dataclasses/functions defined python files in code path above
- The default behavior for linter is to check all the dataclasses/public functions in the code path, but we provide an option to skip bc-linter for some experimental dataclasses/functions with `@bc_linter_skip` decorator