Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
10 changes: 5 additions & 5 deletions src/parse/asp/builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,8 @@ func pkg(s *scope, args []pyObject) pyObject {
s.Assert(configVal != nil, "error calling package(): %s is not a known config value", k)

// Merge in the existing config for dictionaries
if overrides, ok := v.(pyDict); ok {
if pluginConfig, ok := configVal.(pyDict); ok {
if overrides, ok := asDict(v); ok {
if pluginConfig, ok := asDict(configVal); ok {
newPluginConfig := pluginConfig.Copy()
for pluginKey, override := range overrides {
pluginKey = strings.ToUpper(pluginKey)
Expand Down Expand Up @@ -515,11 +515,11 @@ func isType(obj pyObject, name string) bool {
return name == "str"
case *pyRange:
return name == "range"
case pyList:
case pyList, pyFrozenList:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

this is not strictly related but a bit of a drive-by while I was here; isinstance should treat a frozen list as being a list

return name == "list"
case pyDict:
case pyDict, pyFrozenDict:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I ran a quick check against my repo and noticed differences in output, I suspect a change in behaviour; top-level variables in a sub included .build_defs file are frozen once it has loaded, so isinstance(x, dict) and isinstance(x, list) now return True where they used to return False.

This changed output (ran a plz query print comparison with plz-before and plz-after), it's probably worth noting the slight change in behaviour (which probably makes more sense).

08:47:24.702 WARNING: BUILD: describe()=plain value, isinstance(_DEFAULT_TAGS, list)=False
vs
08:47:32.650 WARNING: BUILD: describe()=per-env dict, isinstance(_DEFAULT_TAGS, list)=True

on

$ cat /tmp/isinstance-test0/defs/defs.build_defs 
_UNSET = {"unset": True}  # sentinel default
_DEFAULT_TAGS = ["base"]

def describe(setting = _UNSET):
    if isinstance(setting, dict):
        return "per-env dict"
    return "plain value"

def tags_is_list():
    return isinstance(_DEFAULT_TAGS, list)

would it make sense to check val != _UNSET before the isinstance call?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes exactly; there are some fixes in #3657 which I'll land in the same release as this and I've landed a fix internally to deal with that _UNSET thing so we should be fine before and after the upgrade.

Thanks for looking into it!

return name == "dict"
case *pyConfig:
case *pyConfig, *pyFrozenConfig:
return name == "config"
case *pyFunc:
return name == "callable"
Expand Down
56 changes: 43 additions & 13 deletions src/parse/asp/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ import (
"github.com/thought-machine/please/src/core"
)

// Used to key a cache to memoise building plugin configs.
type pluginConfigKey struct {
PluginState *core.BuildState // State for the plugin itself
State *core.BuildState // State for the receiving package (or its parent if it has no overrides)
InheritOnly bool // True if State is the parent of a state with no overrides for this plugin
}

// valueToPyObject converts a field value to a pyObject
func valueToPyObject(value reflect.Value) pyObject {
switch value.Kind() {
Expand Down Expand Up @@ -134,17 +141,16 @@ func getConfigKey(aspKey, configKey string) string {
return strings.ToLower(configKey)
}

// pluginConfig loads the plugin's config into a pyDict. It will load con
func pluginConfig(pluginState *core.BuildState, pkgState *core.BuildState) pyDict {
pluginName := strings.ToLower(pluginState.RepoConfig.PluginDefinition.Name)
// pluginConfig loads the plugin's config into a pyDict.
func pluginConfig(pluginState *core.BuildState, pkgState *core.BuildState, pluginName string) pyDict {
var extraVals map[string][]string
var ret pyDict
if pkgState.ParentState == nil {
extraVals = getExtraVals(pkgState.RepoConfig, pluginName)
ret = pyDict{}
} else {
extraVals = getExtraVals(pkgState.RepoConfig, pluginName)
ret = pluginConfig(pluginState, pkgState.ParentState)
ret = pluginConfig(pluginState, pkgState.ParentState, pluginName)
}

for key, definition := range pluginState.RepoConfig.PluginConfig {
Expand Down Expand Up @@ -193,6 +199,35 @@ func pluginConfig(pluginState *core.BuildState, pkgState *core.BuildState) pyDic
return ret
}

// loadCachedPluginConfig loads the config for a particular plugin, caching it to minimise rebuilds.
func (i *interpreter) loadCachedPluginConfig(pluginState *core.BuildState, pkgState *core.BuildState, name string) pyFrozenDict {
key := pluginConfigKey{
PluginState: pluginState,
State: pkgState,
}
if len(getExtraVals(pkgState.RepoConfig, name)) == 0 {
// This package doesn't have any overrides, so we can reuse the parent state's config.
key.State = pkgState.ParentState
key.InheritOnly = true
}
i.pluginConfigLock.RLock()
if cfg, present := i.pluginConfigs[key]; present {
i.pluginConfigLock.RUnlock()
return cfg
}
i.pluginConfigLock.RUnlock()
i.pluginConfigLock.Lock()
defer i.pluginConfigLock.Unlock()
// Check again since we just released the lock, we may have raced.
if cfg, present := i.pluginConfigs[key]; present {
return cfg
}
log.Debugf("Loading configuration for plugin %s", name)
cfg := pluginConfig(pluginState, pkgState, name).Freeze().(pyFrozenDict)
i.pluginConfigs[key] = cfg
return cfg
}

func (i *interpreter) loadPluginConfig(s *scope, pluginState *core.BuildState) {
if pluginState.RepoConfig == nil {
return
Expand All @@ -204,19 +239,14 @@ func (i *interpreter) loadPluginConfig(s *scope, pluginState *core.BuildState) {
return
}

log.Debugf("Loading configuration for plugin %s", pluginName)

if s.config.overlay == nil {
s.config.overlay = pyDict{}
}

key := strings.ToUpper(pluginName)
if _, ok := s.config.overlay[key]; ok {
return
}

cfg := pluginConfig(pluginState, s.state)
s.config.overlay[key] = cfg
if s.config.overlay == nil {
s.config.overlay = pyDict{}
}
s.config.overlay[key] = i.loadCachedPluginConfig(pluginState, s.state, strings.ToLower(pluginName))
}

func toPyObject(key, val, toType string, optional bool) pyObject {
Expand Down
138 changes: 138 additions & 0 deletions src/parse/asp/config_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
package asp

import (
"context"
"reflect"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/thought-machine/please/rules"
"github.com/thought-machine/please/src/core"
)

func TestResolvePluginTargetValues(t *testing.T) {
Expand All @@ -19,3 +25,135 @@ func TestResolvePluginTargetValues(t *testing.T) {
values = resolvePluginValue([]string{"//path/to:target"}, "")
assert.Equal(t, []string{"/////path/to:target"}, values)
}

// newPluginConfigTest returns an interpreter with builtins loaded, the host state, and the state of a
// plugin named "foo" with two config fields: BAR (inherited from the host) and BAZ (not inherited).
func newPluginConfigTest(t *testing.T) (*interpreter, *core.BuildState, *core.BuildState) {
t.Helper()
state := core.NewDefaultBuildState()
parser := NewParser(state, nil)
src, err := rules.ReadAsset("builtins.build_defs")
require.NoError(t, err)
parser.MustLoadBuiltins("builtins.build_defs", src)

pluginState := state.ForSubrepo("foo", false)
pluginState.RepoConfig = &core.Configuration{}
pluginState.RepoConfig.PluginDefinition.Name = "foo"
pluginState.RepoConfig.PluginConfig = map[string]*core.PluginConfigDefinition{
"bar": {DefaultValue: []string{"default_bar"}, Inherit: true},
"baz": {DefaultValue: []string{"default_baz"}, Inherit: false},
}
return parser.interpreter, state, pluginState
}

// subrepoState returns a subrepo state under the given parent, with the given overrides for the foo plugin.
// With no overrides it looks like a subrepo with no .plzconfig (e.g. a go_repo module).
func subrepoState(parent *core.BuildState, name string, overrides map[string][]string) *core.BuildState {
s := parent.ForSubrepo(name, false)
s.RepoConfig = &core.Configuration{} // as Initialise does
if overrides != nil {
s.RepoConfig.Plugin = map[string]*core.Plugin{"foo": {ExtraValues: overrides}}
}
return s
}

// runWithPluginConfig interprets some code in a fresh package scope with the foo plugin's config loaded,
// the same way interpretAll sets one up.
func runWithPluginConfig(t *testing.T, i *interpreter, pluginState *core.BuildState, code string) (*scope, error) {
t.Helper()
s := i.scope.NewPackagedScope(context.Background(), core.NewPackage("test/package"), 1)
s.config = i.getConfig(s.state).Copy()
s.Set("CONFIG", s.config)
i.loadPluginConfig(s, pluginState)
stmts, err := i.parser.ParseData([]byte(code), "BUILD")
require.NoError(t, err)
_, err = i.interpretStatements(s, i.parser.optimise(stmts))
return s, err
}

// fooConfig returns the foo plugin's config as seen by the given scope.
func fooConfig(t *testing.T, s *scope) pyDict {
t.Helper()
d, ok := asDict(s.config.MustGet("FOO"))
require.True(t, ok, "CONFIG.FOO should be a dict")
return d
}

func TestPluginConfigCacheSharing(t *testing.T) {
i, state, pluginState := newPluginConfigTest(t)
state.RepoConfig.Plugin = map[string]*core.Plugin{
"foo": {ExtraValues: map[string][]string{"bar": {"host_bar"}, "baz": {"host_baz"}}},
}
a := subrepoState(state, "a", nil)
b := subrepoState(state, "b", nil)
c := subrepoState(state, "c", map[string][]string{"baz": {"c_baz"}})

host := i.loadCachedPluginConfig(pluginState, state, "foo")
cfgA := i.loadCachedPluginConfig(pluginState, a, "foo")
cfgB := i.loadCachedPluginConfig(pluginState, b, "foo")
cfgC := i.loadCachedPluginConfig(pluginState, c, "foo")

assert.Equal(t, pyString("host_bar"), host.pyDict["BAR"])
assert.Equal(t, pyString("host_baz"), host.pyDict["BAZ"])
// Subrepos without overrides inherit BAR, but BAZ isn't inherited so gets reset to its default.
assert.Equal(t, pyString("host_bar"), cfgA.pyDict["BAR"])
assert.Equal(t, pyString("default_baz"), cfgA.pyDict["BAZ"])
// A subrepo with its own override gets that.
assert.Equal(t, pyString("host_bar"), cfgC.pyDict["BAR"])
assert.Equal(t, pyString("c_baz"), cfgC.pyDict["BAZ"])

// Override-free siblings share a single dict; the host and the overriding subrepo have their own.
ptr := func(d pyFrozenDict) uintptr { return reflect.ValueOf(d.pyDict).Pointer() }
assert.Equal(t, ptr(cfgA), ptr(cfgB))
assert.NotEqual(t, ptr(cfgA), ptr(host))
assert.NotEqual(t, ptr(cfgA), ptr(cfgC))
// And repeated lookups for the same state come from the cache.
assert.Equal(t, ptr(host), ptr(i.loadCachedPluginConfig(pluginState, state, "foo")))
assert.Equal(t, ptr(cfgC), ptr(i.loadCachedPluginConfig(pluginState, c, "foo")))
}

func TestPluginConfigPackageOverride(t *testing.T) {
i, _, pluginState := newPluginConfigTest(t)
s, err := runWithPluginConfig(t, i, pluginState, `package(foo = {"bar": "overridden"})`)
require.NoError(t, err)
assert.Equal(t, pyString("overridden"), fooConfig(t, s)["BAR"])
assert.Equal(t, pyString("default_baz"), fooConfig(t, s)["BAZ"])

// The override must not leak into the cached config that other packages get.
s, err = runWithPluginConfig(t, i, pluginState, ``)
require.NoError(t, err)
assert.Equal(t, pyString("default_bar"), fooConfig(t, s)["BAR"])
}

func TestPluginConfigPackageOverrideUnknownKey(t *testing.T) {
i, _, pluginState := newPluginConfigTest(t)
_, err := runWithPluginConfig(t, i, pluginState, `package(foo = {"wibble": "x"})`)
require.Error(t, err)
assert.Contains(t, err.Error(), "FOO.WIBBLE is not a known config value")
}

func TestPluginConfigIsImmutable(t *testing.T) {
i, _, pluginState := newPluginConfigTest(t)
_, err := runWithPluginConfig(t, i, pluginState, "foo = CONFIG.FOO\nfoo[\"BAR\"] = \"mutated\"")
require.Error(t, err)
assert.Contains(t, err.Error(), "dict is immutable")

// Nothing else sees the attempted change.
s, err := runWithPluginConfig(t, i, pluginState, ``)
require.NoError(t, err)
assert.Equal(t, pyString("default_bar"), fooConfig(t, s)["BAR"])
}

func TestPluginConfigBehavesAsDict(t *testing.T) {
i, _, pluginState := newPluginConfigTest(t)
s, err := runWithPluginConfig(t, i, pluginState, `
is_dict = isinstance(CONFIG.FOO, dict)
merged = {"QUX": "qux"} | CONFIG.FOO
`)
require.NoError(t, err)
assert.Equal(t, True, s.Lookup("is_dict"))
merged, ok := asDict(s.Lookup("merged"))
require.True(t, ok)
assert.Equal(t, pyString("qux"), merged["QUX"])
assert.Equal(t, pyString("default_bar"), merged["BAR"])
}
16 changes: 10 additions & 6 deletions src/parse/asp/interpreter.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ type interpreter struct {

regexCache *cmap.Map[string, *regexp.Regexp]

pluginConfigs map[pluginConfigKey]pyFrozenDict
pluginConfigLock sync.RWMutex

// packageMetadata store scope level metadata for each package.
packageMetadata *cmap.Map[core.BuildLabel, scopeMetadata]

Expand All @@ -52,12 +55,13 @@ func newInterpreter(state *core.BuildState, p *Parser, callbacks Callbacks) *int
s.metadata = &noopScopeMetadata{}

i := &interpreter{
scope: s,
parser: p,
configs: map[*core.BuildState]*pyConfig{},
limiter: make(semaphore, state.Config.Parse.NumThreads),
regexCache: cmap.New[string, *regexp.Regexp](cmap.SmallShardCount, cmap.XXHash),
callbacks: callbacks,
scope: s,
parser: p,
configs: map[*core.BuildState]*pyConfig{},
limiter: make(semaphore, state.Config.Parse.NumThreads),
regexCache: cmap.New[string, *regexp.Regexp](cmap.SmallShardCount, cmap.XXHash),
pluginConfigs: map[pluginConfigKey]pyFrozenDict{},
callbacks: callbacks,
}
// If we're creating an interpreter for a subrepo, we should share the subinclude cache.
if p.interpreter != nil {
Expand Down
2 changes: 1 addition & 1 deletion src/parse/asp/objects.go
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,7 @@ func (d pyDict) Operator(operator Operator, operand pyObject) pyObject {
}
panic("unknown dict key: " + s.String())
case Union:
d2, ok := operand.(pyDict)
d2, ok := asDict(operand)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

another drive-by about using a union operator of a dict and a frozen dict

if !ok {
panic("Operator to | must be another dict, not " + operand.Type())
}
Expand Down
1 change: 1 addition & 0 deletions src/please.go
Original file line number Diff line number Diff line change
Expand Up @@ -1638,6 +1638,7 @@ func execute(command string) int {
}
defer f.Close()
defer pprof.WriteHeapProfile(f)
defer runtime.GC()

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

and here; we only get up-to-date stats for the heap profile once a GC cycle has completed, without this that might have been ages ago

}
if opts.MutexProfile != "" {
runtime.SetMutexProfileFraction(1)
Expand Down
Loading