a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
153 lines
3.8 KiB
Go
153 lines
3.8 KiB
Go
package contracts
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// pyFieldRe captures a Python class attribute with a type annotation.
|
|
// Pydantic V1/V2 models and plain typed dataclasses both use this
|
|
// form. Capture groups:
|
|
//
|
|
// 1: attribute name
|
|
// 2: type expression (up to the optional `=` or end of line)
|
|
// 3: default value expression (after `=`) — empty when no default
|
|
var pyFieldRe = regexp.MustCompile(`^\s*([A-Za-z_][\w]*)\s*:\s*([^=#\n]+?)(?:\s*=\s*(.+?))?\s*(?:#\s*(.*))?$`)
|
|
|
|
// pyFieldAliasRe pulls alias="wire" out of a Pydantic Field(...) call,
|
|
// which is how users rename a Python-legal attribute to a JSON-valid
|
|
// wire name.
|
|
var pyFieldAliasRe = regexp.MustCompile(`alias\s*=\s*['"]([^'"]+)['"]`)
|
|
|
|
// extractPythonShape reads the body of a class definition and returns
|
|
// its typed attributes. Attributes without a type annotation are
|
|
// skipped because we can't record a useful type for them. Methods
|
|
// are skipped by requiring `:` before any `(`.
|
|
func extractPythonShape(src []byte, startLine, endLine int) *Shape {
|
|
body := sliceBody(src, startLine, endLine)
|
|
if body == "" {
|
|
return nil
|
|
}
|
|
lines := strings.Split(body, "\n")
|
|
if len(lines) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Determine the indent of the class body so we can stop scanning
|
|
// at the next declaration at or below the class indent. Python
|
|
// classes have `class Foo(Bar):` on the first line and the body
|
|
// on subsequent indented lines.
|
|
classIndent := leadingWhitespace(lines[0])
|
|
shape := &Shape{Kind: "class"}
|
|
|
|
for i, raw := range lines {
|
|
if i == 0 {
|
|
continue // class header
|
|
}
|
|
if strings.TrimSpace(raw) == "" {
|
|
continue
|
|
}
|
|
indent := leadingWhitespace(raw)
|
|
if len(indent) <= len(classIndent) {
|
|
// De-dent — end of class body.
|
|
break
|
|
}
|
|
line := strings.TrimSpace(raw)
|
|
if strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
// Skip methods: def / async def.
|
|
if strings.HasPrefix(line, "def ") || strings.HasPrefix(line, "async def ") {
|
|
continue
|
|
}
|
|
// Skip decorator lines at field scope.
|
|
if strings.HasPrefix(line, "@") {
|
|
continue
|
|
}
|
|
// Require a `:` to appear before any `(`.
|
|
colonIdx := strings.Index(line, ":")
|
|
if colonIdx < 0 {
|
|
continue
|
|
}
|
|
parenIdx := strings.Index(line, "(")
|
|
if parenIdx >= 0 && parenIdx < colonIdx {
|
|
continue
|
|
}
|
|
m := pyFieldRe.FindStringSubmatch(line)
|
|
if m == nil {
|
|
continue
|
|
}
|
|
name := m[1]
|
|
typeExpr := strings.TrimSpace(m[2])
|
|
defaultExpr := ""
|
|
comment := ""
|
|
if len(m) > 3 {
|
|
defaultExpr = strings.TrimSpace(m[3])
|
|
}
|
|
if len(m) > 4 {
|
|
comment = truncateComment(m[4])
|
|
}
|
|
if typeExpr == "" {
|
|
continue
|
|
}
|
|
alias := ""
|
|
if defaultExpr != "" {
|
|
if am := pyFieldAliasRe.FindStringSubmatch(defaultExpr); len(am) > 1 {
|
|
alias = am[1]
|
|
}
|
|
}
|
|
required := defaultExpr == "" && !pyTypeIsOptional(typeExpr)
|
|
repeated := pyTypeIsRepeated(typeExpr)
|
|
wireName := name
|
|
if alias != "" {
|
|
wireName = alias
|
|
}
|
|
shape.Fields = append(shape.Fields, ShapeField{
|
|
Name: wireName,
|
|
Type: typeExpr,
|
|
JSONTag: alias,
|
|
Required: required,
|
|
Repeated: repeated,
|
|
Comment: comment,
|
|
})
|
|
}
|
|
if len(shape.Fields) == 0 {
|
|
return nil
|
|
}
|
|
return shape
|
|
}
|
|
|
|
func pyTypeIsOptional(t string) bool {
|
|
t = strings.TrimSpace(t)
|
|
if strings.HasPrefix(t, "Optional[") {
|
|
return true
|
|
}
|
|
// `str | None` / `None | str` — Python 3.10+ union syntax.
|
|
for _, p := range strings.Split(t, "|") {
|
|
if strings.TrimSpace(p) == "None" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func pyTypeIsRepeated(t string) bool {
|
|
t = strings.TrimSpace(t)
|
|
// list[Foo], List[Foo], tuple[Foo, ...], Sequence[Foo]
|
|
for _, pfx := range []string{"list[", "List[", "tuple[", "Tuple[", "Sequence[", "Iterable[", "set[", "Set[", "FrozenSet["} {
|
|
if strings.HasPrefix(t, pfx) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func leadingWhitespace(s string) string {
|
|
for i, r := range s {
|
|
if r != ' ' && r != '\t' {
|
|
return s[:i]
|
|
}
|
|
}
|
|
return s
|
|
}
|