Simpler Reusability with the Global Client

January 22, 2024

Jan 22, 2024

Helder Correia

Engineering

Share
Share
Share
Share

Passing a dagger.Client instance around when making pipelines is no longer required since there’s now a globally available one. By switching to the global client, your code will be less verbose and simpler to reuse. In this post, we’ll explore the global client feature with Python, Typescript, and Go SDK examples.

Background

The usual first step for building a Dagger pipeline is to establish a connection, which returns a dagger.Client instance that must be passed around to access the API:

import anyio
import dagger


def base(client: dagger.Client) -> dagger.Container:
    return client.container().from_("alpine")


async def test(client: dagger.Client) -> str:
    return await base(client).with_exec(["echo", "hello world"]).stdout()


async def main():
    async with dagger.Connection() as client:
        out = await test(client)

    print(out)


anyio.run(main)

In larger projects, this can become quite cumbersome, especially when using a tool like Click, that creates a command line interface from functions in code, since one has to conform to the tool’s API to pass the client instance around, while making sure the connection isn’t closed prematurely (for example, by exiting the with block).

The global client: dag

Users usually only need one client connection during a dagger run call. We can simplify by providing a global client instance that can be referred from anywhere.

The Python SDK introduced this as an experimental opt-in feature back in v0.8.5 until its maturity in v0.9.4 where the name dag was adopted as a convention for the global client instance across SDKs.

Basic usage:

import dagger
from dagger import dag

async def main():
    async with dagger.connection():
        src = dag.host().directory(".")
        ...

Notice the difference in casing:

Example

We can now simplify the initial example:

import anyio
import dagger
from dagger import dag


def base() -> dagger.Container:
    return dag.container().from_("alpine")


async def test() -> str:
    return await base().with_exec(["echo", "hello world"]).stdout()


async def main():
    async with dagger.connection():
        out = await test()

    print(out)


anyio.run(main)

In summary:

  • Import dag

  • Replace dagger.Connection() as client with dagger.connection()

  • Remove client from function arguments

  • Rename remaining client references to dag

Simplified utility factories

In a lot of cases with utilities need to be wrapped in a factory function to pass a single client instance argument:

def base(client: dagger.Client):
    return client.container().with_(source(client))

def source(client: dagger.Client):
    def _source(ctr: dagger.Container):
        src = client.host().directory("src")
        return ctr.with_directory("/src", src)
    return _source

With the global client, a factory is no longer necessary:

def base():
    return dag.container().with_(source)

def source(ctr: dagger.Container):
    src = dag.host().directory("src")
    return ctr.with_directory("/src", src)

Simplified reusability

For lazy API fields (those that don’t require an await), having an established connection first is no longer necessary, which can make it much simpler to manage the pipeline setup in certain use cases:

import os

import anyio
import dagger
from dagger import dag


async def main():
    token = dag.set_secret("token", os.getenv("API_TOKEN"))
    src = dag.host().directory("src")
    
    ctr = (
        dag.container()
        .from_("alpine")
        .with_secret_variable("token", token)
        .with_directory("/src", src)
        .with_exec([...])
    )
    
    # only need a connection when making requests
    async with dagger.connection():
        out = await ctr.stdout()

    print(out)


anyio.run(main)

Lazy connection

If a dagger.Config isn’t needed (and it mostly isn’t when using dagger run), the global client can also automatically connect when it needs to make the first request. Removing the need to connect explicitly can further simplify some setups, but you need to make sure to close the connection in the end cleanly:

import anyio
import dagger
from dagger import dag

async def run():
    ctr = (
        dag.container()
        .from_("alpine")
        .with_exec(["cat", "/etc/alpine-release"])
    )
    print(await ctr.stdout())  # automatically connected here
    

async def main():
    try:
        await run()
    finally:
        # make sure `close` is called
        await dagger.close()


anyio.run(main)

Notice that you must guard against exceptions that may be raised by the pipeline anyway; otherwise, dagger.close() won’t be called.

Another downside is that if there’s an error during connection, it’s possible that it’s being made during an implicit conversion from object to ID that the SDKs make internally (for example, a Directory instance in Container.with_directory to a DirectoryID required by the API) so you may be confused by the error message’s origin.

For those reasons, we recommend being more explicit and simply using a with dagger.connection() block as in the previous examples.

Typescript SDK

The global client has been available in the Typescript SDK since v0.9.4 via dagger.connection():

import { dag } from '@dagger.io/dagger'
import * as dagger from '@dagger.io/dagger'

await dagger.connection(async () => {
   const out = await dag
        .container()
        .from("alpine")
        .withExec(["echo"," hello world"])
        .stdout()

    console.log(out)
})

In summary:

  • Import dag

  • Replace connect with dagger.connection

  • Remove client from function arguments

  • Rename remaining client references to dag

To connect lazily, you can replace dagger.connection(main) with a manual call to dagger.close() as well:

main().finally(() => dagger.close())

Go SDK

The Go SDK added support more recently in v0.9.6:

package main

import (
	"context"
	"fmt"

	"dagger.io/dagger/dag"
)

func main() {
	ctx := context.Background()
	defer dag.Close()

	out, err := dag.
		Container().
		From("alpine").
		WithExec([]string{"echo", "hello world"}).
		Stdout(ctx)

	if err != nil {
		panic(err)
	}

	fmt.Println(out

In Go, there’s no way to pass configuration for the connection (you probably don't need it anyway if using dagger run). It only supports a lazy connection, but the ergonomics for closing it is the same (with defer dag.Close()).

In summary:

  • Import dag

  • Remove dagger.Connection(ctx)

  • Replace client.Close() with dag.Close()

  • Remove client from function arguments;

  • Rename remaining client references to dag

Conclusion

We’ve seen that the Python, Typescript, and Go SDKs have a new convention for a global client under the name dag, which can be called from anywhere, even before establishing a connection. As well as how adopting it can reduce verbosity and help simplify the setup for running pipelines in non-trivial cases where greater care needs to be taken to manage the client connection to the Dagger API.

In addition, our upcoming Zenith release will feature support for what we call Dagger modules (currently available as experimental), which use the global client in all SDKs but where the connection is managed for you. So, adopting the global client now will also make it much easier to adapt to a module later.

Check out the global client today, and let us know if you have any feedback! If you get stuck, have feedback to share, or want to discuss your use case, chat with us on Discord.

Get Involved With the community

Discover what our community is doing, and join the conversation on Discord & GitHub to help shape the evolution of Dagger.

Subscribe to our newsletter

Get Involved With the community

Discover what our community is doing, and join the conversation on Discord & GitHub to help shape the evolution of Dagger.

Subscribe to our newsletter

Get Involved With the community

Discover what our community is doing, and join the conversation on Discord & GitHub to help shape the evolution of Dagger.

Subscribe to our newsletter