Skip to content

Commit

Permalink
config: Add typed helpers (config.str, config.bool) (#128)
Browse files Browse the repository at this point in the history
* Revert strongly-typed values for `config.get`

Starting with #120, `config.get` would return values of different types
based on the type of the underlying schema field. For example, for
toggle fields, the returned value would be a boolean instead of a string.

Unfortunatley, there are multiple edge cases where this doesn't work.
For example, if a schema field gets deleted but an installation still
has a value for it, then we no longer know what type to return.

On top of that, for generated fields, we don't have an easy way to tell
what type they are. Generated fields aren't currently implemented in
Pixlet, but they are available to Tidbyt-internal apps and will be in
Pixlet soon.

* config: Add typed config helpers

You can now use `config.bool` to get a boolean representation of a
config value.

* Fix indentation in docs
  • Loading branch information
rohansingh authored Jan 21, 2022
1 parent a555891 commit 7bfb818
Show file tree
Hide file tree
Showing 11 changed files with 235 additions and 191 deletions.
11 changes: 10 additions & 1 deletion docs/authoring_apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,21 @@ The main thing to keep in mind when working with `cache` is that it's scoped per
Config is used to configure an app. It's a key/value pair of config values and is passed into `main`. It's exposed through query parameters in the url of `pixlet serve` or through command line args through `pixlet render`. When publishing apps to the Tidbyt [Community](https://github.com/tidbyt/community) repo, the Tidbyt backend will populate the config values from values provided in the mobile app.

The important thing to remember here is that your app should always be able to render even if a config value isn't provided. Providing default values for every config value or checking it for `None` will ensure the app behaves as expected even if config was not provided. For example, the following ensures there will always be a value for `foo`:
```

```starlark
DEFAULT_FOO = "bar"

def main(config):
foo = config.get("foo", DEFAULT_FOO)
```

The `config` object also has helpers to convert config values into specific types:

```starlark
config.str("foo") # returns a string, or None if not found
config.bool("foo") # returns a boolean (True or False), or None if not found
```


## Fail
Using a `fail()` inside of your app should be used incredibly sparingly. It kills the entire execution in an unrecoverable fashion. If your app depends on an external API, it cannot cache the response, and has no means of recovering with a failed response - then a `fail()` is appropriate. Otherwise, using a `print()` and handling the error appropriately is a better approach.
22 changes: 16 additions & 6 deletions docs/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ The way it works is quite simple. We call `get_schema` and supply the fields to
> Note: Schema is for configuring apps submitted to [Community Apps](https://github.com/tidbyt/community). We're working on adding tighter integration into Pixlet so that pixlet commands make better use of schema.
## Quick Start
Let's add a toggle to our hello world example. Here it is before we add schema:
Let's add a toggle and a text input to our hello world example. Here it is before we add schema:
```starlark
load("render.star", "render")

Expand All @@ -22,11 +22,15 @@ This is a quick start, so let's start with the code and we'll break it down:
load("render.star", "render")
load("schema.star", "schema")

DEFAULT_WHO = "World"

def main(config):
if config.get("small"):
msg = render.Text("Hello, World!", font = "CG-pixel-3x5-mono")
message = "Hello, %s!" % config.str("who", DEFAULT_WHO)

if config.bool("small"):
msg = render.Text(message, font = "CG-pixel-3x5-mono")
else:
msg = render.Text("Hello, World!")
msg = render.Text(message)

return render.Root(
child = msg,
Expand All @@ -36,6 +40,12 @@ def get_schema():
return schema.Schema(
version = "1",
fields = [
schema.Text(
id = "who",
name = "Who?",
desc = "Who to say hello to.",
icon = "user",
),
schema.Toggle(
id = "small",
name = "Display small text",
Expand All @@ -49,9 +59,9 @@ def get_schema():

The big change here is the `get_schema` method. This is the method we will call before rendering your app when running inside of our [Community Apps](https://github.com/tidbyt/community) repo. A quick note - we don't call this method using Pixlet at this time.

The `get_schema` method returns a `schema.Schema` object that contains _fields_. See below for a complete breakdown of what config options are available. In our example, we'll use a toggle.
The `get_schema` method returns a `schema.Schema` object that contains _fields_. See below for a complete breakdown of what config options are available. In our example, we use a toggle and a text input.

Next up should be more familiar. We're now passing `config` into `main()`. This is the same for current pixlet scripts that take `config` today. In [Community Apps](https://github.com/tidbyt/community), we will populate the config hashmap with values configured from the mobile app. More specifically, `config` is a key/value pair where the key is the `id` of the schema field and the value is determined by the user in the mobile app.
Next up should be more familiar. We're now passing `config` into `main()`. This is the same for current pixlet scripts that take `config` today. In [Community Apps](https://github.com/tidbyt/community), we will populate the config hashmap with values configured from the mobile app.

That's about it!

Expand Down
14 changes: 11 additions & 3 deletions examples/schema_hello_world.star
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ load("render.star", "render")
load("schema.star", "schema")

def main(config):
if config.get("small"):
msg = render.Text("Hello, World!", font = "CG-pixel-3x5-mono")
message = "Hello, %s!" % config.get("who", "World")

if config.bool("small"):
msg = render.Text(message, font = "CG-pixel-3x5-mono")
else:
msg = render.Text("Hello, World!")
msg = render.Text(message)

return render.Root(
child = msg,
Expand All @@ -15,6 +17,12 @@ def get_schema():
return schema.Schema(
version = "1",
fields = [
schema.Text(
id = "who",
name = "Who?",
desc = "Who to say hello to.",
icon = "user",
),
schema.Toggle(
id = "small",
name = "Display small text",
Expand Down
94 changes: 94 additions & 0 deletions runtime/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package runtime

import (
"fmt"
"strconv"

"github.com/mitchellh/hashstructure/v2"
"go.starlark.net/starlark"
)

type AppletConfig map[string]string

func (a AppletConfig) AttrNames() []string {
return []string{
"get",
"str",
"bool",
}
}

func (a AppletConfig) Attr(name string) (starlark.Value, error) {
switch name {

case "get", "str":
return starlark.NewBuiltin("str", a.getString), nil

case "bool":
return starlark.NewBuiltin("bool", a.getBoolean), nil

default:
return nil, nil
}
}

func (a AppletConfig) Get(key starlark.Value) (starlark.Value, bool, error) {
switch v := key.(type) {
case starlark.String:
val, found := a[v.GoString()]
return starlark.String(val), found, nil
default:
return nil, false, nil
}
}

func (a AppletConfig) String() string { return "AppletConfig(...)" }
func (a AppletConfig) Type() string { return "AppletConfig" }
func (a AppletConfig) Freeze() {}
func (a AppletConfig) Truth() starlark.Bool { return true }

func (a AppletConfig) Hash() (uint32, error) {
sum, err := hashstructure.Hash(a, hashstructure.FormatV2, nil)
return uint32(sum), err
}

func (a AppletConfig) getString(thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
var key starlark.String
var def starlark.Value
def = starlark.None

if err := starlark.UnpackPositionalArgs(
"str", args, kwargs, 1,
&key, &def,
); err != nil {
return nil, fmt.Errorf("unpacking arguments for config.str: %v", err)
}

val, ok := a[key.GoString()]
if !ok {
return def, nil
} else {
return starlark.String(val), nil
}
}

func (a AppletConfig) getBoolean(thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
var key starlark.String
var def starlark.Value
def = starlark.None

if err := starlark.UnpackPositionalArgs(
"bool", args, kwargs, 1,
&key, &def,
); err != nil {
return nil, fmt.Errorf("unpacking arguments for config.bool: %v", err)
}

val, ok := a[key.GoString()]
if !ok {
return def, nil
} else {
b, _ := strconv.ParseBool(val)
return starlark.Bool(b), nil
}
}
83 changes: 27 additions & 56 deletions runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,7 @@ package runtime
import (
"context"
"crypto/md5"
"encoding/json"
"fmt"
"log"
"strconv"
"strings"

"github.com/pkg/errors"
starlibbase64 "github.com/qri-io/starlib/encoding/base64"
Expand All @@ -20,6 +16,7 @@ import (
"go.starlark.net/resolve"
"go.starlark.net/starlark"
"go.starlark.net/starlarkstruct"
"go.starlark.net/starlarktest"

"tidbyt.dev/pixlet/render"
"tidbyt.dev/pixlet/runtime/modules/sunrise"
Expand All @@ -43,16 +40,15 @@ func init() {
}

type Applet struct {
Filename string
Id string
Globals starlark.StringDict
src []byte
loader ModuleLoader
predeclared starlark.StringDict
main *starlark.Function

schema *schema.Schema
schemaJSON []byte
Filename string
Id string
Globals starlark.StringDict
src []byte
loader ModuleLoader
predeclared starlark.StringDict
main *starlark.Function
schema string
schemaHandler map[string]schema.SchemaHandler
}

func (a *Applet) thread(initializers ...ThreadInitializer) *starlark.Thread {
Expand Down Expand Up @@ -108,24 +104,24 @@ func (a *Applet) Load(filename string, src []byte, loader ModuleLoader) (err err
}
a.main = main

var s string
var handlers map[string]schema.SchemaHandler
schemaFun, _ := a.Globals[schema.SchemaFunctionName].(*starlark.Function)
if schemaFun != nil {
schemaVal, err := a.Call(schemaFun, nil)
if err != nil {
return errors.Wrap(err, "calling schema function")
}

a.schema, err = schema.FromStarlark(schemaVal, a.Globals)
s, handlers, err = schema.EncodeSchema(schemaVal, a.Globals)
if err != nil {
return errors.Wrap(err, "parsing Starlark schema")
}

a.schemaJSON, err = json.Marshal(a.schema)
if err != nil {
return errors.Wrap(err, "serializing schema to JSON")
return errors.Wrap(err, "encode schema")
}
}

a.schema = s
a.schemaHandler = handlers

return nil
}

Expand All @@ -134,29 +130,7 @@ func (a *Applet) Load(filename string, src []byte, loader ModuleLoader) (err err
func (a *Applet) Run(config map[string]string, initializers ...ThreadInitializer) (roots []render.Root, err error) {
var args starlark.Tuple
if a.main.NumParams() > 0 {
starlarkConfig := starlark.NewDict(len(config))
for k, v := range config {
var starlarkVal starlark.Value
starlarkVal = starlark.String(v)

if strings.HasPrefix(k, "$") {
// this a special field like "$tz". no need to check the schema
} else if a.schema != nil {
// app has a schema, so we can provide strongly typed config values
field := a.schema.Field(k)

if field == nil {
// we have a value, but it's not part of the app's schema.
// allow it for now, but we will deprecate it in the future.
log.Printf("received config value for '%s', but it is not in the schema for %s", k, a.Filename)
} else if field.Type == "onoff" {
b, _ := strconv.ParseBool(v)
starlarkVal = starlark.Bool(b)
}
}

starlarkConfig.SetKey(starlark.String(k), starlarkVal)
}
starlarkConfig := AppletConfig(config)
args = starlark.Tuple{starlarkConfig}
}

Expand Down Expand Up @@ -195,7 +169,7 @@ func (a *Applet) Run(config map[string]string, initializers ...ThreadInitializer
// CallSchemaHandler calls the schema handler for a field, passing it a single
// string parameter and returning a single string value.
func (app *Applet) CallSchemaHandler(ctx context.Context, fieldId, parameter string) (result string, err error) {
handler, found := app.schema.Handlers[fieldId]
handler, found := app.schemaHandler[fieldId]
if !found {
return "", fmt.Errorf("no handler exported for field id %s", fieldId)
}
Expand All @@ -218,17 +192,11 @@ func (app *Applet) CallSchemaHandler(ctx context.Context, fieldId, parameter str
return options, nil

case schema.ReturnSchema:
sch, err := schema.FromStarlark(resultVal, app.Globals)
schema, _, err := schema.EncodeSchema(resultVal, app.Globals)
if err != nil {
return "", errors.Wrap(err, "parsing Starlark schema")
}

s, err := json.Marshal(sch)
if err != nil {
return "", errors.Wrap(err, "serializing schema to JSON")
return "", err
}

return string(s), nil
return schema, nil

case schema.ReturnString:
str, ok := starlark.AsString(resultVal)
Expand All @@ -244,9 +212,9 @@ func (app *Applet) CallSchemaHandler(ctx context.Context, fieldId, parameter str
return "", fmt.Errorf("a very unexpected error happened for field \"%s\"", fieldId)
}

// GetSchema returns the serialized schema for the applet.
// GetSchema returns the config for the applet.
func (app *Applet) GetSchema() string {
return string(app.schemaJSON)
return app.schema
}

func attachContext(ctx context.Context) ThreadInitializer {
Expand Down Expand Up @@ -333,6 +301,9 @@ func (a *Applet) loadModule(thread *starlark.Thread, module string) (starlark.St
starlibtime.Module.Name: starlibtime.Module,
}, nil

case "assert.star":
return starlarktest.LoadAssertModule()

default:
return nil, fmt.Errorf("invalid module: %s", module)
}
Expand Down
Loading

0 comments on commit 7bfb818

Please sign in to comment.