Skip to content

Commit

Permalink
Add option to run dependencies asynchronously (#106)
Browse files Browse the repository at this point in the history
* Add option to run dependencies asynchronously

* Document runDeps task attribute

* Do not cancel other async deps on error

* Add log prefix to all scripts

The prefixes are padded with spaces when the task has dependencies

* Fix bug when an attribute is on the last line

* Fix lint issues

* Add tests for prefix logger

* Add deps behaviour to task display

* Add interactive attribute to disable log prefixing
  • Loading branch information
stephenafamo authored Dec 4, 2023
1 parent e7cbab8 commit 01aa6e0
Show file tree
Hide file tree
Showing 13 changed files with 479 additions and 43 deletions.
7 changes: 6 additions & 1 deletion doc/content/task-syntax/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description:
linkTitle: "Task Syntax"
---

## The anatomy of an `xc` task.
## The anatomy of an `xc` task

### Structure

Expand All @@ -14,18 +14,23 @@ linkTitle: "Task Syntax"
- [Scripts](/task-syntax/scripts/)
- [Requires](/task-syntax/requires/)
- [Run](/task-syntax/run/)
- [RunDeps](/task-syntax/run-deps/)
- [Directory](/task-syntax/directory/)
- [Environment Variables](/task-syntax/environment-variables/)
- [Inputs](/task-syntax/inputs/)
- [Interactive](/task-syntax/interactive/)

### Example

````md
## Tasks

### deploy

Requires: test
Directory: ./deployment
Env: ENVIRONMENT=STAGING

```
sh deploy.sh
```
Expand Down
17 changes: 17 additions & 0 deletions doc/content/task-syntax/interactive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
title: "Interactive"
description:
linkTitle: "Interactive"
menu: { main: { parent: "task-syntax", weight: 12 } }
---

## Interactive attribute

By default, the logs of a task are prefixed with the task name. This does not work well for interactive tasks which usually require complete control over the terminal.
If you want to run a task interactively, you can set the `interactive` attribute to `true`.

```markdown
### configure

interactive: true
```
40 changes: 40 additions & 0 deletions doc/content/task-syntax/run-deps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
title: "Run Dependencies"
description:
linkTitle: "RunDeps"
menu: { main: { parent: "task-syntax", weight: 11 } }
---

## RunDeps attribute

By default, the dependencies of a task are run sequentially, in the order they are listed.
However we may prefer for all the dependencies of a task to be run in paralled.

The solution would be to set the `runDeps` attribute to `async` (defaults to `sync`).

```markdown
### build-all

requires: build-js, build-css
runDeps: async
```

This will result in both `build-js` and `build-css` being run in parallel.

The default is `sync`, which can be omitted or specified.

```markdown
### build-all

requires: build-js, build-css
runDeps: sync
```

is the same as

```markdown
### build-all

requires: build-js, build-css
runDeps: sync
```
35 changes: 35 additions & 0 deletions models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ type Task struct {
Inputs []string
ParsingError string
RequiredBehaviour RequiredBehaviour
DepsBehaviour DepsBehaviour
Interactive bool
}

// Display writes a Task as Markdown.
Expand All @@ -28,6 +30,7 @@ func (t Task) Display(w io.Writer) {
}
if len(t.DependsOn) > 0 {
fmt.Fprintln(w, "Requires:", strings.Join(t.DependsOn, ", "))
fmt.Fprintln(w, "RunDeps:", t.DepsBehaviour)
fmt.Fprintln(w)
}
if t.Dir != "" {
Expand All @@ -43,6 +46,9 @@ func (t Task) Display(w io.Writer) {
fmt.Fprintln(w)
}
fmt.Fprintln(w, "Run:", t.RequiredBehaviour)
if t.Interactive {
fmt.Fprintln(w, "Interactive: true")
}
fmt.Fprintln(w)
if len(t.Script) > 0 {
fmt.Fprintln(w, "```")
Expand Down Expand Up @@ -95,3 +101,32 @@ func ParseRequiredBehaviour(s string) (RequiredBehaviour, bool) {
return 0, false
}
}

// DepsBehaviour represents how a tasks dependencies are run.
// The default is DependencyBehaviourSync
type DepsBehaviour int

const (
// DependencyBehaviourSync should be used if the dependencies are to be run synchronously.
DependencyBehaviourSync DepsBehaviour = iota
// DependencyBehaviourAsync should be used if the dependencies are to be run asynchronously.
DependencyBehaviourAsync
)

func (b DepsBehaviour) String() string {
if b == DependencyBehaviourSync {
return "sync"
}
return "async"
}

func ParseDepsBehaviour(s string) (DepsBehaviour, bool) {
switch strings.ToLower(s) {
case "sync":
return DependencyBehaviourSync, true
case "async":
return DependencyBehaviourAsync, true
default:
return 0, false
}
}
45 changes: 34 additions & 11 deletions parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import (
// ErrNoTasksHeading is returned if the markdown contains no xc block
var ErrNoTasksHeading = errors.New("no xc block found")

const trimValues = "_*` "
const codeBlockStarter = "```"
const (
trimValues = "_*` "
codeBlockStarter = "```"
)

type parser struct {
scanner *bufio.Scanner
Expand Down Expand Up @@ -130,17 +132,26 @@ const (
// AttrubuteTypeRun sets the tasks requiredBehaviour, can be always or once.
// Default is always
AttributeTypeRun
// AttributeTypeRunDeps sets the tasks dependenciesBehaviour, can be sync or async.
AttributeTypeRunDeps
// AttributeTypeInteractive indicates if this is an interactive task
// if it is, then logs are not prefixed and the stdout/stderr are passed directly
// from the OS
AttributeTypeInteractive
)

var attMap = map[string]AttributeType{
"req": AttributeTypeReq,
"requires": AttributeTypeReq,
"env": AttributeTypeEnv,
"environment": AttributeTypeEnv,
"dir": AttributeTypeDir,
"directory": AttributeTypeDir,
"inputs": AttributeTypeInp,
"run": AttributeTypeRun,
"req": AttributeTypeReq,
"requires": AttributeTypeReq,
"env": AttributeTypeEnv,
"environment": AttributeTypeEnv,
"dir": AttributeTypeDir,
"directory": AttributeTypeDir,
"inputs": AttributeTypeInp,
"run": AttributeTypeRun,
"rundeps": AttributeTypeRunDeps,
"rundependencies": AttributeTypeRunDeps,
"interactive": AttributeTypeInteractive,
}

func (p *parser) parseAttribute() (bool, error) {
Expand Down Expand Up @@ -181,6 +192,16 @@ func (p *parser) parseAttribute() (bool, error) {
return false, fmt.Errorf("run contains invalid behaviour %q should be (always, once): %s", s, p.currTask.Name)
}
p.currTask.RequiredBehaviour = r
case AttributeTypeRunDeps:
s := strings.Trim(rest, trimValues)
r, ok := models.ParseDepsBehaviour(s)
if !ok {
return false, fmt.Errorf("runDeps contains invalid behaviour %q should be (sync, async): %s", s, p.currTask.Name)
}
p.currTask.DepsBehaviour = r
case AttributeTypeInteractive:
s := strings.Trim(rest, trimValues)
p.currTask.Interactive = s == "true"
}
p.scan()
return true, nil
Expand Down Expand Up @@ -234,7 +255,9 @@ func (p *parser) parseTaskBody() (bool, error) {
return false, err
}
if p.reachedEnd {
return false, nil
// parse attribute again in case it is on the last line
_, err = p.parseAttribute()
return false, err
}
if ok {
continue
Expand Down
76 changes: 68 additions & 8 deletions parser/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import (
//go:embed testdata/example.md
var s string

//go:embed testdata/till-eof.md
var tillEOF string

//go:embed testdata/notasks.md
var e string

Expand All @@ -34,6 +37,9 @@ func assertTask(t *testing.T, expected, actual models.Task) {
if expected.RequiredBehaviour != actual.RequiredBehaviour {
t.Fatalf("Run want=%q got=%q", expected.RequiredBehaviour, actual.RequiredBehaviour)
}
if expected.DepsBehaviour != actual.DepsBehaviour {
t.Fatalf("Run want=%q got=%q", expected.DepsBehaviour, actual.DepsBehaviour)
}
if strings.Join(expected.DependsOn, ",") != strings.Join(actual.DependsOn, ",") {
t.Fatalf("requires want=%v got=%v", expected.DependsOn, actual.DependsOn)
}
Expand Down Expand Up @@ -83,6 +89,41 @@ echo "Hello, world2!"
}
}

func TestParseFileToEOF(t *testing.T) {
p, err := NewParser(strings.NewReader(tillEOF), "Tasks")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
result, err := p.Parse()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected := models.Tasks{
{
Name: "generate-templ",
Script: "go run -mod=mod github.com/a-h/templ/cmd/templ generate\ngo mod tidy\n",
},
{
Name: "generate-translations",
Script: "go run ./i18n/generate\n",
},
{
Name: "generate-all",
DependsOn: []string{
"generate-templ",
"generate-translations",
},
DepsBehaviour: models.DependencyBehaviourAsync,
},
}
if len(result) != len(expected) {
t.Fatalf("want %d tasks got %d", len(expected), len(result))
}
for i := range result {
assertTask(t, expected[i], result[i])
}
}

func TestParseFileNoTasks(t *testing.T) {
_, err := NewParser(strings.NewReader(e), "tasks")
if !errors.Is(err, ErrNoTasksHeading) {
Expand Down Expand Up @@ -191,14 +232,15 @@ func TestMultipleCodeBlocks(t *testing.T) {

func TestParseAttribute(t *testing.T) {
tests := []struct {
name string
in string
expectNotOk bool
expectEnv string
expectDir string
expectDependsOn string
expectInputs string
expectBehaviour models.RequiredBehaviour
name string
in string
expectNotOk bool
expectEnv string
expectDir string
expectDependsOn string
expectInputs string
expectBehaviour models.RequiredBehaviour
expectDepsBehaviour models.DepsBehaviour
}{
{
name: "given a basic Env, should parse",
Expand Down Expand Up @@ -295,6 +337,21 @@ func TestParseAttribute(t *testing.T) {
in: "run: _*`once`*_",
expectBehaviour: models.RequiredBehaviourOnce,
},
{
name: "given runDeps sync, should parse",
in: "runDeps: sync",
expectDepsBehaviour: models.DependencyBehaviourSync,
},
{
name: "given runDeps async, should parse",
in: "runDeps: async",
expectDepsBehaviour: models.DependencyBehaviourAsync,
},
{
name: "given runDeps sync with formatting, should parse",
in: "runDeps: _*`sync`*_",
expectDepsBehaviour: models.DependencyBehaviourSync,
},
{
name: "given env with no colon, should not parse",
in: "env _*`my:attribute_*`",
Expand Down Expand Up @@ -337,6 +394,9 @@ func TestParseAttribute(t *testing.T) {
if p.currTask.RequiredBehaviour != tt.expectBehaviour {
t.Fatalf("got=%q, want=%q", p.currTask.RequiredBehaviour, tt.expectBehaviour)
}
if p.currTask.DepsBehaviour != tt.expectDepsBehaviour {
t.Fatalf("got=%q, want=%q", p.currTask.DepsBehaviour, tt.expectDepsBehaviour)
}
})
}
}
Expand Down
21 changes: 21 additions & 0 deletions parser/testdata/till-eof.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# My readme

## Tasks

### generate-templ

```bash
go run -mod=mod github.com/a-h/templ/cmd/templ generate
go mod tidy
```

### generate-translations

```bash
go run ./i18n/generate
```

### generate-all

Requires: generate-templ, generate-translations
RunDeps: async
Loading

0 comments on commit 01aa6e0

Please sign in to comment.