We add the Starlark application on Go
Contents
What kind of bird?
Starlark (formerly known as Skylark) is a Python-like language originally developed for the Bazel assembly system, which later branched out through interpreters for Go and Rust.
The language favors its use as a configuration tool, however, thanks to a well-written Go interpreter and a detailed description of the specification, it can be used as a programming language embedded in an application – for example, when you want to let the user interact with the application logic object, but do not want to constantly spawn entities under almost the same key due to almost the same parameters.
I could not find sufficiently complete tutorials during my work, so I had the idea to write a small article on this topic. In the article, we will walk you through working with Starlark in Go, from the simplest script launch to adding a new built-in type.
Disclaimer
I’m not a real developer, just i pretend I am writing a pet project in my spare time, so the text may contain incorrect definitions. Please let me know about all jambs, snags and other possible errors, I will correct them.
Starting small
To start work, it is enough to do two things – write the source script and execute it:
# hello.star
print("Hello from Starlark script")
// main.go
package main
import "go.starlark.net/starlark"
func main() {
_, err := starlark.ExecFile(&starlark.Thread{}, "hello.star", nil, nil)
if err != nil {
panic(err)
}
}
ExecFile loads the source file (or source code if the function’s third argument is not nil
), parses and executes the code in the specified thread, after which it returns a dictionary with objects of the global scope of the script – variables, functions. The global scope is frozen, which means that any attempt to change the global definitions will cause a runtime error.
We launch it, we get:
$ go run main.go
Hello from Starlark script
A good start, however, there is little benefit from this – the point is that the script should be considered not as a separate program, but as a module, the functions of which we are going to call as necessary.
Let’s try another way:
# hello.star
def hello():
print("Hello from Starlark script")
Re-running the code on Go and… nothing happens (as expected) because we only declared the function and didn’t call it. So, you need to call it from the main program:
// main.go
package main
import "go.starlark.net/starlark"
func main() {
// выделим поток, в котором будем загружать и выполнять скрипт
thread := &starlark.Thread{}
globals, err := starlark.ExecFile(thread, "hello.star", nil, nil)
if err != nil {
panic(err)
}
_, err = starlark.Call(thread, globals["hello"], nil, nil)
if err != nil {
panic(err)
}
}
Result:
$ go run main.go
Hello from Starlark script
globals
this is the same dictionary with global variables and functions mentioned earlier. Through Call we call our function hello()
by name, getting it from the dictionary. Positional and named arguments can be passed as the third and fourth arguments to the function.
We transmit and receive values
“Out of the box” Starlark has eleven built-in types (and a few more available modules, but not about them now):
-
None, analog
nil
which is used when the lack of meaning is to be expressed -
Boolean, logical
True
orFalse
-
Integer type combines signed and unsigned integers
-
Float, a floating point number
-
String, a string in UTF-8
-
List, list, variable sequence of values
-
Tuple, a tuple like a list, only immutable (but the values contained in the tuple can be changed)
-
Dictionary, a key-value dictionary, only hashable types are supported as keys.
-
Set uses a hash table under the hood, so the requirement for values is the same as for keys in a dictionary; A Go-specific type that requires a special flag to be set to use
-
Function, functions defined in Starlark code
-
Built-in function, a separate type for functions (or methods, more on that later) implemented in Go
On the Go side, all types must implement the Value interface, except for type-specific interfaces like Callable for functions – you’ll need this information when writing your types.
So, let’s try to pass something to our function:
# hello.star
def hello(message):
print(message)
// main.go
package main
import "go.starlark.net/starlark"
func main() {
thread := &starlark.Thread{}
globals, err := starlark.ExecFile(thread, "hello.star", nil, nil)
if err != nil {
panic(err)
}
// здесь готовим позиционные аргументы для вызываемой функции
args := starlark.Tuple{
starlark.String("Hello from Golang"),
}
_, err = starlark.Call(thread, globals["hello"], args, nil)
if err != nil {
panic(err)
}
}
Result:
$ go run main.go
Hello from Golang
In this way, any reasonable number of arguments can be passed to the called function. It is worth noting that an attempt to transfer more or less arguments is a script execution time error – if three arguments are specified in the signature, three are transferred.
Let’s try to get something from our function. Let’s write the addition of numbers as the simplest example:
# hello.star
def sum(x, y):
return x + y
// main.go
package main
import "go.starlark.net/starlark"
func main() {
thread := &starlark.Thread{}
globals, err := starlark.ExecFile(thread, "hello.star", nil, nil)
if err != nil {
panic(err)
}
// здесь готовим позиционные аргументы для вызываемой функции
args := starlark.Tuple{
starlark.MakeInt(42),
starlark.MakeInt(451),
}
result, err := starlark.Call(thread, globals["sum"], args, nil)
if err != nil {
panic(err)
}
print(result.String()) // распечатаем результат
}
Let’s start:
$ go run main.go
493
In variable reslut
is stored some the result of the called function. Now it turned out to be a translation Value
in a line, but in real use you need to cast the interface to the required type. As an example:
Long feature
func toGoValue(starValue starlark.Value) (any, error) {
switch v := starValue.(type) {
case starlark.String:
return string(v), nil
case starlark.Bool:
return bool(v), nil
case starlark.Int: // int, uint both here
if value, ok := v.Int64(); ok {
return value, nil
}
if value, ok := v.Uint64(); ok {
return value, nil
}
return nil, errors.New("unknown starlark Int representation")
case starlark.Float:
return float64(v), nil
case *starlark.List:
slice := []any{}
iter := v.Iterate()
defer iter.Done()
var starValue starlark.Value
for iter.Next(&starValue) {
goValue, err := toGoValue(starValue)
if err != nil {
return nil, err
}
slice = append(slice, goValue)
}
return slice, nil
case *starlark.Dict:
datamap := make(map[string]any, v.Len())
for _, starKey := range v.Keys() {
goKey, ok := starKey.(starlark.String)
if !ok { // datamap key must be a string
return nil, fmt.Errorf("datamap key must be a string, got %v", starKey.String())
}
// since the search is based on a known key,
// it is expected that the value will always be found
starValue, _, _ := v.Get(starKey)
goValue, err := toGoValue(starValue)
if err != nil {
return nil, err
}
datamap[goKey.String()] = goValue
}
return datamap, nil
default:
return nil, fmt.Errorf("%v is not representable as datamap value", starValue.Type())
}
}
A small note: in the above code, it is worth paying attention to the work with the iterator – when it is no longer needed, you need to explicitly call Done()
.
We add a new type
The Starlark interpreter allows you to extend the language by adding new types – it is enough to implement the Value interface.
Let’s imagine that we have a type, let it be a user, a synthetic example:
type User struct {
name string
mail *mail.Address
}
// конструктор пригодится позже
func NewUser(name, address string) (*User, error) {
mail, err := mail.ParseAddress(address)
if err != nil {
return nil, err
}
if len(name) == 0 {
return nil, errors.New("name required")
}
return &User{name: name, mail: mail}, nil
}
func (u *User) Rename(newName string) {
u.name = newName
}
func (u *User) ChangeMail(newMail string) error {
mail, err := mail.ParseAddress(newMail)
if err != nil {
return err
}
u.mail = mail
return nil
}
func (u *User) Name() string {
return u.name
}
func (u *User) Mail() string {
return u.mail.String()
}
and we want to make it available in Starlark. This will require a wrapper type with the appropriate methods:
var _ starlark.Value = &StarlarkUser{}
type StarlarkUser struct {
user *User
}
func (e *StarlarkUser) String() string {
return fmt.Sprintf("name: %v, mail: %v", e.user.Name(), e.user.Mail())
}
func (e *StarlarkUser) Type() string {
return "user"
}
// для упрощения, не будем заморачиваться с реализацией методов ниже
func (e *StarlarkUser) Freeze() {}
func (e *StarlarkUser) Truth() starlark.Bool {
return len(e.user.Name()) > 0 && len(e.user.Mail()) > 0
}
func (e *StarlarkUser) Hash() (uint32, error) {
return 0, errors.New("not hashable")
}
MethodsString()
, Type()
and Truth()
required to use the type in the language’s built-in functions str()
, type()
and bool()
(in addition, the second carries information about the type), Hash()
is used to hash a value for use in a hash map in dictionaries and sets, a Freeze()
it is necessary to freeze the object (as you can see, the guarantee of the immutability of the object after freezing the global scope lies entirely in the implementation of the type).
This type can already be used somehow, let’s try:
# user.star
def user_info(user):
print(type(user)) # напечатает тип
print(user) # напечатает строковое представление объекта
args := starlark.Tuple{
&StarlarkUser{
&User{
name: "John",
mail: &mail.Address{
Name: "John",
Address: "[email protected]",
},
},
},
}
_, err = starlark.Call(thread, globals["user_info"], args, nil)
if err != nil {
panic(err)
}
Result:
user
name: John, mail: "John" <[email protected]>
We have a full-fledged type, however, it is not possible to perform any operations with it – to implement them, you need to support the corresponding interfaces, for example, HasUnary, HasBinary, etc., which we will not do in the framework of this article, otherwise there is already a lot of text, and the creation of built-in functions and methods is still ahead.
We are adding a new function
This is where the constructor will come in handy NewUser()
. Built-in functions are implemented via Builtin:
// тело функции
func newUser(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
var name, mail string
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 2, &name, &mail); err != nil {
return starlark.None, err
}
user, err := NewUser(name, mail)
if err != nil {
return starlark.None, err
}
return &StarlarkUser{user: user}, nil
}
func main() {
thread := &starlark.Thread{}
// собираем наши встраиваемые функции, которые затем передаются в ExecFile()
builtins := starlark.StringDict{
"newUser": starlark.NewBuiltin("newUser", newUser),
}
globals, err := starlark.ExecFile(thread, "user.star", nil, builtins)
if err != nil {
panic(err)
}
_, err = starlark.Call(thread, globals["user_info"], nil, nil)
if err != nil {
panic(err)
}
}
Several new things appeared. First, inline functions must match the type func(thread *starlark.Thread, fn *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error)
.
Secondly, built-in functions, or rather, all conditional objects, are collected in a dictionary and transferred to ExecFile()
to make them available to Starlark code.
Thirdly, you can use UnpackPositionalArgs to unpack the arguments – it will check the number and types of arguments passed.
We are trying to call:
# user.star
def user_info():
user = newUser("John", "[email protected]")
print(type(user))
print(user)
$ go run main.go
user
name: John, mail: <[email protected]>
Working! It is worth noting that not only functions can be passed in this way, but also any other objects whose type implements Value
– for example, you can pass a pre-compiled set of constants that can be useful in embedded code.
We add methods
Adding “methods” to custom objects is implemented this way, via Buitlin
At the same time, a type that has methods must implement the HasAttrs interface.
But first, let’s prepare our methods:
func userName(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 0); err != nil {
return starlark.None, err
}
// получаем ресивер, приводим к нужному типу и работаем уже с ним
name := b.Receiver().(*StarlarkUser).user.Name()
return starlark.String(name), nil
}
func userRename(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
var name string
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &name); err != nil {
return starlark.None, err
}
b.Receiver().(*StarlarkUser).user.Rename(name)
return starlark.None, nil
}
The mechanics of the methods differ from functions in that they are transferred Builtin
the receiver is pulled out, brought to the desired type, after which the necessary manipulations are performed on it.
Looking at the implementation of built-in types in the interpreter library, we collect our methods into a dictionary:
var userMethods = map[string]*starlark.Builtin{
"name": starlark.NewBuiltin("name", userName),
"rename": starlark.NewBuiltin("rename", userRename),
}
And we implement HasAttrs
:
func (e *StarlarkUser) Attr(name string) (starlark.Value, error) {
b, ok := userMethods[name]
if !ok {
return nil, nil // нет такого метода
}
return b.BindReceiver(e), nil
}
func (e *StarlarkUser) AttrNames() []string {
names := make([]string, 0, len(userMethods))
for name := range userMethods {
names = append(names, name)
}
sort.Strings(names)
return names
}
BindReceiver()
creates a new one Builtin
which carries the value passed to which we access the method.
Let’s try:
# user.star
def user_info():
user = newUser("John", "[email protected]")
user.rename("Jim")
print(user.name())
$ go run main.go
Jim
This is how we managed to add methods to our custom type in a not so clever way.
Bonus: Modules, built-in and custom
Starlark has several built-in modules that can be useful, here are some of them:
-
math – a module with mathematical functions and a pair of constants
-
time – functions and types for working with time
There is a special load function for loading modules, but you can’t use it just like that – loading modules is performed by the Load() function in the thread, and by default it is not there, so you need to implement it. Let’s extend our initial flow:
import (
"go.starlark.net/lib/math"
"go.starlark.net/lib/time"
)
thread := &starlark.Thread{
Load: func(thread *starlark.Thread, module string) (starlark.StringDict, error) {
switch module {
case "math.star":
return starlark.StringDict{
"math": math.Module,
}, nil
case "time.star":
return starlark.StringDict{
"time": time.Module,
}, nil
default:
return nil, fmt.Errorf("no such module: %v", module)
}
},
}
The function will be called for each load()
in the code. Let’s try to display the current time:
# modules.star
load("time.star", "time")
print(time.now()) # выведет текущее время
The second (and subsequent) arguments load()
define imported literals, while starting with _
are not imported. In the case of modules implemented on Go, a structure is imported, through the fields of which we refer to functions and constants.
You can write modules on Starlark, and for convenience, use the starlarkstruct extension so that working with our custom module does not differ from working with built-in ones:
builtins := starlark.StringDict{
"newUser": starlark.NewBuiltin("newUser", newUser),
"struct": starlark.NewBuiltin("struct", starlarkstruct.Make),
}
We will support uploading files to the module upload function:
thread := &starlark.Thread{
Load: func(thread *starlark.Thread, module string) (starlark.StringDict, error) {
switch module {
case "math.star":
return starlark.StringDict{
"math": math.Module,
}, nil
case "time.star":
return starlark.StringDict{
"time": time.Module,
}, nil
default: // внешний модуль, загружаем из файла
script, err := os.ReadFile(module)
if err != nil {
return nil, err
}
entries, err := starlark.ExecFile(thread, module, script, builtins)
if err != nil {
return nil, err
}
return entries, nil
}
},
}
Let’s define the module:
# myModule.star
def hello():
print("hello from module")
# создаем структуру с экспортируемыми литералами
myModule = struct(
hello = hello
)
And we will use it in the main code:
load("myModule.star", "myModule")
myModule.hello() # выведет hello from module
Due to the fact that we have defined a structure of the same name with literals in the module, the module is easily imported and used.
Conclusion
In my case, embedding Starlark allowed the app to give users a tool for flexible configuration and adding custom logic to some of the event-driven steps. I will be glad if this material turns out to be useful to you, and Starlark may take a worthy place in your code.