Skip to content

Commit

Permalink
"Task" API: Simplified usage and "normalized" naming scheme (#51)
Browse files Browse the repository at this point in the history
With GH-14 [1] the "abstract" wand API was introduced with a naming
scheme is inspired by the fantasy novel "Harry Potter" [2] that was used
to to define interfaces.
The main motivation was to create a matching naming to the overall
"magic" topic and the actual target project Mage [3], but in retrospect
this is way too abstract and confusing.

The goal of this commit is to...

- rewrite the API to make it way easier to use.
- use a "normal" naming scheme.
- improve all documentations to be more user-scoped and provide guides
  and examples.

>>> New API Concept

The basic mindset of the API remains partially the same, but it is
designed around the concept of tasks and the ways to run them.

>>>> Command Runner

`task.Runner` [4] is a new base interface that runs a command with
parameters in a specific environment. It can be compared to the current
`cast.Caster` [5] interface, but provides a cleaner method set
accepting the new `task.Task` [6] interface.

- <M> `Handles() task.Kind` - returns the supported task kind [7].
- <M> `Run(task.Task) error` - runs a command.
- <M> `Validate() error` - validates the runner.

The new `task.RunnerExec` [8] interface is a specialized `task.Runner`
and serves as an abstract representation for a command or action, in
most cases a (binary) [executable][9] of external commands or Go module
`main` packages, that provides corresponding information like the path
to the executable.
It can be compared to the previous `BinaryCaster` [10] interface,
but also comes with a cleaner method set and a more appropriate name.

- <M> `FilePath() string` - returns the path to the (binary) command
  executable.

>>>> Tasks

`task.Task` [6] is the new interface that is scoped for Mage
"target" [11] usage. It can be compared to the previous
`spell.Incantation` [12] interface, but provides a smaller method set
without `Formula() []string`.

- <M> `Kind() task.Kind` - returns the task kind [7].
- <M> `Options() task.Options` - returns the task options [13].

The new `task.Exec` [14] interface is a specialized `task.Task` and
serves as an abstract task for an executable command.
It can be compared to the previous `Binary` [15] interface,
but also comes with the new `BuildParams() []string` method that enables
a more flexible usage by exposing the parameters for command runner
like `task.RunnerExec` and also allows to compose with other tasks.
See the Wikipedia page about the "anatomy of a shell CLI" [16] for more
details about parameters.

- <M> `BuildParams() []string` - builds the parameters for a command
  runner where parameters can consist of options, flags and arguments.
- <M> `Env() map[string]string` - returns the task specific environment.

The new `task.GoModule` [17] interface is a specialized `task.Exec` for
a executable Go module command.
It can be compared to the previous `spell.GoModule` [18] interface and
the method set has not changed except a renaming of the
`GoModuleID() *project.GoModuleID` to the more appropriate name
`ID() *project.GoModuleID`.
See the official "Go module reference documentation" [19] for more
details about Go modules.

- <M> `ID() *project.GoModuleID` - returns the identifier of a Go
  module.

>>> New API Naming Scheme

The following listing shows the new name concept and how the previous
API components can be mapped to the upcoming changes:

1. Runner - A component that runs a command with parameters in a
   specific environment, in most cases a (binary) executable [9] of
   external commands or Go module `main` packages.
   The previous API component that can be compared to runners is
   `cast.Caster` [5] and its specialized interfaces.
2. Tasks - A component that is scoped for Mage "target" [11] usage in
   order to run a action. The previous API component that can be
   compared to tasks is`spell.Incantation` [12] and its specialized
   interfaces.

>>> API Usage

Even though the API has been changed quite heavily, the basic usage has
almost not changed.

-> A `task.Task` can only be run through a `task.Runner`!

Before a `spell.Incantation` was passed to a `cast.Caster` in order to
run it, in most cases a (binary) executable of a command that uses the
`Formula() []string` method of `spell.Incantation` to pass the result a
 parameters.
The new API works the same: A `task.Task` is passed to a `task.Runner`
that calls the `BuildParams() []string` method when the runner is
specialized for (binary) executable of commands.

>>> Improved Documentations

Before the documentation was mainly scoped on the technical details,
but lacked more user-friendly sections about topics like the way how to
implement own API components, how to compose the "elder" reference
implementation [20] or usage examples for single or monorepo [21]
project layouts.

>>>> User Guide

Most of the current sections have been rewritten or removed entirely
while new sections now provide more user-friendly guides about how to...

- use or compose the "elder" reference implementation [20].
- build own tasks and runners using the new API.
- structure repositories independent of the layout, single or
  "monorepo".

>>>> Usage Examples

Some examples have been added, that are linked and documented in the
user guides described above, to show how to...

- use or compose the "elder" reference implementation [20].
- build own tasks and runners using the new API.
- structure repositories independent of the layout, single or
  "monorepo".

[1]: #14
[2]: https://en.wikipedia.org/wiki/Harry_Potter
[3]: https://magefile.org
[4]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task#Runner
[5]: https://pkg.go.dev/github.com/svengreb/wand/pkg/cast#Caster
[6]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task#Task
[7]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task#Kind
[8]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task#RunnerExec
[9]: https://en.wikipedia.org/wiki/Executable
[10]: https://pkg.go.dev/github.com/svengreb/wand/pkg/cast#BinaryCaster
[11]: https://magefile.org/targets
[12]: https://pkg.go.dev/github.com/svengreb/wand/pkg/spell#Incantation
[13]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task#Options
[14]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task#Exec
[15]: https://pkg.go.dev/github.com/svengreb/wand/pkg/spell#Binary
[16]: https://en.wikipedia.org/wiki/Command-line_interface#Anatomy_of_a_shell_CLI
[17]: https://pkg.go.dev/github.com/svengreb/wand/pkg/task#GoModule
[18]: https://pkg.go.dev/github.com/svengreb/wand/pkg/spell#GoModule
[19]: https://golang.org/ref/mod
[20]: https://pkg.go.dev/github.com/svengreb/wand/pkg/elder
[21]: https://trunkbaseddevelopment.com/monorepos

Closes GH-49
  • Loading branch information
svengreb authored Dec 7, 2020
1 parent bd92961 commit f51a4bf
Show file tree
Hide file tree
Showing 60 changed files with 2,712 additions and 1,958 deletions.
280 changes: 176 additions & 104 deletions README.md

Large diffs are not rendered by default.

141 changes: 141 additions & 0 deletions examples/custom_runner/runners.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// +build mage

package main

import (
"fmt"
"os/exec"

"github.com/magefile/mage/sh"
glFS "github.com/svengreb/golib/pkg/io/fs"

"github.com/svengreb/wand/pkg/task"
)

const (
// DefaultRunnerExec is the default name of the runner executable.
DefaultRunnerExec = "fruitctl"

// RunnerName is the name of the runner.
RunnerName = "fruit_mixer"
)

// FruitMixerOption is a fruit mixer runner option.
type FruitMixerOption func(*FruitMixerOptions)

// FruitMixerOptions are fruit mixer runner options.
type FruitMixerOptions struct {
// Env is the runner specific environment.
Env map[string]string

// Exec is the name or path of the runner command executable.
Exec string

// Quiet indicates whether the runner output should be minimal.
Quiet bool
}

// FruitMixerRunner is a task runner for the fruit mixer.
type FruitMixerRunner struct {
opts *FruitMixerOptions
}

// FilePath returns the path to the runner executable.
func (r *FruitMixerRunner) FilePath() string {
return r.opts.Exec
}

// Handles returns the supported task kind.
func (r *FruitMixerRunner) Handles() task.Kind {
return task.KindExec
}

// Run runs the command.
// It returns an error of type *task.ErrRunner when any error occurs during the command execution.
func (r *FruitMixerRunner) Run(t task.Task) error {
tExec, ok := t.(task.Exec)
if t.Kind() != task.KindExec || !ok {
return &task.ErrRunner{
Err: fmt.Errorf("expected %q but got %q", r.Handles(), t.Kind()),
Kind: task.ErrUnsupportedTaskKind,
}
}

runFn := sh.RunWithV
if r.opts.Quiet {
runFn = sh.RunWith
}

for k, v := range tExec.Env() {
r.opts.Env[k] = v
}

return runFn(r.opts.Env, r.opts.Exec, tExec.BuildParams()...)
}

// Validate validates the command executable.
// It returns an error of type *task.ErrRunner when the executable does not exist and when it is also not available in
// the executable search path(s) of the current environment.
func (r *FruitMixerRunner) Validate() error {
// Check if the executable exists,...
execExits, fsErr := glFS.RegularFileExists(r.opts.Exec)
if fsErr != nil {
return &task.ErrRunner{
Err: fmt.Errorf("command runner %q: %w", RunnerName, fsErr),
Kind: task.ErrRunnerValidation,
}
}
// ...otherwise try to look up the executable search path(s).
if !execExits {
path, pathErr := exec.LookPath(r.opts.Exec)
if pathErr != nil {
return &task.ErrRunner{
Err: fmt.Errorf("command runner %q: %q not found in PATH: %w", RunnerName, r.opts.Exec, pathErr),
Kind: task.ErrRunnerValidation,
}
}
r.opts.Exec = path
}

return nil
}

// NewFruitMixerRunner creates a new fruit mixer command runner.
func NewFruitMixerRunner(opts ...FruitMixerOption) *FruitMixerRunner {
return &FruitMixerRunner{opts: NewFruitMixerRunnerOptions(opts...)}
}

// NewFruitMixerRunnerOptions creates new fruit mixer runner options.
func NewFruitMixerRunnerOptions(opts ...FruitMixerOption) *FruitMixerOptions {
opt := &FruitMixerOptions{
Env: make(map[string]string),
Exec: DefaultRunnerExec,
}
for _, o := range opts {
o(opt)
}

return opt
}

// WithEnv sets the runner specific environment.
func WithEnv(env map[string]string) FruitMixerOption {
return func(o *FruitMixerOptions) {
o.Env = env
}
}

// WithExec sets the name or path of the runner command executable.
// Defaults to DefaultRunnerExec.
func WithExec(nameOrPath string) FruitMixerOption {
return func(o *FruitMixerOptions) {
o.Exec = nameOrPath
}
}

// WithQuiet indicates whether the runner output should be minimal.
func WithQuiet(quiet bool) FruitMixerOption {
return func(o *FruitMixerOptions) {
o.Quiet = quiet
}
}
104 changes: 104 additions & 0 deletions examples/custom_task/tasks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// +build mage

package main

import (
"github.com/svengreb/wand"
"github.com/svengreb/wand/pkg/app"
"github.com/svengreb/wand/pkg/task"
)

// MixOption is a mix task option.
type MixOption func(*MixOptions)

// MixOptions are mix task options.
type MixOptions struct {
// env is the task specific environment.
env map[string]string

// extraArgs are additional arguments passed to the command.
extraArgs []string

// fruits are the fruits.
fruits []string

// verbose indicates whether the output should be verbose.
verbose bool
}

// MixTask is a mix task for the fruit CLI.
type MixTask struct {
ac app.Config
opts *MixOptions
}

// BuildParams builds the parameters.
func (t *MixTask) BuildParams() []string {
var params []string

// Toggle verbose output.
if t.opts.verbose {
params = append(params, "-v")
}

// Include additionally configured arguments.
params = append(params, t.opts.extraArgs...)

// Append all fruits.
params = append(params, t.opts.fruits...)

return params
}

// Env returns the task specific environment.
func (t *MixTask) Env() map[string]string {
return t.opts.env
}

// Kind returns the task kind.
func (t *MixTask) Kind() task.Kind {
return task.KindExec
}

// Options returns the task options.
func (t *MixTask) Options() task.Options {
return *t.opts
}

// NewMixTask creates a new mix task for the fruit CLI.
func NewMixTask(wand wand.Wand, ac app.Config, opts ...MixOption) (*MixTask, error) {
return &MixTask{ac: ac, opts: NewMixOptions(opts...)}, nil
}

// NewMixOptions creates new mix task options.
func NewMixOptions(opts ...MixOption) *MixOptions {
opt := &MixOptions{
env: make(map[string]string),
}
for _, o := range opts {
o(opt)
}

return opt
}

// WithEnv sets the mix task specific environment.
func WithEnv(env map[string]string) MixOption {
return func(o *MixOptions) {
o.env = env
}
}

// WithFruits adds fruits.
func WithFruits(fruits ...string) MixOption {
return func(o *MixOptions) {
o.fruits = append(o.fruits, fruits...)
}
}

// WithVerboseOutput indicates whether the output should be verbose.
func WithVerboseOutput(verbose bool) MixOption {
return func(o *MixOptions) {
o.verbose = verbose
}
}
9 changes: 9 additions & 0 deletions examples/monorepo/apps/cli/cli.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package main

import (
"github.com/svengreb/wand/examples/monorepo/pkg/cmd/cli"
)

func main() {
cli.Main()
}
9 changes: 9 additions & 0 deletions examples/monorepo/apps/daemon/daemon.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package main

import (
"github.com/svengreb/wand/examples/monorepo/pkg/cmd/daemon"
)

func main() {
daemon.Main()
}
9 changes: 9 additions & 0 deletions examples/monorepo/apps/promexp/promexp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package main

import (
"github.com/svengreb/wand/examples/monorepo/pkg/cmd/promexp"
)

func main() {
promexp.Main()
}
99 changes: 99 additions & 0 deletions examples/monorepo/magefile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// +build mage

package main

import (
"context"
"fmt"
"os"

"github.com/magefile/mage/mg"
"github.com/svengreb/nib"
"github.com/svengreb/nib/inkpen"

appCLI "github.com/svengreb/wand/examples/monorepo/pkg/cmd/cli"
appDaemon "github.com/svengreb/wand/examples/monorepo/pkg/cmd/daemon"
appPromExp "github.com/svengreb/wand/examples/monorepo/pkg/cmd/promexp"
"github.com/svengreb/wand/pkg/elder"
wandProj "github.com/svengreb/wand/pkg/project"
wandProjVCS "github.com/svengreb/wand/pkg/project/vcs"
taskGo "github.com/svengreb/wand/pkg/task/golang"
taskGoBuild "github.com/svengreb/wand/pkg/task/golang/build"
)

const (
projectDisplayName = "Fruit Mixer"
projectName = "fruit-mixer"
)

var elderWand *elder.Elder

func init() {
// Create a new "elder wand".
ew, ewErr := elder.New(
// Provide information about the project.
elder.WithProjectOptions(
wandProj.WithName(projectName),
wandProj.WithDisplayName(projectDisplayName),
wandProj.WithVCSKind(wandProjVCS.KindNone),
wandProj.WithModulePath("github.com/svengreb/wand/examples/monorepo"),
),
// Use "github.com/svengreb/nib/inkpen" module as line printer for human-facing messages.
elder.WithNib(inkpen.New()),
)
if ewErr != nil {
fmt.Printf("Failed to initialize elder wand: %v\n", ewErr)
os.Exit(1)
}

// Register any amount of project applications (monorepo layout).
apps := []struct {
name, displayName, pathRel string
}{
{appCLI.Name, appCLI.DisplayName, "apps/cli"},
{appDaemon.Name, appDaemon.DisplayName, "apps/daemon"},
{appPromExp.Name, appPromExp.DisplayName, "apps/promexp"},
}
for _, app := range apps {
if regErr := ew.RegisterApp(app.name, app.displayName, app.pathRel); regErr != nil {
ew.ExitPrintf(1, nib.ErrorVerbosity, "Failed to register application %q: %v", app.name, regErr)
}
}

elderWand = ew
}

func baseGoBuild(appName string) {
buildErr := elderWand.GoBuild(
appName,
taskGoBuild.WithBinaryArtifactName(appName),
taskGoBuild.WithOutputDir("out"),
taskGoBuild.WithGoOptions(
taskGo.WithTrimmedPath(true),
),
)
if buildErr != nil {
fmt.Printf("Build incomplete: %v\n", buildErr)
}
elderWand.Successf("Build completed")
}

func Build(mageCtx context.Context) {
mg.SerialDeps(
CLI.Build,
Daemon.Build,
PrometheusExporter.Build,
)
}

type CLI mg.Namespace

func (CLI) Build(mageCtx context.Context) { baseGoBuild(appCLI.Name) }

type Daemon mg.Namespace

func (Daemon) Build(mageCtx context.Context) { baseGoBuild(appDaemon.Name) }

type PrometheusExporter mg.Namespace

func (PrometheusExporter) Build(mageCtx context.Context) { baseGoBuild(appPromExp.Name) }
Loading

0 comments on commit f51a4bf

Please sign in to comment.