How do I make a driver?

I have a main program. It will load any one of various drivers (plugins?), depending on a configuration file. Each driver will operate a hardware device or accept files from the outside. The drivers have to be all listed in Project.toml, but only one can be loaded, because they all define and export the same function names. How do I do this?

I tried @less using, to see the source code of using, but the REPL hung and I had to type ^C. I tried @less using(), but got an error because using is not a function. Is there a function I can call to load a module, whose name is not known until runtime, in the same way as using?

I suspect there might be better solutions, but you could use eval for this:

If this is main.jl

module A1
    export foo
    foo(x) = x
end

module A2
    export foo
    foo(x) = 2x
end

moduleName = Symbol(ARGS[1])
@eval using .$(moduleName)
println(foo(1))

You would get

$ julia main_test.jl A1
1

$ julia main_test.jl A2
2

Using @eval comes with the usual caveats of execution any code you pass to it from the outside of course (whatever you read from the config file).
Depending on if you need the list of drivers/plugins to be expandable from the outside, you could just check if the runtime value of the plugin name is in a list of allowed names or something like that.

But if I understand this correctly, the @eval using $() expression would at least error if you try to interpolate anything that’s not just a symbol into it. So it should be reasonably restrictive already.

Note the dot . in my example since I’m using modules defined directly in Main. In your case it would probably be @eval using $(...) without the leading ..

PS: If your list of possible plugins is fixed in advance, you can also just write an if statement

if moduleName == :A1
    using .A1
elseif ...
    ...
end

which would avoid @eval altogether. Of course it’s not as concise, but might be enough in your situation and is quite straightforward to understand. For some reason I thought that this doesn’t work, but I can’t recall why… after all, conditional loading of packages is also what is done by some people in the startup.jl file, e.g. something like

if isinteractive()
     using Revise
end

I think you should always load all the drivers and solve this with dispatch. Here is how this could look

  • Have on abstract type for the plugins e.g.
# module structure optional but good style and helps show the dependencies in this example
module DriversBase

# export interface functions
export load, end

abstract type AbstractDriver end

# also define the functions the drivers implement
# so the driver can add their dispatches
# also it's nice to see the whole interface together in one file
"""
    load(::AbstractDriver)
Set up the driver
"""
function load end

"""
    dostuff(::AbstractDriver, msg::String)
Do stuff with the message.
"""
function dostuff end

end #module
  • Have each driver/plugin define a type and then add their methods to the aforementioned functions:
module FooDriver

# import DriversBase module from parent
using ..DriversBase

# export this driver's type
export FooDriver
# note we don't need to reexport the functions because they are already exported by Drivers module

# type for dispatch
struct FooDriver <: DriversBase.AbstractDriver end

## implement the functions
function DriversBase.load(::FooDriver)
    # ...
end

function DriversBase.dostuff(::FooDriver, msg)
    # ...
end

end #module
  • Pull this together into a package/module
module MyDriverCollection

export load, dostuff
export FooDriver #, BarDriver, ...

include("drivers_base.jl")
using DriversBase

include("foo.jl")
using FooDriver

# and so on
end # module

Then all you need to do to switch drivers is write function that return one of these type:

function decide_driver(path)
    # parse file and return appropriate type
    return FooDriver()
end

Note: this decision is inherently type unstable so be sure to use a function barrier to contain it properly, i.e. do something like this:

function main()
    # ...
    driver = decide_driver()
    do_everything_else(driver) # 1 runtime dispatch but then type stable again
end

Also disclaimer: I wrote this up on my phone, so minor mistakes are possible. But overall this approach should work. Feel free to ask if it doesn’t work out-of-the-box and you have trouble getting it right :slight_smile:

5 Likes

Could I run into world age bugs?

Doesn’t the module name have to be listed in Project.toml for using to succeed?

It isn’t, but for a particular installation, the driver is generally fixed, though the software may need to be upgraded.

Type of what? All the drivers export startDriver(), justStopDriver(), and waitDriverFinish(), and there’s nothing obvious for it to be a type of.

There’s no need or way to switch drivers once the program’s running.

It’ll be at least a few weeks. I have one half-written driver and am going to start another tonight, and it’ll be next month at the earliest before I can buy hardware to write a hardware driver.

In general, yes. But without knowing more about your code, it’s hard to make accurate predictions. My guess is everything will be fine as long as you hit the “top-level” of your main module after dynamically loading the plugins and before your other functions execute. But I’m not even sure if using ... will create the same type of problems you would have when you try to call a function defined in @eval directly (before returning to the top-level).

Sure (or the module is defined in your code somewhere). That’s why I think that @eval using ... will error for anything that doesn’t parse to a valid “using” expression and it’s quite safe to use in this situation.


All that being said, I think @abraemer’s approach probably makes more sense here. It requires a bit of extra code, but has the additional benefit that other people (including you) can define new drivers/plugins later independently of your main package. Even if you don’t need to switch the driver dynamically, it might be convenient for testing or for writing new drivers to have a function that can turn a driver on/off.

You just create a new type representing the driver itself. This will allow these three functions to dispatch on the driver type (by which Julia will automatically choose the desired driver for you without the need of a “dynamic using expression”.

E.g. the driver called Diver1 implements the methods of this function specific to that driver

startDriver(driver::Driver1) = ...
justStopDriver(driver::Driver1) = ...
waitDriverFinish(driver::Driver1) = ...

And similarly for all other drivers. The argument driver will likely not be used inside the method definition, but that’s fine.

You still have to carry around the driver explicitly for this to work and be performant. But you could also make a global variable that holds the “current driver” and define a fallback method like

startDriver() = startDriver(current_driver())
justStopDriver() = justStopDriver(current_driver())
waitDriverFinish() = waitDriverFinish(current_driver())

But this might impact performance, depending on how/where these functions are used.

2 Likes

It is certainly possible to achieve this design, but why would you do it like that?

I feel such artificial limitations are ugly. One practical drawback is that it will make testing your packages a pain.

1 Like

This is not an artificial limitation, but I can’t tell you why, because the project is so far a trade secret. There is one planned driver which will drive two hardware devices.

I’ve done some plotting. I use CairoMakie or GLMakie, but not both at the same time, because they export functions with the same name. To change it, I have to edit the source code and restart the program. I’d like to do something similar, but choosing the module at the start of runtime by reading a config file.

Or just rename:

using SomePackage: original_name as new_name

I think that may make precompilation impossible. Or at least less straightforward.

What for? There’s no need to have two of these drivers running at once.

If I follow, you have one program where you want to be able to select which driver to use. However once the program is running the driver is fixed.

I am not an expert in this area, but presumably you could organize your code like Makie, CairoMakie, and GLMakie?

Or perhaps you could use extensions to determine which driver is loaded?

1 Like

What do you mean by extensions?

This is rather paranthetical to your actual problem, but you can load both CairoMakie and GLMakie, and switch between which backend is active by doing:

using CairoMakie
using GLMakie
CairoMakie.activate!() # Sets CairoMakie as the active backend for plotting

# Do some plotting with CairoMakie

GLMakie.activate!() # Set GLMakie as the active backend 

# Do some plotting with GLMakie

I have no idea how this is implemented, but at least this should make plotting a bit less of a pain.

I agree that this would be my first recommendation:

But alternatively, this seems like a good use case for Preferences. Maybe something like:

module MyPackage

using Preferences: Preferences, @load_preference

const driver = @load_preference("driver")

# We're at top-level, so no need for @static
if driver == "foo"
    include("foo_driver_functions.jl")
elseif driver == "bar"
    include("bar_driver_functions.jl")
elseif driver == "baz"
    include("baz_driver_functions.jl")
else
    error("Unsupported driver name: $driver")
end

end # module MyPackage

Or alternatively, if the drivers are all separate packages:

module MyPackage

using Preferences: Preferences, @load_preference

const driver = @load_preference("driver")

# We're at top-level, so no need for @static
if driver == "foo"
    using FooDriver
elseif driver == "bar"
    using BarDriver
elseif driver == "baz"
    using BazDriver
else
    error("Unsupported driver name: $driver")
end

end # module MyPackage
5 Likes

I guess that was what was meant by extensions. They have a similar purpose, but it’s more about dynamically activating some functionality in PackageA that is required for working nicely with PackageB (but only when PackageB is actually loaded with using PackageB). So in your scenario it would again require a using MyDriver statement to activate the corresponding extension, which you want to avoid, if I understand correctly.

Preferences sounds workable, but IIRR its file is in the source tree. I’m already using ConfParser so that the config file can be in /etc (it’s in ~/.config for now). Can I do the same with ConfParser?

I believe that there is a way to have a depot-wide preference, but off the top of my head I don’t know how to set that up.

Preferences is special because it hooks into precompilation. I don’t think other packages currently offer this functionality[1].


  1. Presumably they could, although they’d need to call private Base Julia internals. ↩︎