Skip to content

Commit

Permalink
Document interactive stuff, add a few tests
Browse files Browse the repository at this point in the history
  • Loading branch information
christopher-dG committed Apr 14, 2020
1 parent 47c9a62 commit eaeebb7
Show file tree
Hide file tree
Showing 6 changed files with 122 additions and 26 deletions.
15 changes: 15 additions & 0 deletions docs/src/developer.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,21 @@ If you want to extend the validation but keep the file existence check, use the

For more examples, see the plugins in the [Continuous Integration (CI)](@ref) and [Code Coverage](@ref) sections.

## Supporting Interactive Mode

When it comes to supporting interactive mode for your custom plugins, you have two options: write your own [`interactive`](@ref) method, or use the default one.
If you choose the first option, then you are free to implement the method however you want.
If you want to use the default implementation, then there are a few functions that you should be aware of, although in many cases you will not need to add any new methods.

```@docs
interactive
prompt
not_customizable
extra_customizable
input_tips
convert_input
```

## Miscellaneous Tips

### Writing Template Files
Expand Down
7 changes: 5 additions & 2 deletions docs/src/migrating.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,12 @@ One less name to remember!
| :-----------------------------------------: | :---------------------------------: |
| `generate(::Template, pkg::AbstractString)` | `(::Template)(pkg::AbstractString)` |

## Interactive Templates
## Interactive Mode

Currently not implemented, but will be in the future.
| Old | New |
| :-----------------------------------------: | :---------------------------------: |
| `interactive_template()` | `Template(; interactive=true)` |
| `generate_interactive(pkg::AbstractString)` | `Template(; interactive=true)(pkg)` |

## Other Functions

Expand Down
79 changes: 55 additions & 24 deletions src/interactive.jl
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
const TemplateOrPlugin = Union{Template, Plugin}

"""
interactive(T::Type{<:Plugin}) -> T
Interactively create a plugin of type `T`. Implement this method and ignore other
related functions only if you want completely custom behaviour.
"""
function interactive(::Type{T}) where T <: TemplateOrPlugin
names = setdiff(fieldnames(T), not_customizable(T))
pairs = map(name -> name => fieldtype(T, name), names)
foreach(pair -> first(pair) in names || push!(pairs, pair), extra_customizable(T))
sort!(pairs; by=first)
pairs = interactive_pairs(T)

# There must be at least 2 MultiSelectMenu options.
# If there are none, return immediately.
Expand All @@ -21,45 +24,58 @@ function interactive(::Type{T}) where T <: TemplateOrPlugin
just_one && lastindex(pairs) in customize && return T()

kwargs = Dict{Symbol, Any}()
foreach(map(i -> pairs[i], customize)) do (name, F)
foreach(pairs[customize]) do (name, F)
kwargs[name] = prompt(T, F, name)
end
return T(; kwargs...)
end

function pretty_message(s::AbstractString)
replacements = [
r"Array{(.*?),1}" => s"Vector{\1}",
r"Union{Nothing, (.*?)}" => s"Union{\1, Nothing}",
]
return reduce((s, p) -> replace(s, p), replacements; init=s)
end

"""
not_customizable(::Type{<:Plugin}) -> Vector{Symbol}
Return a list of fields of the given plugin type that are not to be customized.
Return the names of fields of the given plugin type that cannot be customized
in interactive mode.
"""
not_customizable(::Type{T}) where T <: TemplateOrPlugin = ()

"""
extra_customizable(::Type{<:Plugin}) -> Vector{Symbol}
extra_customizable(::Type{<:Plugin}) -> Vector{Pair{Symbol, DataType}}
Return a list of keyword arguments that the given plugin type accepts,
which are not fields of the type.
which are not fields of the type, and should be customizable in interactive mode.
For example, for a constructor `Foo(; x::Bool)`, provide `[x => Bool]`.
"""
extra_customizable(::Type{T}) where T <: Plugin = ()

function pretty_message(s::AbstractString)
replacements = [
r"Array{(.*?),1}" => s"Vector{\1}",
r"Union{Nothing, (.*?)}" => s"Union{\1, Nothing}",
]
return reduce((s, p) -> replace(s, p), replacements; init=s)
end

"""
input_tips(::Type{T}) -> Vector{String}
Provide some extra tips to users on how to structure their input for the type `T`,
for example if multiple delimited values are expected.
"""
input_tips(T::Type{<:Vector}) = ["comma-delimited", input_tips(eltype(T))...]
input_tips(::Type{Union{T, Nothing}}) where T = ["empty for nothing", input_tips(T)...]
input_tips(::Type{Secret}) = ["name only"]
input_tips(::Type) = String[]

"""
convert_input(::Type{<:Plugin}, ::Type{T}, s::AbstractString) -> T
Convert the user input `s` into an instance of `T`.
A default implementation of `T(s)` exists.
"""
convert_input(::Type{<:TemplateOrPlugin}, ::Type{String}, s::AbstractString) = string(s)
convert_input(::Type{<:TemplateOrPlugin}, ::Type{VersionNumber}, s::AbstractString) = VersionNumber(s)
convert_input(::Type{<:TemplateOrPlugin}, ::Type{T}, s::AbstractString) where T <: Real = parse(T, s)
convert_input(::Type{<:TemplateOrPlugin}, ::Type{Secret}, s::AbstractString) = Secret(s)
convert_input(::Type{<:TemplateOrPlugin}, T::Type{<:Real}, s::AbstractString) = parse(T, s)
convert_input(::Type{<:TemplateOrPlugin}, ::Type{Bool}, s::AbstractString) = startswith(s, r"[ty]"i)
convert_input(::Type{<:TemplateOrPlugin}, T::Type, s::AbstractString) = T(s)

function convert_input(P::Type{<:TemplateOrPlugin}, T::Type{<:Vector}, s::AbstractString)
xs = map(strip, split(s, ","))
Expand All @@ -70,12 +86,12 @@ end
prompt(P::Type{<:Plugin}, ::Type{T}, ::Val{name::Symbol}) -> Any
Prompts for an input of type `T` for field `name` of plugin type `P`.
Implement this method to customize interactive logic for particular fields.
Implement this method to customize particular fields of particular types.
"""
prompt(P::Type{<:TemplateOrPlugin}, T::Type, name::Symbol) = prompt(P, T, Val(name))

function prompt(P::Type{<:TemplateOrPlugin}, ::Type{T}, ::Val{name}) where {T, name}
tips = join(filter(x -> x !== nothing, [T, input_tips(T)...]), ", ")
tips = join([T; input_tips(T)], ", ")
print(pretty_message("Enter value for '$name' ($tips): "))
input = strip(readline())
return if isempty(input)
Expand Down Expand Up @@ -121,23 +137,38 @@ function prompt(::Type{Template}, ::Type, ::Val{:plugins})
menu = MultiSelectMenu(map(string, options))
println("Select plugins:")
types = collect(request(menu))
return map(i -> interactive(options[i]), types)
return map(interactive, options[types])
end

function prompt(::Type{Template}, ::Type, ::Val{:disable_defaults})
options = map(typeof, default_plugins())
menu = MultiSelectMenu(map(string, options))
println("Select default plugins to disable:")
types = collect(request(menu))
return collect(map(i -> options[i], types))
return options[types]
end

# Call the default prompt method even if a specialized one exists.
function fallback_prompt(::Type{T}, name::Symbol) where T
return invoke(
prompt,
Tuple{Type{Plugin}, Type{T}, Val{name}},
Plugin, T, Val(:name),
Plugin, T, Val(name),
)
end

# Compute name => type pairs for T's interactive options.
function interactive_pairs(::Type{T}) where T <: TemplateOrPlugin
names = setdiff(fieldnames(T), not_customizable(T))
pairs = map(name -> name => fieldtype(T, name), names)

# Use pushfirst! here so that users can override field types if they wish.
foreach(pair -> pushfirst!(pairs, pair), extra_customizable(T))
unique!(first, pairs)
sort!(pairs; by=first)

return pairs
end

# Compute all the concrete subtypes of T.
concretes(T::Type) = isconcretetype(T) ? Any[T] : vcat(map(concretes, subtypes(T))...)
5 changes: 5 additions & 0 deletions src/template.jl
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ A configuration used to generate packages.
[`TagBot`](@ref)
To override a default plugin instead of disabling it altogether, supply it via `plugins`.
### Interactive Mode
- `interactive::Bool=false`: In addition to specifying the template options with keywords,
you can also build up a template by following a set of prompts.
To create a template interactively, set this keyword to `true`.
---
To create a package from a `Template`, use the following syntax:
Expand Down
41 changes: 41 additions & 0 deletions test/interactive.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
notnothingtype(::Type{T}) where T = T
notnothingtype(::Type{Union{T, Nothing}}) where T = T

@testset "Interactive mode" begin
@testset "convert_input has all required methods" begin
Fs = mapreduce(union!, PT.concretes(PT.Plugin); init=Set()) do T
map(notnothingtype, fieldtypes(T))
end
foreach(Fs) do F
@test hasmethod(PT.convert_input, Tuple{Type{Template}, Type{F}, AbstractString})
end
end

@testset "input_tips" begin
@test isempty(PT.input_tips(Int))
@test PT.input_tips(Vector{String}) == ["comma-delimited"]
@test PT.input_tips(Union{Vector{String}, Nothing}) ==
["empty for nothing", "comma-delimited"]
@test PT.input_tips(Union{String, Nothing}) == ["empty for nothing"]
@test PT.input_tips(Union{Vector{Secret}, Nothing}) ==
["empty for nothing", "comma-delimited", "name only"]
end

@testset "Interactive name/type pair collection" begin
name = gensym()
@eval begin
struct $name <: PT.Plugin
x::Int
y::String
end

@test PT.interactive_pairs($name) == [:x => Int, :y => String]

PT.not_customizable(::Type{$name}) = (:x,)
@test PT.interactive_pairs($name) == [:y => String]

PT.extra_customizable(::Type{$name}) = (:y => Float64, :z => Int)
@test PT.interactive_pairs($name) == [:y => Float64, :z => Int]
end
end
end
1 change: 1 addition & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ mktempdir() do dir
include("template.jl")
include("plugin.jl")
include("show.jl")
include("interactive.jl")

if PT.git_is_installed()
include("git.jl")
Expand Down

0 comments on commit eaeebb7

Please sign in to comment.