|
| 1 | +"""Evaluate the subset of GitHub Actions expressions that workflow job names use.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import re |
| 6 | +from typing import Any |
| 7 | + |
| 8 | +from scripts.release_gate.constants import ( |
| 9 | + EXPRESSION_LITERALS, |
| 10 | + FALSY_VALUES, |
| 11 | + STARTS_WITH_ARGUMENTS, |
| 12 | + STARTS_WITH_FUNCTION, |
| 13 | +) |
| 14 | +from scripts.release_gate.exceptions import ReleaseGateError |
| 15 | +from scripts.release_gate.types import Operator, TokenKind |
| 16 | + |
| 17 | +_TEMPLATE: re.Pattern[str] = re.compile(r"\$\{\{(.*?)\}\}", re.DOTALL) |
| 18 | +_TOKEN: re.Pattern[str] = re.compile( |
| 19 | + r"\s*(?:(?P<string>'(?:[^']|'')*')|(?P<operator>&&|\|\||==|!=|!|\(|\)|,)" |
| 20 | + r"|(?P<name>[A-Za-z_][A-Za-z0-9_-]*(?:\.[A-Za-z_][A-Za-z0-9_-]*)*))" |
| 21 | +) |
| 22 | + |
| 23 | + |
| 24 | +def evaluate_template(*, template: str, contexts: dict[str, Any]) -> str: |
| 25 | + """Substitute every `${{ }}` expression in `template` with its value under `contexts`.""" |
| 26 | + return _TEMPLATE.sub( |
| 27 | + lambda match: _to_string(evaluate(expression=match.group(1), contexts=contexts)), |
| 28 | + template, |
| 29 | + ) |
| 30 | + |
| 31 | + |
| 32 | +def evaluate(*, expression: str, contexts: dict[str, Any]) -> Any: |
| 33 | + """Return the value of one expression under `contexts`.""" |
| 34 | + parser: _Parser = _Parser(tokens=_tokenize(expression), contexts=contexts) |
| 35 | + value: Any = parser.parse_or() |
| 36 | + if parser.peek() is not None: |
| 37 | + raise ReleaseGateError(f"Unexpected token {parser.peek()!r} in {expression!r}") |
| 38 | + return value |
| 39 | + |
| 40 | + |
| 41 | +def _tokenize(expression: str) -> list[tuple[TokenKind, str]]: |
| 42 | + tokens: list[tuple[TokenKind, str]] = [] |
| 43 | + position: int = 0 |
| 44 | + while expression[position:].strip(): |
| 45 | + match: re.Match[str] | None = _TOKEN.match(expression, position) |
| 46 | + if match is None or match.lastgroup is None: |
| 47 | + raise ReleaseGateError(f"Unsupported expression syntax at {expression[position:]!r}") |
| 48 | + tokens.append((TokenKind(match.lastgroup), match.group(match.lastgroup))) |
| 49 | + position = match.end() |
| 50 | + return tokens |
| 51 | + |
| 52 | + |
| 53 | +class _Parser: |
| 54 | + def __init__(self, *, tokens: list[tuple[TokenKind, str]], contexts: dict[str, Any]) -> None: |
| 55 | + self._tokens: list[tuple[TokenKind, str]] = tokens |
| 56 | + self._contexts: dict[str, Any] = contexts |
| 57 | + self._index: int = 0 |
| 58 | + |
| 59 | + def peek(self) -> str | None: |
| 60 | + return self._tokens[self._index][1] if self._index < len(self._tokens) else None |
| 61 | + |
| 62 | + def _take(self) -> tuple[TokenKind, str]: |
| 63 | + if self._index >= len(self._tokens): |
| 64 | + raise ReleaseGateError("Unexpected end of expression") |
| 65 | + token: tuple[TokenKind, str] = self._tokens[self._index] |
| 66 | + self._index += 1 |
| 67 | + return token |
| 68 | + |
| 69 | + def _expect(self, operator: Operator) -> None: |
| 70 | + if self._take() != (TokenKind.OPERATOR, operator): |
| 71 | + raise ReleaseGateError(f"Expected {operator.value!r}") |
| 72 | + |
| 73 | + def parse_or(self) -> Any: |
| 74 | + value: Any = self._parse_and() |
| 75 | + while self.peek() == Operator.OR: |
| 76 | + self._take() |
| 77 | + right: Any = self._parse_and() |
| 78 | + value = value if _truthy(value) else right |
| 79 | + return value |
| 80 | + |
| 81 | + def _parse_and(self) -> Any: |
| 82 | + value: Any = self._parse_equality() |
| 83 | + while self.peek() == Operator.AND: |
| 84 | + self._take() |
| 85 | + right: Any = self._parse_equality() |
| 86 | + value = right if _truthy(value) else value |
| 87 | + return value |
| 88 | + |
| 89 | + def _parse_equality(self) -> Any: |
| 90 | + value: Any = self._parse_unary() |
| 91 | + if self.peek() in (Operator.EQUAL, Operator.NOT_EQUAL): |
| 92 | + operator: str = self._take()[1] |
| 93 | + equal: bool = _equal(left=value, right=self._parse_unary()) |
| 94 | + return equal if operator == Operator.EQUAL else not equal |
| 95 | + return value |
| 96 | + |
| 97 | + def _parse_unary(self) -> Any: |
| 98 | + if self.peek() == Operator.NOT: |
| 99 | + self._take() |
| 100 | + return not _truthy(self._parse_unary()) |
| 101 | + return self._parse_primary() |
| 102 | + |
| 103 | + def _parse_primary(self) -> Any: |
| 104 | + kind, text = self._take() |
| 105 | + if (kind, text) == (TokenKind.OPERATOR, Operator.OPEN): |
| 106 | + value: Any = self.parse_or() |
| 107 | + self._expect(Operator.CLOSE) |
| 108 | + return value |
| 109 | + if kind is TokenKind.STRING: |
| 110 | + return text[1:-1].replace("''", "'") |
| 111 | + if kind is not TokenKind.NAME: |
| 112 | + raise ReleaseGateError(f"Unexpected token {text!r}") |
| 113 | + if text in EXPRESSION_LITERALS: |
| 114 | + return EXPRESSION_LITERALS[text] |
| 115 | + if self.peek() == Operator.OPEN: |
| 116 | + return self._call(text) |
| 117 | + return self._lookup(text) |
| 118 | + |
| 119 | + def _call(self, function: str) -> Any: |
| 120 | + self._expect(Operator.OPEN) |
| 121 | + arguments: list[Any] = [self.parse_or()] |
| 122 | + while self.peek() == Operator.SEPARATOR: |
| 123 | + self._take() |
| 124 | + arguments.append(self.parse_or()) |
| 125 | + self._expect(Operator.CLOSE) |
| 126 | + if function.lower() == STARTS_WITH_FUNCTION and len(arguments) == STARTS_WITH_ARGUMENTS: |
| 127 | + return _to_string(arguments[0]).lower().startswith(_to_string(arguments[1]).lower()) |
| 128 | + raise ReleaseGateError(f"Unsupported function {function}") |
| 129 | + |
| 130 | + def _lookup(self, path: str) -> Any: |
| 131 | + value: Any = self._contexts |
| 132 | + for key in path.split("."): |
| 133 | + value = value.get(key) if isinstance(value, dict) else None |
| 134 | + return value |
| 135 | + |
| 136 | + |
| 137 | +def _truthy(value: Any) -> bool: |
| 138 | + return value not in FALSY_VALUES |
| 139 | + |
| 140 | + |
| 141 | +def _equal(*, left: Any, right: Any) -> bool: |
| 142 | + if isinstance(left, str) and isinstance(right, str): |
| 143 | + return left.lower() == right.lower() |
| 144 | + return left == right |
| 145 | + |
| 146 | + |
| 147 | +def _to_string(value: Any) -> str: |
| 148 | + if value is None: |
| 149 | + return "" |
| 150 | + if isinstance(value, bool): |
| 151 | + return str(value).lower() |
| 152 | + return str(value) |
0 commit comments