Interactive mode (#145)

This commit is contained in:
Chris de Graaf 2020-05-25 15:20:27 -05:00 committed by GitHub
parent 35002583b4
commit 18c32a1519
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 686 additions and 175 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@
*.jl.*.cov *.jl.*.cov
*.jl.cov *.jl.cov
*.jl.mem *.jl.mem
/Manifest.toml

View File

@ -12,7 +12,6 @@ julia:
before_script: before_script:
- git config --global user.name Tester - git config --global user.name Tester
- git config --global user.email te@st.er - git config --global user.email te@st.er
script: travis_wait julia --project -e 'using Pkg; Pkg.test(coverage=true)'
matrix: matrix:
fast_finish: true fast_finish: true
allow_failures: allow_failures:

View File

@ -1,115 +0,0 @@
# This file is machine-generated - editing it directly is not advised
[[Base64]]
uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
[[DataAPI]]
git-tree-sha1 = "674b67f344687a88310213ddfa8a2b3c76cc4252"
uuid = "9a962f9c-6df0-11e9-0e5d-c546b8b5ee8a"
version = "1.1.0"
[[DataValueInterfaces]]
git-tree-sha1 = "bfc1187b79289637fa0ef6d4436ebdfe6905cbd6"
uuid = "e2d170a0-9d28-54be-80f0-106bbe20a464"
version = "1.0.0"
[[Dates]]
deps = ["Printf"]
uuid = "ade2ca70-3891-5945-98fb-dc099432e06a"
[[Distributed]]
deps = ["Random", "Serialization", "Sockets"]
uuid = "8ba89e20-285c-5b6f-9357-94700520ee1b"
[[InteractiveUtils]]
deps = ["Markdown"]
uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240"
[[IteratorInterfaceExtensions]]
git-tree-sha1 = "a3f24677c21f5bbe9d2a714f95dcd58337fb2856"
uuid = "82899510-4779-5014-852e-03e436cf321d"
version = "1.0.0"
[[LibGit2]]
deps = ["Printf"]
uuid = "76f85450-5226-5b5a-8eaa-529ad045b433"
[[Libdl]]
uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb"
[[LinearAlgebra]]
deps = ["Libdl"]
uuid = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
[[Logging]]
uuid = "56ddb016-857b-54e1-b83d-db4d58db5568"
[[Markdown]]
deps = ["Base64"]
uuid = "d6f4376e-aef5-505a-96c1-9c027394607a"
[[Mustache]]
deps = ["Printf", "Tables"]
git-tree-sha1 = "e06eef2abee113c49695f5347668e15d4c02978a"
uuid = "ffc61752-8dc7-55ee-8c37-f3e9cdd09e70"
version = "1.0.0"
[[OrderedCollections]]
deps = ["Random", "Serialization", "Test"]
git-tree-sha1 = "c4c13474d23c60d20a67b217f1d7f22a40edf8f1"
uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d"
version = "1.1.0"
[[Parameters]]
deps = ["OrderedCollections"]
git-tree-sha1 = "b62b2558efb1eef1fa44e4be5ff58a515c287e38"
uuid = "d96e819e-fc66-5662-9728-84c9c7592b0a"
version = "0.12.0"
[[Pkg]]
deps = ["Dates", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "REPL", "Random", "SHA", "UUIDs"]
uuid = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
[[Printf]]
deps = ["Unicode"]
uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7"
[[REPL]]
deps = ["InteractiveUtils", "Markdown", "Sockets"]
uuid = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb"
[[Random]]
deps = ["Serialization"]
uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
[[SHA]]
uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce"
[[Serialization]]
uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b"
[[Sockets]]
uuid = "6462fe0b-24de-5631-8697-dd941f90decc"
[[TableTraits]]
deps = ["IteratorInterfaceExtensions"]
git-tree-sha1 = "b1ad568ba658d8cbb3b892ed5380a6f3e781a81e"
uuid = "3783bdb8-4a98-5b6b-af9a-565f29a5fe9c"
version = "1.0.0"
[[Tables]]
deps = ["DataAPI", "DataValueInterfaces", "IteratorInterfaceExtensions", "LinearAlgebra", "TableTraits", "Test"]
git-tree-sha1 = "aaed7b3b00248ff6a794375ad6adf30f30ca5591"
uuid = "bd369af6-aec1-5ad0-b16a-f7cc5008161c"
version = "0.2.11"
[[Test]]
deps = ["Distributed", "InteractiveUtils", "Logging", "Random"]
uuid = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
[[UUIDs]]
deps = ["Random", "SHA"]
uuid = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"
[[Unicode]]
uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5"

View File

@ -5,10 +5,12 @@ version = "0.7.0-DEV"
[deps] [deps]
Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240"
LibGit2 = "76f85450-5226-5b5a-8eaa-529ad045b433" LibGit2 = "76f85450-5226-5b5a-8eaa-529ad045b433"
Mustache = "ffc61752-8dc7-55ee-8c37-f3e9cdd09e70" Mustache = "ffc61752-8dc7-55ee-8c37-f3e9cdd09e70"
Parameters = "d96e819e-fc66-5662-9728-84c9c7592b0a" Parameters = "d96e819e-fc66-5662-9728-84c9c7592b0a"
Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
REPL = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb"
UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"
[compat] [compat]

View File

@ -4,9 +4,9 @@
uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
[[DataAPI]] [[DataAPI]]
git-tree-sha1 = "674b67f344687a88310213ddfa8a2b3c76cc4252" git-tree-sha1 = "176e23402d80e7743fc26c19c681bfb11246af32"
uuid = "9a962f9c-6df0-11e9-0e5d-c546b8b5ee8a" uuid = "9a962f9c-6df0-11e9-0e5d-c546b8b5ee8a"
version = "1.1.0" version = "1.3.0"
[[DataValueInterfaces]] [[DataValueInterfaces]]
git-tree-sha1 = "bfc1187b79289637fa0ef6d4436ebdfe6905cbd6" git-tree-sha1 = "bfc1187b79289637fa0ef6d4436ebdfe6905cbd6"
@ -29,9 +29,9 @@ version = "0.8.1"
[[Documenter]] [[Documenter]]
deps = ["Base64", "Dates", "DocStringExtensions", "InteractiveUtils", "JSON", "LibGit2", "Logging", "Markdown", "REPL", "Test", "Unicode"] deps = ["Base64", "Dates", "DocStringExtensions", "InteractiveUtils", "JSON", "LibGit2", "Logging", "Markdown", "REPL", "Test", "Unicode"]
git-tree-sha1 = "646ebc3db49889ffeb4c36f89e5d82c6a26295ff" git-tree-sha1 = "395fa1554c69735802bba37d9e7d9586fd44326c"
uuid = "e30172f5-a6a5-5a46-863b-614d45cd2de4" uuid = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
version = "0.24.7" version = "0.24.11"
[[InteractiveUtils]] [[InteractiveUtils]]
deps = ["Markdown"] deps = ["Markdown"]
@ -71,15 +71,14 @@ uuid = "a63ad114-7e13-5084-954f-fe012c677804"
[[Mustache]] [[Mustache]]
deps = ["Printf", "Tables"] deps = ["Printf", "Tables"]
git-tree-sha1 = "e06eef2abee113c49695f5347668e15d4c02978a" git-tree-sha1 = "2e11fc5de3a01d23482a257e22009ddaab058d9a"
uuid = "ffc61752-8dc7-55ee-8c37-f3e9cdd09e70" uuid = "ffc61752-8dc7-55ee-8c37-f3e9cdd09e70"
version = "1.0.0" version = "1.0.2"
[[OrderedCollections]] [[OrderedCollections]]
deps = ["Random", "Serialization", "Test"] git-tree-sha1 = "12ce190210d278e12644bcadf5b21cbdcf225cd3"
git-tree-sha1 = "c4c13474d23c60d20a67b217f1d7f22a40edf8f1"
uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d"
version = "1.1.0" version = "1.2.0"
[[Parameters]] [[Parameters]]
deps = ["OrderedCollections"] deps = ["OrderedCollections"]
@ -89,16 +88,16 @@ version = "0.12.0"
[[Parsers]] [[Parsers]]
deps = ["Dates", "Test"] deps = ["Dates", "Test"]
git-tree-sha1 = "0c16b3179190d3046c073440d94172cfc3bb0553" git-tree-sha1 = "f8f5d2d4b4b07342e5811d2b6428e45524e241df"
uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0"
version = "0.3.12" version = "1.0.2"
[[Pkg]] [[Pkg]]
deps = ["Dates", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "REPL", "Random", "SHA", "UUIDs"] deps = ["Dates", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "REPL", "Random", "SHA", "UUIDs"]
uuid = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" uuid = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
[[PkgTemplates]] [[PkgTemplates]]
deps = ["Dates", "LibGit2", "Mustache", "Parameters", "Pkg", "UUIDs"] deps = ["Dates", "InteractiveUtils", "LibGit2", "Mustache", "Parameters", "Pkg", "REPL", "UUIDs"]
path = ".." path = ".."
uuid = "14b8a8f1-9102-5b29-a752-f990bacb7fe1" uuid = "14b8a8f1-9102-5b29-a752-f990bacb7fe1"
version = "0.7.0-DEV" version = "0.7.0-DEV"
@ -132,9 +131,9 @@ version = "1.0.0"
[[Tables]] [[Tables]]
deps = ["DataAPI", "DataValueInterfaces", "IteratorInterfaceExtensions", "LinearAlgebra", "TableTraits", "Test"] deps = ["DataAPI", "DataValueInterfaces", "IteratorInterfaceExtensions", "LinearAlgebra", "TableTraits", "Test"]
git-tree-sha1 = "aaed7b3b00248ff6a794375ad6adf30f30ca5591" git-tree-sha1 = "c45dcc27331febabc20d86cb3974ef095257dcf3"
uuid = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" uuid = "bd369af6-aec1-5ad0-b16a-f7cc5008161c"
version = "0.2.11" version = "1.0.4"
[[Test]] [[Test]]
deps = ["Distributed", "InteractiveUtils", "Logging", "Random"] deps = ["Distributed", "InteractiveUtils", "Logging", "Random"]

View File

@ -75,7 +75,7 @@ To understand how they're implemented, let's look at simplified versions of two
### Example: `Documenter` ### Example: `Documenter`
```julia ```julia
@with_kw_noshow struct Documenter <: Plugin @plugin struct Documenter <: Plugin
make_jl::String = default_file("docs", "make.jl") make_jl::String = default_file("docs", "make.jl")
index_md::String = default_file("docs", "src", "index.md") index_md::String = default_file("docs", "src", "index.md")
end end
@ -117,10 +117,11 @@ function hook(p::Documenter, t::Template, pkg_dir::AbstractString)
end end
``` ```
The `@with_kw_noshow` macro defines keyword constructors for us. The `@plugin` macro defines some helpful methods for us.
Inside of our struct definition, we're using [`default_file`](@ref) to refer to files in this repository. Inside of our struct definition, we're using [`default_file`](@ref) to refer to files in this repository.
```@docs ```@docs
@plugin
default_file default_file
``` ```
@ -138,7 +139,11 @@ Badge
``` ```
These two functions, [`gitignore`](@ref) and [`badges`](@ref), are currently the only "special" functions for cross-plugin interactions. These two functions, [`gitignore`](@ref) and [`badges`](@ref), are currently the only "special" functions for cross-plugin interactions.
In other cases, you can still access the [`Template`](@ref)'s plugins to depend on the presence/properties of other plugins, although that's less powerful. In other cases, you can still access the [`Template`](@ref)'s plugins to depend on the presence/properties of other plugins via [`getplugin`](@ref), although that's less powerful.
```@docs
getplugin
```
Third, we implement [`view`](@ref), which is used to fill placeholders in badges and rendered files. Third, we implement [`view`](@ref), which is used to fill placeholders in badges and rendered files.
@ -197,6 +202,7 @@ function posthook(::Git, ::Template, pkg_dir::AbstractString)
end end
``` ```
We didn't use `@plugin` for this one, because there are no fields.
Validation and all three hooks are implemented: Validation and all three hooks are implemented:
- [`validate`](@ref) makes sure that all required Git configuration is present. - [`validate`](@ref) makes sure that all required Git configuration is present.
@ -217,7 +223,7 @@ In general, they just generate one templated file.
To illustrate, let's look at the [`Citation`](@ref) plugin, which creates a `CITATION.bib` file. To illustrate, let's look at the [`Citation`](@ref) plugin, which creates a `CITATION.bib` file.
```julia ```julia
@with_kw_noshow struct Citation <: FilePlugin @plugin struct Citation <: FilePlugin
file::String = default_file("CITATION.bib") file::String = default_file("CITATION.bib")
end end
@ -294,7 +300,7 @@ Of course, we could use a normal [`Plugin`](@ref), but it turns out there's a wa
The plugin implements its own `hook`, but uses `invoke` to avoid duplicating the file creation code: The plugin implements its own `hook`, but uses `invoke` to avoid duplicating the file creation code:
```julia ```julia
@with_kw_noshow struct Tests <: FilePlugin @plugin struct Tests <: FilePlugin
file::String = default_file("runtests.jl") file::String = default_file("runtests.jl")
end end
@ -315,6 +321,20 @@ 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. 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
customizable
input_tips
convert_input
```
## Miscellaneous Tips ## Miscellaneous Tips
### Writing Template Files ### Writing Template Files

View File

@ -41,9 +41,12 @@ One less name to remember!
| :-----------------------------------------: | :---------------------------------: | | :-----------------------------------------: | :---------------------------------: |
| `generate(::Template, pkg::AbstractString)` | `(::Template)(pkg::AbstractString)` | | `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 ## Other Functions

View File

@ -21,6 +21,7 @@ t("MyPkg")
```@docs ```@docs
Template Template
generate
``` ```
## Plugins ## Plugins
@ -40,6 +41,7 @@ Tests
Readme Readme
License License
Git Git
CompatHelper
TagBot TagBot
Secret Secret
``` ```
@ -76,7 +78,6 @@ Documenter
```@docs ```@docs
Develop Develop
CompatHelper
Citation Citation
``` ```

View File

@ -3,8 +3,10 @@ module PkgTemplates
using Base: active_project, contractuser using Base: active_project, contractuser
using Dates: month, today, year using Dates: month, today, year
using InteractiveUtils: subtypes
using LibGit2: LibGit2, GitConfig, GitRemote, GitRepo using LibGit2: LibGit2, GitConfig, GitRemote, GitRepo
using Pkg: Pkg, TOML, PackageSpec using Pkg: Pkg, TOML, PackageSpec
using REPL.TerminalMenus: MultiSelectMenu, RadioMenu, request
using UUIDs: uuid4 using UUIDs: uuid4
using Mustache: render using Mustache: render
@ -25,6 +27,7 @@ export
GitHubActions, GitHubActions,
GitLabCI, GitLabCI,
License, License,
NoDeploy,
ProjectFile, ProjectFile,
Readme, Readme,
Secret, Secret,
@ -44,6 +47,7 @@ abstract type Plugin end
include("template.jl") include("template.jl")
include("plugin.jl") include("plugin.jl")
include("show.jl") include("show.jl")
include("interactive.jl")
include("deprecated.jl") include("deprecated.jl")
# Run some function with a project activated at the given path. # Run some function with a project activated at the given path.

View File

@ -3,3 +3,4 @@
@deprecate interactive_template() Template(; interactive=true) @deprecate interactive_template() Template(; interactive=true)
@deprecate generate_interactive(pkg::AbstractString) Template(; interactive=true)(pkg) @deprecate generate_interactive(pkg::AbstractString) Template(; interactive=true)(pkg)
@deprecate GitHubPages(; kwargs...) Documenter{TravisCI}(; kwargs...) @deprecate GitHubPages(; kwargs...) Documenter{TravisCI}(; kwargs...)
@deprecate GitLabPages(; kwargs...) Documenter{GitLabCI}(; kwargs...)

179
src/interactive.jl Normal file
View File

@ -0,0 +1,179 @@
"""
generate([pkg::AbstractString]) -> Template
Shortcut for `Template(; interactive=true)(pkg)`.
If no package name is supplied, you will be prompted for one.
"""
function generate(pkg::AbstractString=prompt(Template, String, :pkg))
t = Template(; interactive=true)
t(pkg)
return t
end
"""
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(T::Type)
pairs = interactive_pairs(T)
# There must be at least 2 MultiSelectMenu options.
# If there are none, return immediately.
# If there's just one, add a "dummy" option.
isempty(pairs) && return T()
just_one = length(pairs) == 1
just_one && push!(pairs, :None => Nothing)
menu = MultiSelectMenu(
collect(map(pair -> string(first(pair)), pairs));
pagesize=length(pairs),
)
println("$(nameof(T)) keywords to customize:")
customize = sort!(collect(request(menu)))
# If the "None" option was selected, don't customize anything.
just_one && lastindex(pairs) in customize && return T()
kwargs = Dict{Symbol, Any}()
foreach(pairs[customize]) do (name, F)
kwargs[name] = prompt(T, F, name)
end
return T(; kwargs...)
end
struct NotCustomizable end
"""
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, and should be customizable in interactive mode.
For example, for a constructor `Foo(; x::Bool)`, provide `[x => Bool]`.
If `T` has fields which should not be customizable, use `NotCustomizable` as the type.
"""
customizable(::Type) = ()
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(::Type{Vector{T}}) where T = ["comma-delimited", input_tips(T)...]
input_tips(::Type{Nothing}) = String[]
input_tips(::Type{Union{T, Nothing}}) where T = ["'nothing' for nothing", input_tips(T)...]
input_tips(::Type{Secret}) = ["name only"]
input_tips(::Type) = String[]
"""
convert_input(::Type{P}, ::Type{T}, s::AbstractString) -> T
Convert the user input `s` into an instance of `T` for plugin of type `P`.
A default implementation of `T(s)` exists.
"""
convert_input(::Type, T::Type{<:Real}, s::AbstractString) = parse(T, s)
convert_input(::Type, T::Type, s::AbstractString) = T(s)
function convert_input(P::Type, ::Type{Union{T, Nothing}}, s::AbstractString) where T
# This is kind of sketchy because technically, there might be some other input
# whose value we want to instantiate with the string "nothing",
# but I think that would be a pretty rare occurrence.
# If that really happens, they can just override this method.
return s == "nothing" ? nothing : convert_input(P, T, s)
end
function convert_input(::Type, ::Type{Bool}, s::AbstractString)
s = lowercase(s)
return if startswith(s, 't') || startswith(s, 'y')
true
elseif startswith(s, 'f') || startswith(s, 'n')
false
else
throw(ArgumentError("Unrecognized boolean response"))
end
end
function convert_input(P::Type, T::Type{<:Vector}, s::AbstractString)
startswith(s, '[') && endswith(s, ']') && (s = s[2:end-1])
xs = map(strip, split(s, ","))
return map(x -> convert_input(P, eltype(T), x), xs)
end
"""
prompt(::Type{P}, ::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 particular fields of particular types.
"""
prompt(P::Type, T::Type, name::Symbol) = prompt(P, T, Val(name))
# The trailing `nothing` is a hack for `fallback_prompt` to use, ignore it.
function prompt(P::Type, ::Type{T}, ::Val{name}, ::Nothing=nothing) where {T, name}
tips = join([T; input_tips(T); "default=$(repr(defaultkw(P, name)))"], ", ")
default = defaultkw(P, name)
input = Base.prompt(pretty_message("Enter value for '$name' ($tips)"))
input === nothing && throw(InterruptException())
return if isempty(input)
default
else
try
# Working around what appears to be a bug in Julia 1.0:
# #145#issuecomment-623049535
if VERSION < v"1.1" && T isa Union && Nothing <: T
if input == "nothing"
nothing
else
convert_input(P, T.a === Nothing ? T.b : T.a, input)
end
else
convert_input(P, T, input)
end
catch ex
ex isa InterruptException && rethrow()
@warn "Invalid input" ex
prompt(P, T, name)
end
end
end
# Compute all the concrete subtypes of T.
concretes_rec(T::Type) = isabstracttype(T) ? vcat(map(concretes_rec, subtypes(T))...) : Any[T]
concretes(T::Type) = sort!(concretes_rec(T); by=nameof)
# Compute name => type pairs for T's interactive options.
function interactive_pairs(T::Type)
pairs = collect(map(name -> name => fieldtype(T, name), fieldnames(T)))
# Use prepend! here so that users can override field types if they wish.
prepend!(pairs, reverse(customizable(T)))
uniqueby!(first, pairs)
filter!(p -> last(p) !== NotCustomizable, pairs)
sort!(pairs; by=first)
return pairs
end
# unique!(f, xs) added here: https://github.com/JuliaLang/julia/pull/30141
if VERSION >= v"1.1"
const uniqueby! = unique!
else
function uniqueby!(f, xs)
seen = Set()
todelete = Int[]
foreach(enumerate(map(f, xs))) do (i, out)
out in seen && push!(todelete, i)
push!(seen, out)
end
return deleteat!(xs, todelete)
end
end

View File

@ -1,6 +1,82 @@
const TEMPLATES_DIR = normpath(joinpath(@__DIR__, "..", "templates")) const TEMPLATES_DIR = normpath(joinpath(@__DIR__, "..", "templates"))
const DEFAULT_PRIORITY = 1000 const DEFAULT_PRIORITY = 1000
"""
@plugin struct ... end
Define a plugin subtype with keyword constructors and default values.
For details on the general syntax, see
[Parameters.jl](https://mauro3.github.io/Parameters.jl/stable/manual/#Types-with-default-values-and-keyword-constructors-1).
There are a few extra restrictions:
- Before using this macro, you must have imported `@with_kw_noshow`
via `using PkgTemplates: @with_kw_noshow`
- The type must be a subtype of [`Plugin`](@ref) (or one of its abstract subtypes)
- The type cannot be parametric
- All fields must have default values
## Example
```julia
using PkgTemplates: @plugin, @with_kw_noshow, Plugin
@plugin struct MyPlugin <: Plugin
x::String = "hello!"
y::Union{Int, Nothing} = nothing
end
```
## Implementing `@plugin` Manually
If for whatever reason, you are unable to meet the criteria outlined above,
you can manually implement the methods that `@plugin` would have created for you.
This is only mandatory if you want to use your plugin in interactive mode.
### Keyword Constructors
If possible, use `@with_kw_noshow` to create a keyword constructor for your type.
Your type must be capable of being instantiated with no arguments.
### Default Values
If your type's fields have sensible default values, implement `defaultkw` like so:
```julia
using PkgTemplates: PkgTemplates, Plugin
struct MyPlugin <: Plugin
x::String
end
PkgTemplates.defaultkw(::Type{MyPlugin}, ::Val{:x}) = "my default"
```
Remember to add a method to the function belonging to PkgTemplates,
rather than creating your own function that PkgTemplates won't see.
If your plugin's fields have no sane defaults, then you'll need to implement
[`prompt`](@ref) appropriately instead.
"""
macro plugin(ex::Expr)
@assert ex.head === :struct "Expression must be a struct definition"
@assert ex.args[2] isa Expr && ex.args[2].head === :<: "Type must have a supertype"
T = ex.args[2].args[1]
@assert T isa Symbol "@plugin does not work for parametric types"
msg = "Run `using PkgTemplates: @with_kw_noshow` before using this macro"
@assert isdefined(__module__, Symbol("@with_kw_noshow")) msg
block = :(begin @with_kw_noshow $ex end)
foreach(filter(arg -> arg isa Expr, ex.args[3].args)) do field
@assert field.head === :(=) "Field must have a default value"
name = QuoteNode(field.args[1].args[1])
default = field.args[2]
def = :(PkgTemplates.defaultkw(::Type{$T}, ::Val{$name}) = $default)
push!(block.args, def)
end
return esc(block)
end
function Base.:(==)(a::T, b::T) where T <: Plugin function Base.:(==)(a::T, b::T) where T <: Plugin
return all(n -> getfield(a, n) == getfield(b, n), fieldnames(T)) return all(n -> getfield(a, n) == getfield(b, n), fieldnames(T))
end end
@ -240,13 +316,13 @@ function gen_file(file::AbstractString, text::AbstractString)
end end
""" """
render_file(file::AbstractString view::Dict{<:AbstractString}, tags) -> String render_file(file::AbstractString view::Dict{<:AbstractString}, tags=nothing) -> String
Render a template file with the data in `view`. Render a template file with the data in `view`.
`tags` should be a tuple of two strings, which are the opening and closing delimiters, `tags` should be a tuple of two strings, which are the opening and closing delimiters,
or `nothing` to use the default delimiters. or `nothing` to use the default delimiters.
""" """
function render_file(file::AbstractString, view::Dict{<:AbstractString}, tags) function render_file(file::AbstractString, view::Dict{<:AbstractString}, tags=nothing)
return render_text(read(file, String), view, tags) return render_text(read(file, String), view, tags)
end end

View File

@ -44,7 +44,7 @@ $EXTRA_VERSIONS_DOC
If using coverage plugins, don't forget to manually add your API tokens as secrets, If using coverage plugins, don't forget to manually add your API tokens as secrets,
as described [here](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets#creating-encrypted-secrets). as described [here](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets#creating-encrypted-secrets).
""" """
@with_kw_noshow struct GitHubActions <: FilePlugin @plugin struct GitHubActions <: FilePlugin
file::String = default_file("github", "workflows", "ci.yml") file::String = default_file("github", "workflows", "ci.yml")
destination::String = "ci.yml" destination::String = "ci.yml"
linux::Bool = true linux::Bool = true
@ -116,7 +116,7 @@ Integrates your packages with [Travis CI](https://travis-ci.com).
Another code coverage plugin such as [`Codecov`](@ref) must also be included. Another code coverage plugin such as [`Codecov`](@ref) must also be included.
$EXTRA_VERSIONS_DOC $EXTRA_VERSIONS_DOC
""" """
@with_kw_noshow struct TravisCI <: FilePlugin @plugin struct TravisCI <: FilePlugin
file::String = default_file("travis.yml") file::String = default_file("travis.yml")
linux::Bool = true linux::Bool = true
osx::Bool = true osx::Bool = true
@ -188,7 +188,7 @@ via [AppVeyor.jl](https://github.com/JuliaCI/Appveyor.jl).
[`Codecov`](@ref) must also be included. [`Codecov`](@ref) must also be included.
$EXTRA_VERSIONS_DOC $EXTRA_VERSIONS_DOC
""" """
@with_kw_noshow struct AppVeyor <: FilePlugin @plugin struct AppVeyor <: FilePlugin
file::String = default_file("appveyor.yml") file::String = default_file("appveyor.yml")
x86::Bool = false x86::Bool = false
coverage::Bool = true coverage::Bool = true
@ -244,7 +244,7 @@ $EXTRA_VERSIONS_DOC
Code coverage submission from Cirrus CI is not yet supported by Code coverage submission from Cirrus CI is not yet supported by
[Coverage.jl](https://github.com/JuliaCI/Coverage.jl). [Coverage.jl](https://github.com/JuliaCI/Coverage.jl).
""" """
@with_kw_noshow struct CirrusCI <: FilePlugin @plugin struct CirrusCI <: FilePlugin
file::String = default_file("cirrus.yml") file::String = default_file("cirrus.yml")
image::String = "freebsd-12-0-release-amd64" image::String = "freebsd-12-0-release-amd64"
coverage::Bool = true coverage::Bool = true
@ -293,7 +293,7 @@ See [`Documenter`](@ref) for more information.
!!! note !!! note
Nightly Julia is not supported. Nightly Julia is not supported.
""" """
@with_kw_noshow struct GitLabCI <: FilePlugin @plugin struct GitLabCI <: FilePlugin
file::String = default_file("gitlab-ci.yml") file::String = default_file("gitlab-ci.yml")
coverage::Bool = true coverage::Bool = true
# Nightly has no Docker image. # Nightly has no Docker image.
@ -353,7 +353,7 @@ $EXTRA_VERSIONS_DOC
!!! note !!! note
Nightly Julia is not supported. Nightly Julia is not supported.
""" """
@with_kw_noshow struct DroneCI <: FilePlugin @plugin struct DroneCI <: FilePlugin
file::String = default_file("drone.star") file::String = default_file("drone.star")
destination::String = ".drone.star" destination::String = ".drone.star"
amd64::Bool = true amd64::Bool = true
@ -396,6 +396,8 @@ function collect_versions(t::Template, versions::Vector)
return sort(unique(vs)) return sort(unique(vs))
end end
const AllCI = Union{AppVeyor, GitHubActions, TravisCI, CirrusCI, GitLabCI, DroneCI}
""" """
is_ci(::Plugin) -> Bool is_ci(::Plugin) -> Bool
@ -403,6 +405,7 @@ Determine whether or not a plugin is a CI plugin.
If you are adding a CI plugin, you should implement this function and return `true`. If you are adding a CI plugin, you should implement this function and return `true`.
""" """
is_ci(::Plugin) = false is_ci(::Plugin) = false
is_ci(::Union{AppVeyor, GitHubActions, TravisCI, CirrusCI, GitLabCI, DroneCI}) = true is_ci(::AllCI) = true
needs_username(::Union{AppVeyor, GitHubActions, TravisCI, CirrusCI, GitLabCI, DroneCI}) = true needs_username(::AllCI) = true
customizable(::Type{<:AllCI}) = (:extra_versions => Vector{VersionNumber},)

View File

@ -7,7 +7,7 @@ Creates a `CITATION.bib` file for citing package repositories.
- `file::AbstractString`: Template file for `CITATION.bib`. - `file::AbstractString`: Template file for `CITATION.bib`.
- `readme::Bool`: Whether or not to include a section about citing in the README. - `readme::Bool`: Whether or not to include a section about citing in the README.
""" """
@with_kw_noshow struct Citation <: FilePlugin @plugin struct Citation <: FilePlugin
file::String = default_file("CITATION.bib") file::String = default_file("CITATION.bib")
readme::Bool = false readme::Bool = false
end end

View File

@ -13,7 +13,7 @@ Integrates your packages with [CompatHelper](https://github.com/bcbi/CompatHelpe
relative to `.github/workflows`. relative to `.github/workflows`.
- `cron::AbstractString`: Cron expression for the schedule interval. - `cron::AbstractString`: Cron expression for the schedule interval.
""" """
@with_kw_noshow struct CompatHelper <: FilePlugin @plugin struct CompatHelper <: FilePlugin
file::String = default_file("github", "workflows", "CompatHelper.yml") file::String = default_file("github", "workflows", "CompatHelper.yml")
destination::String = "CompatHelper.yml" destination::String = "CompatHelper.yml"
cron::String = "0 0 * * *" cron::String = "0 0 * * *"

View File

@ -9,7 +9,7 @@ Sets up code coverage submission from CI to [Codecov](https://codecov.io).
- `file::Union{AbstractString, Nothing}`: Template file for `.codecov.yml`, - `file::Union{AbstractString, Nothing}`: Template file for `.codecov.yml`,
or `nothing` to create no file. or `nothing` to create no file.
""" """
@with_kw_noshow struct Codecov <: FilePlugin @plugin struct Codecov <: FilePlugin
file::Union{String, Nothing} = nothing file::Union{String, Nothing} = nothing
end end
@ -31,7 +31,7 @@ Sets up code coverage submission from CI to [Coveralls](https://coveralls.io).
- `file::Union{AbstractString, Nothing}`: Template file for `.coveralls.yml`, - `file::Union{AbstractString, Nothing}`: Template file for `.coveralls.yml`,
or `nothing` to create no file. or `nothing` to create no file.
""" """
@with_kw_noshow struct Coveralls <: FilePlugin @plugin struct Coveralls <: FilePlugin
file::Union{String, Nothing} = nothing file::Union{String, Nothing} = nothing
end end

View File

@ -3,11 +3,12 @@ const DOCUMENTER_DEP = PackageSpec(;
uuid="e30172f5-a6a5-5a46-863b-614d45cd2de4", uuid="e30172f5-a6a5-5a46-863b-614d45cd2de4",
) )
const DeployStyle = Union{TravisCI, GitHubActions, GitLabCI, Nothing} struct NoDeploy end
const DeployStyle = Union{TravisCI, GitHubActions, GitLabCI, NoDeploy}
const GitHubPagesStyle = Union{TravisCI, GitHubActions} const GitHubPagesStyle = Union{TravisCI, GitHubActions}
""" """
Documenter{T<:Union{TravisCI, GitLabCI, Nothing}}(; Documenter{T<:Union{TravisCI, GitLabCI, NoDeploy}}(;
make_jl="$(contractuser(default_file("docs", "make.jl")))", make_jl="$(contractuser(default_file("docs", "make.jl")))",
index_md="$(contractuser(default_file("docs", "src", "index.md")))", index_md="$(contractuser(default_file("docs", "src", "index.md")))",
assets=String[], assets=String[],
@ -26,7 +27,7 @@ or `Nothing` to only support local documentation builds.
with the help of [`TravisCI`](@ref). with the help of [`TravisCI`](@ref).
- `GitLabCI`: Deploys documentation to [GitLab Pages](https://pages.gitlab.com) - `GitLabCI`: Deploys documentation to [GitLab Pages](https://pages.gitlab.com)
with the help of [`GitLabCI`](@ref). with the help of [`GitLabCI`](@ref).
- `Nothing` (default): Does not set up documentation deployment. - `NoDeploy` (default): Does not set up documentation deployment.
## Keyword Arguments ## Keyword Arguments
- `make_jl::AbstractString`: Template file for `make.jl`. - `make_jl::AbstractString`: Template file for `make.jl`.
@ -49,7 +50,7 @@ struct Documenter{T<:DeployStyle} <: Plugin
make_jl::String make_jl::String
index_md::String index_md::String
# Can't use @with_kw_noshow due to some weird precompilation issues. # Can't use @plugin because we're implementing our own no-arguments constructor.
function Documenter{T}(; function Documenter{T}(;
assets::Vector{<:AbstractString}=String[], assets::Vector{<:AbstractString}=String[],
makedocs_kwargs::Dict{Symbol}=Dict{Symbol, Any}(), makedocs_kwargs::Dict{Symbol}=Dict{Symbol, Any}(),
@ -61,7 +62,12 @@ struct Documenter{T<:DeployStyle} <: Plugin
end end
end end
Documenter(; kwargs...) = Documenter{Nothing}(; kwargs...) Documenter(; kwargs...) = Documenter{NoDeploy}(; kwargs...)
# We have to define these manually because we didn't use @plugin.
defaultkw(::Type{<:Documenter}, ::Val{:assets}) = String[]
defaultkw(::Type{<:Documenter}, ::Val{:make_jl}) = default_file("docs", "make.jl")
defaultkw(::Type{<:Documenter}, ::Val{:index_md}) = default_file("docs", "src", "index.md")
gitignore(::Documenter) = ["/docs/build/"] gitignore(::Documenter) = ["/docs/build/"]
priority(::Documenter, ::Function) = DEFAULT_PRIORITY - 1 # We need SrcDir to go first. priority(::Documenter, ::Function) = DEFAULT_PRIORITY - 1 # We need SrcDir to go first.
@ -102,7 +108,7 @@ function view(p::Documenter{<:GitHubPagesStyle}, t::Template, pkg::AbstractStrin
return merge(base, Dict("HAS_DEPLOY" => true)) return merge(base, Dict("HAS_DEPLOY" => true))
end end
validate(::Documenter{Nothing}, ::Template) = nothing validate(::Documenter{NoDeploy}, ::Template) = nothing
function validate(::Documenter{T}, t::Template) where T <: DeployStyle function validate(::Documenter{T}, t::Template) where T <: DeployStyle
if !hasplugin(t, T) if !hasplugin(t, T)
name = nameof(T) name = nameof(T)
@ -138,6 +144,18 @@ gitlab_pages_url(t::Template, pkg::AbstractString) = "https://$(t.user).gitlab.i
make_canonical(::Type{<:GitHubPagesStyle}) = github_pages_url make_canonical(::Type{<:GitHubPagesStyle}) = github_pages_url
make_canonical(::Type{GitLabCI}) = gitlab_pages_url make_canonical(::Type{GitLabCI}) = gitlab_pages_url
make_canonical(::Type{Nothing}) = nothing make_canonical(::Type{NoDeploy}) = nothing
needs_username(::Documenter) = true needs_username(::Documenter) = true
function customizable(::Type{<:Documenter})
return (:canonical_url => NotCustomizable, :makedocs_kwargs => NotCustomizable)
end
function interactive(::Type{Documenter})
styles = [Nothing, TravisCI, GitLabCI]
menu = RadioMenu(map(string, styles); pagesize=length(styles))
println("Documenter deploy style:")
idx = request(menu)
return interactive(Documenter{styles[idx]})
end

View File

@ -22,7 +22,7 @@ Creates a Git repository and a `.gitignore` file.
This option requires that the Git CLI is installed, This option requires that the Git CLI is installed,
and for you to have a GPG key associated with your committer identity. and for you to have a GPG key associated with your committer identity.
""" """
@with_kw_noshow struct Git <: Plugin @plugin struct Git <: Plugin
ignore::Vector{String} = String[] ignore::Vector{String} = String[]
name::Union{String, Nothing} = nothing name::Union{String, Nothing} = nothing
email::Union{String, Nothing} = nothing email::Union{String, Nothing} = nothing
@ -34,8 +34,6 @@ end
# Try to make sure that no files are created after we commit. # Try to make sure that no files are created after we commit.
priority(::Git, ::typeof(posthook)) = 5 priority(::Git, ::typeof(posthook)) = 5
Base.:(==)(a::Git, b::Git) = all(map(n -> getfield(a, n) == getfield(b, n), fieldnames(Git)))
function gitignore(p::Git) function gitignore(p::Git)
ignore = copy(p.ignore) ignore = copy(p.ignore)
p.manifest || push!(ignore, "Manifest.toml") p.manifest || push!(ignore, "Manifest.toml")
@ -101,6 +99,7 @@ function posthook(p::Git, ::Template, pkg_dir::AbstractString)
msg = "Files generated by PkgTemplates" msg = "Files generated by PkgTemplates"
v = version_of("PkgTemplates") v = version_of("PkgTemplates")
v === nothing || (msg *= "\n\nPkgTemplates version: $v") v === nothing || (msg *= "\n\nPkgTemplates version: $v")
# TODO: Put the template config in the message too?
commit(p, repo, pkg_dir, msg) commit(p, repo, pkg_dir, msg)
end end
end end

View File

@ -29,9 +29,26 @@ function License(;
return License(path, destination) return License(path, destination)
end end
defaultkw(::Type{License}, ::Val{:path}) = nothing
defaultkw(::Type{License}, ::Val{:name}) = "MIT"
defaultkw(::Type{License}, ::Val{:destination}) = "LICENSE"
source(p::License) = p.path source(p::License) = p.path
destination(p::License) = p.destination destination(p::License) = p.destination
view(::License, t::Template, ::AbstractString) = Dict( view(::License, t::Template, ::AbstractString) = Dict(
"AUTHORS" => join(t.authors, ", "), "AUTHORS" => join(t.authors, ", "),
"YEAR" => year(today()), "YEAR" => year(today()),
) )
function prompt(::Type{License}, ::Type, ::Val{:name})
options = readdir(default_file("licenses"))
# Move MIT to the top.
deleteat!(options, findfirst(==("MIT"), options))
pushfirst!(options, "MIT")
menu = RadioMenu(options; pagesize=length(options))
println("Select a license:")
idx = request(menu)
return options[idx]
end
customizable(::Type{License}) = (:name => String,)

View File

@ -6,7 +6,7 @@ Creates a `Project.toml`.
## Keyword Arguments ## Keyword Arguments
- `version::VersionNumber`: The initial version of created packages. - `version::VersionNumber`: The initial version of created packages.
""" """
@with_kw_noshow struct ProjectFile <: Plugin @plugin struct ProjectFile <: Plugin
version::VersionNumber = v"0.1.0" version::VersionNumber = v"0.1.0"
end end

View File

@ -13,7 +13,7 @@ Creates a `README` file that contains badges for other included plugins.
For example, values of `"README"` or `"README.rst"` might be desired. For example, values of `"README"` or `"README.rst"` might be desired.
- `inline_badges::Bool`: Whether or not to put the badges on the same line as the package name. - `inline_badges::Bool`: Whether or not to put the badges on the same line as the package name.
""" """
@with_kw_noshow struct Readme <: FilePlugin @plugin struct Readme <: FilePlugin
file::String = default_file("README.md") file::String = default_file("README.md")
destination::String = "README.md" destination::String = "README.md"
inline_badges::Bool = false inline_badges::Bool = false

View File

@ -6,7 +6,7 @@ Creates a module entrypoint.
## Keyword Arguments ## Keyword Arguments
- `file::AbstractString`: Template file for `src/<module>.jl`. - `file::AbstractString`: Template file for `src/<module>.jl`.
""" """
@with_kw_noshow mutable struct SrcDir <: FilePlugin @plugin mutable struct SrcDir <: FilePlugin
file::String = default_file("src", "module.jl") file::String = default_file("src", "module.jl")
destination::String = "" destination::String = ""
end end

View File

@ -34,7 +34,7 @@ Adds GitHub release support via [TagBot](https://github.com/JuliaRegistries/TagB
- `dispatch::Bool`: Whether or not to enable the `dispatch` option. - `dispatch::Bool`: Whether or not to enable the `dispatch` option.
- `dispatch_delay::Int`: Number of minutes to delay for dispatch events. - `dispatch_delay::Int`: Number of minutes to delay for dispatch events.
""" """
@with_kw_noshow struct TagBot <: FilePlugin @plugin struct TagBot <: FilePlugin
file::String = default_file("github", "workflows", "TagBot.yml") file::String = default_file("github", "workflows", "TagBot.yml")
destination::String = "TagBot.yml" destination::String = "TagBot.yml"
cron::String = "0 0 * * *" cron::String = "0 0 * * *"

View File

@ -16,7 +16,7 @@ Sets up testing for packages.
Managing test dependencies with `test/Project.toml` is only supported Managing test dependencies with `test/Project.toml` is only supported
in Julia 1.2 and later. in Julia 1.2 and later.
""" """
@with_kw_noshow struct Tests <: FilePlugin @plugin struct Tests <: FilePlugin
file::String = default_file("test", "runtests.jl") file::String = default_file("test", "runtests.jl")
project::Bool = false project::Bool = false
end end

View File

@ -16,7 +16,7 @@ end
function Base.show(io::IO, ::MIME"text/plain", p::T) where T <: Plugin function Base.show(io::IO, ::MIME"text/plain", p::T) where T <: Plugin
indent = get(io, :indent, 0) indent = get(io, :indent, 0)
print(io, repeat(' ', indent), T) print(io, repeat(' ', indent), nameof(T))
ns = fieldnames(T) ns = fieldnames(T)
isempty(ns) || print(io, ":") isempty(ns) || print(io, ":")
foreach(ns) do n foreach(ns) do n

View File

@ -42,10 +42,17 @@ A configuration used to generate packages.
### Template Plugins ### Template Plugins
- `plugins::Vector{<:Plugin}=Plugin[]`: A list of [`Plugin`](@ref)s used by the template. - `plugins::Vector{<:Plugin}=Plugin[]`: A list of [`Plugin`](@ref)s used by the template.
The default plugins are [`ProjectFile`](@ref), [`SrcDir`](@ref), [`Tests`](@ref), The default plugins are [`ProjectFile`](@ref), [`SrcDir`](@ref), [`Tests`](@ref),
[`Readme`](@ref), [`License`](@ref), and [`Git`](@ref). [`Readme`](@ref), [`License`](@ref), [`Git`](@ref), [`CompatHelper`](@ref), and
[`TagBot`](@ref).
To disable a default plugin, pass in the negated type: `!PluginType`. To disable a default plugin, pass in the negated type: `!PluginType`.
To override a default plugin instead of disabling it, pass in your own instance. To override a default plugin instead of disabling it, pass in your own instance.
### 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`.
See also the similar [`generate`](@ref) function.
--- ---
To create a package from a `Template`, use the following syntax: To create a package from a `Template`, use the following syntax:
@ -65,9 +72,9 @@ struct Template
user::String user::String
end end
Template(; kwargs...) = Template(Val(false); kwargs...) Template(; interactive::Bool=false, kwargs...) = Template(Val(interactive); kwargs...)
Template(::Val{true}; kwargs...) = interactive(Template; kwargs...)
# Non-interactive constructor.
function Template(::Val{false}; kwargs...) function Template(::Val{false}; kwargs...)
kwargs = Dict(kwargs) kwargs = Dict(kwargs)
@ -144,7 +151,11 @@ end
hasplugin(t::Template, f::Function) = any(f, t.plugins) hasplugin(t::Template, f::Function) = any(f, t.plugins)
hasplugin(t::Template, ::Type{T}) where T <: Plugin = hasplugin(t, p -> p isa T) hasplugin(t::Template, ::Type{T}) where T <: Plugin = hasplugin(t, p -> p isa T)
# Get a plugin by type. """
getplugin(t::Template, ::Type{T<:Plugin}) -> Union{T, Nothing}
Get the plugin of type `T` from the template `t`, if it's present.
"""
function getplugin(t::Template, ::Type{T}) where T <: Plugin function getplugin(t::Template, ::Type{T}) where T <: Plugin
i = findfirst(p -> p isa T, t.plugins) i = findfirst(p -> p isa T, t.plugins)
return i === nothing ? nothing : t.plugins[i] return i === nothing ? nothing : t.plugins[i]
@ -156,8 +167,87 @@ getkw!(kwargs, k) = pop!(kwargs, k, defaultkw(Template, k))
# Default Template keyword values. # Default Template keyword values.
defaultkw(::Type{T}, s::Symbol) where T = defaultkw(T, Val(s)) defaultkw(::Type{T}, s::Symbol) where T = defaultkw(T, Val(s))
defaultkw(::Type{Template}, ::Val{:authors}) = default_authors() defaultkw(::Type{Template}, ::Val{:authors}) = default_authors()
defaultkw(::Type{Template}, ::Val{:dir}) = Pkg.devdir() defaultkw(::Type{Template}, ::Val{:dir}) = contractuser(Pkg.devdir())
defaultkw(::Type{Template}, ::Val{:host}) = "github.com" defaultkw(::Type{Template}, ::Val{:host}) = "github.com"
defaultkw(::Type{Template}, ::Val{:julia}) = default_version() defaultkw(::Type{Template}, ::Val{:julia}) = default_version()
defaultkw(::Type{Template}, ::Val{:plugins}) = Plugin[] defaultkw(::Type{Template}, ::Val{:plugins}) = Plugin[]
defaultkw(::Type{Template}, ::Val{:user}) = default_user() defaultkw(::Type{Template}, ::Val{:user}) = default_user()
function interactive(::Type{Template}; kwargs...)
# If the user supplied any keywords themselves, don't prompt for them.
kwargs = Dict{Symbol, Any}(kwargs)
options = [:user, :authors, :dir, :host, :julia, :plugins]
customizable = setdiff(options, keys(kwargs))
# Make sure we don't try to show a menu with < 2 options.
isempty(customizable) && return Template(; kwargs...)
just_one = length(customizable) == 1
just_one && push(customizable, "None")
return try
println("Template keywords to customize:")
menu = MultiSelectMenu(map(string, customizable); pagesize=length(customizable))
customize = customizable[sort!(collect(request(menu)))]
just_one && lastindex(customizable) in customize && return Template(; kwargs...)
# Prompt for each keyword.
foreach(customize) do k
kwargs[k] = prompt(Template, fieldtype(Template, k), k)
end
Template(; kwargs...)
catch e
e isa InterruptException || rethrow()
println()
@info "Cancelled"
nothing
end
end
function prompt(::Type{Template}, ::Type, ::Val{:host})
hosts = ["github.com", "gitlab.com", "bitbucket.org", "Other"]
menu = RadioMenu(hosts; pagesize=length(hosts))
println("Select Git repository hosting service:")
idx = request(menu)
return if idx == lastindex(hosts)
fallback_prompt(String, :host)
else
hosts[idx]
end
end
function prompt(::Type{Template}, ::Type, ::Val{:julia})
versions = map(format_version, VersionNumber.(1, 0:VERSION.minor))
push!(versions, "Other")
menu = RadioMenu(map(string, versions); pagesize=length(versions))
println("Select minimum Julia version:")
idx = request(menu)
return if idx == lastindex(versions)
fallback_prompt(VersionNumber, :julia)
else
VersionNumber(versions[idx])
end
end
const CR = "\r"
const DOWN = "\eOB"
function prompt(::Type{Template}, ::Type, ::Val{:plugins})
defaults = map(typeof, default_plugins())
ndefaults = length(defaults)
# Put the defaults first.
options = unique!([defaults; concretes(Plugin)])
menu = MultiSelectMenu(map(T -> string(nameof(T)), options); pagesize=length(options))
println("Select plugins:")
# Pre-select the default plugins and move the cursor to the first non-default.
# To make this better, we need julia#30043.
print(stdin.buffer, (CR * DOWN)^ndefaults)
types = sort!(collect(request(menu)))
plugins = Vector{Any}(map(interactive, options[types]))
# Find any defaults that were disabled.
foreach(i -> i in types || push!(plugins, !defaults[i]), 1:ndefaults)
return plugins
end
# Call the default prompt method even if a specialized one exists.
fallback_prompt(T::Type, name::Symbol) = prompt(Template, T, Val(name), nothing)

View File

@ -1,3 +1,5 @@
@info "Running Git tests"
@testset "Git repositories" begin @testset "Git repositories" begin
@testset "Does not create Git repo" begin @testset "Does not create Git repo" begin
t = tpl(; plugins=[!Git]) t = tpl(; plugins=[!Git])

203
test/interactive.jl Normal file
View File

@ -0,0 +1,203 @@
@info "Running interactive tests"
using PkgTemplates: @with_kw_noshow
const CR = "\r"
const LF = "\n"
const UP = "\eOA"
const DOWN = "\eOB"
const ALL = "a"
const NONE = "n"
const DONE = "d"
# Because the plugin selection dialog prints directly to stdin in the same way
# as we do here, and our input prints happen first, we're going to need to insert
# the plugin selection prints ourselves, and then "undo" the extra ones at the end
# by consuming whatever is left in stdin.
const NDEFAULTS = length(PT.default_plugins())
const SELECT_DEFAULTS = (CR * DOWN)^NDEFAULTS
struct FromString
s::String
end
@testset "Interactive mode" begin
@testset "Input conversion" begin
generic(T, x) = PT.convert_input(PT.Plugin, T, x)
@test generic(String, "foo") == "foo"
@test generic(Float64, "1.23") == 1.23
@test generic(Int, "01") == 1
@test generic(Bool, "yes") === true
@test generic(Bool, "True") === true
@test generic(Bool, "No") === false
@test generic(Bool, "false") === false
@test generic(Vector{Int}, "1, 2, 3") == [1, 2, 3]
@test generic(Vector{String}, "a, b,c") == ["a", "b", "c"]
@test generic(FromString, "hello") == FromString("hello")
if VERSION < v"1.1"
@test_broken generic(Union{String, Nothing}, "nothing") === nothing
else
@test generic(Union{String, Nothing}, "nothing") === nothing
end
@test_throws ArgumentError generic(Int, "hello")
@test_throws ArgumentError generic(Float64, "hello")
@test_throws ArgumentError generic(Bool, "hello")
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}) ==
["'nothing' for nothing", "comma-delimited"]
@test PT.input_tips(Union{String, Nothing}) == ["'nothing' for nothing"]
@test PT.input_tips(Union{Vector{Secret}, Nothing}) ==
["'nothing' for nothing", "comma-delimited", "name only"]
end
@testset "Interactive name/type pair collection" begin
name = gensym()
@eval begin
PT.@plugin struct $name <: PT.Plugin
x::Int = 0
y::String = ""
end
@test PT.interactive_pairs($name) == [:x => Int, :y => String]
PT.customizable(::Type{$name}) = (:x => PT.NotCustomizable, :y => Float64, :z => Int)
@test PT.interactive_pairs($name) == [:y => Float64, :z => Int]
end
end
@testset "Simulated inputs" begin
@testset "Default template" begin
print(
stdin.buffer,
CR, # Select user
DONE, # Finish menu
USER, LF, # Enter user
)
@test Template(; interactive=true) == Template(; user=USER)
end
@testset "Custom options, accept defaults" begin
print(
stdin.buffer,
ALL, DONE, # Customize all fields
"user", LF, # Enter user (don't assume we have default for this one).
LF, # Enter authors
LF, # Enter dir
CR, # Enter host
CR, # Enter julia
SELECT_DEFAULTS, # Pre-select default plugins
DONE, # Select no additional plugins
DONE^NDEFAULTS, # Don't customize plugins
)
@test Template(; interactive=true) == Template(; user="user")
readavailable(stdin.buffer)
end
@testset "Custom options, custom values" begin
nversions = VERSION.minor + 1
print(
stdin.buffer,
ALL, DONE, # Customize all fields
"user", LF, # Enter user
"a, b", LF, # Enter authors
"~", LF, # Enter dir
DOWN^3, CR, # Choose "Other" for host
"x.com", LF, # Enter host
DOWN^nversions, CR, # Choose "Other" for julia
"0.7", LF, # Enter Julia version
SELECT_DEFAULTS, # Pre-select default plugins
DONE, # Select no additional plugins
DONE^NDEFAULTS, # Don't customize plugins
)
@test Template(; interactive=true) == Template(;
user="user",
authors=["a", "b"],
dir="~",
host="x.com",
julia=v"0.7",
)
readavailable(stdin.buffer)
end
@testset "Disabling default plugins" begin
print(
stdin.buffer,
CR, DOWN^5, CR, DONE, # Customize user and plugins
USER, LF, # Enter user
SELECT_DEFAULTS, # Pre-select default plugins
UP, CR, UP^2, CR, DONE, # Disable TagBot and Readme
DONE^(NDEFAULTS - 2), # Don't customize plugins
)
@test Template(; interactive=true) == Template(;
user=USER,
plugins=[!Readme, !TagBot],
)
readavailable(stdin.buffer)
end
@testset "Plugins" begin
print(
stdin.buffer,
ALL, DONE, # Customize all fields
"true", LF, # Enable ARM64
"no", LF, # Disable coverage
"1.1,v1.2", LF, # Enter extra versions
"x.txt", LF, # Enter file
"Yes", LF, # Enable Linux
"false", LF, # Disable OSX
"TRUE", LF, # Enable Windows
"YES", LF, # Enable x64
"NO", LF, # Disable x86
)
@test PT.interactive(TravisCI) == TravisCI(;
arm64=true,
coverage=false,
extra_versions=[v"1.1", v"1.2"],
file="x.txt",
linux=true,
osx=false,
windows=true,
x64=true,
x86=false,
)
print(
stdin.buffer,
DOWN^2, CR, # Select GitLabCI
DOWN, CR, DONE, # Customize index_md
"x.txt", LF, # Enter index file
)
@test PT.interactive(Documenter) == Documenter{GitLabCI}(; index_md="x.txt")
print(
stdin.buffer,
CR, DOWN, CR, DONE, # Customize name and destination
"COPYING", LF, # Enter destination
CR, # Choose MIT for name (it's at the top)
)
@test PT.interactive(License) == License(; destination="COPYING", name="MIT")
end
@testset "Union{T, Nothing} weirdness" begin
print(
stdin.buffer,
DOWN, CR, DONE, # Customize changelog
"hello", LF, # Enter changelog
)
@test PT.interactive(TagBot) == TagBot(; changelog="hello")
print(
stdin.buffer,
DOWN, CR, DONE, # Customize changelog
"nothing", LF, # Set to null
)
@test PT.interactive(TagBot) == TagBot(; changelog=nothing)
end
println()
end
end

View File

@ -1,5 +1,7 @@
# Don't move this line from the top, please. {{X}} {{Y}} {{Z}} # Don't move this line from the top, please. {{X}} {{Y}} {{Z}}
@info "Running plugin tests"
struct FileTest <: PT.FilePlugin struct FileTest <: PT.FilePlugin
a::String a::String
b::Bool b::Bool

View File

@ -1,3 +1,5 @@
@info "Running reference tests"
const PROMPT = get(ENV, "PT_INTERACTIVE", "false") == "true" || !haskey(ENV, "CI") const PROMPT = get(ENV, "PT_INTERACTIVE", "false") == "true" || !haskey(ENV, "CI")
const STATIC_FILE = joinpath(@__DIR__, "fixtures", "static.txt") const STATIC_FILE = joinpath(@__DIR__, "fixtures", "static.txt")
const STATIC_DOCUMENTER = [ const STATIC_DOCUMENTER = [

View File

@ -4,7 +4,7 @@ using Base.Filesystem: path_separator
using LibGit2: LibGit2, GitCommit, GitRemote, GitRepo using LibGit2: LibGit2, GitCommit, GitRemote, GitRepo
using Pkg: Pkg, PackageSpec, TOML using Pkg: Pkg, PackageSpec, TOML
using Random: Random, randstring using Random: Random, randstring
using Test: @test, @testset, @test_logs, @test_throws using Test: @test, @testset, @test_broken, @test_logs, @test_throws
using DeepDiffs: deepdiff using DeepDiffs: deepdiff
using SimpleMock: mock using SimpleMock: mock
@ -54,6 +54,7 @@ mktempdir() do dir
include("template.jl") include("template.jl")
include("plugin.jl") include("plugin.jl")
include("show.jl") include("show.jl")
include("interactive.jl")
if PT.git_is_installed() if PT.git_is_installed()
include("git.jl") include("git.jl")
@ -64,7 +65,7 @@ mktempdir() do dir
if VERSION.major == 1 && VERSION.minor == 4 if VERSION.major == 1 && VERSION.minor == 4
include("reference.jl") include("reference.jl")
else else
@info "Skipping reference tests" julia=VERSION @info "Skipping reference tests" VERSION
end end
else else
@info "Git is not installed, skipping Git and reference tests" @info "Git is not installed, skipping Git and reference tests"

View File

@ -1,3 +1,5 @@
@info "Running show tests"
const TEMPLATES_DIR = contractuser(PT.TEMPLATES_DIR) const TEMPLATES_DIR = contractuser(PT.TEMPLATES_DIR)
const LICENSES_DIR = joinpath(TEMPLATES_DIR, "licenses") const LICENSES_DIR = joinpath(TEMPLATES_DIR, "licenses")

View File

@ -1,3 +1,5 @@
@info "Running template tests"
@testset "Template" begin @testset "Template" begin
@testset "Template constructor" begin @testset "Template constructor" begin
@testset "user" begin @testset "user" begin