Add prehooks/posthooks for more fine-grained plugin control

This commit is contained in:
Chris de Graaf 2019-09-20 00:21:06 +07:00
parent 12fcc121fa
commit 146c1bdbe5
No known key found for this signature in database
GPG Key ID: 150FFDD9B0073C7B
11 changed files with 253 additions and 98 deletions

View File

@ -61,9 +61,9 @@ version = "1.1.0"
[[Parameters]]
deps = ["OrderedCollections"]
git-tree-sha1 = "1dfd7cd50a8eb06ef693a4c2bbe945943cd000c5"
git-tree-sha1 = "b62b2558efb1eef1fa44e4be5ff58a515c287e38"
uuid = "d96e819e-fc66-5662-9728-84c9c7592b0a"
version = "0.11.0"
version = "0.12.0"
[[Pkg]]
deps = ["Dates", "LibGit2", "Markdown", "Printf", "REPL", "Random", "SHA", "UUIDs"]

View File

@ -11,6 +11,7 @@ Mustache = "ffc61752-8dc7-55ee-8c37-f3e9cdd09e70"
Parameters = "d96e819e-fc66-5662-9728-84c9c7592b0a"
Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
REPL = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb"
UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"
[compat]
julia = "1"

View File

@ -50,7 +50,7 @@ view(p::Documenter, t::Template, pkg::AbstractString) = Dict(
"USER" => t.user,
)
function gen_plugin(p::Documenter, t::Template, pkg_dir::AbstractString)
function hook(p::Documenter, t::Template, pkg_dir::AbstractString)
pkg = basename(pkg_dir)
docs_dir = joinpath(pkg_dir, "docs")
@ -91,10 +91,10 @@ Third, we implement [`view`](@ref), which is used to fill placeholders in badges
view
```
Finally, we implement [`gen_plugin`](@ref), which is the real workhorse for the plugin.
Finally, we implement [`hook`](@ref), which is the real workhorse for the plugin.
```@docs
gen_plugin
hook
```
Inside of this function, we call a few more functions, which help us with text templating.
@ -167,7 +167,7 @@ Finally, we implement [`view`](@ref) to fill in the placeholders that we saw in
## Doing Extra Work With `BasicPlugin`s
Notice that we didn't have to implement [`gen_plugin`](@ref) for our plugin.
Notice that we didn't have to implement [`hook`](@ref) for our plugin.
It's implemented for all [`BasicPlugin`](@ref)s, like so:
```julia
@ -175,7 +175,7 @@ function render_plugin(p::BasicPlugin, t::Template, pkg::AbstractString)
return render_file(source(p), combined_view(p, t, pkg), tags(p))
end
function gen_plugin(p::BasicPlugin, t::Template, pkg_dir::AbstractString)
function hook(p::BasicPlugin, t::Template, pkg_dir::AbstractString)
source(p) === nothing && return
pkg = basename(pkg_dir)
path = joinpath(pkg_dir, destination(p))
@ -191,7 +191,7 @@ It creates `runtests.jl`, but it also modifies the `Project.toml` to include the
Of course, we could use a normal [`Plugin`](@ref), but it turns out there's a way to avoid that while still getting the extra capbilities that we want.
The plugin implements its own `gen_plugin`, 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
@with_kw_noshow struct Tests <: BasicPlugin
@ -202,9 +202,9 @@ source(p::Tests) = p.file
destination(::Tests) = joinpath("test", "runtests.jl")
view(::Tests, ::Template, pkg::AbstractString) = Dict("PKG" => pkg)
function gen_plugin(p::Tests, t::Template, pkg_dir::AbstractString)
function hook(p::Tests, t::Template, pkg_dir::AbstractString)
# Do the normal BasicPlugin behaviour to create the test script.
invoke(gen_plugin, Tuple{BasicPlugin, Template, AbstractString}, p, t, pkg_dir)
invoke(hook, Tuple{BasicPlugin, Template, AbstractString}, p, t, pkg_dir)
# Do some other work.
add_test_dependency()
end

View File

@ -28,10 +28,12 @@ These plugins are included by default.
They can be overridden by supplying another value via the `plugins` keyword, or disabled by supplying the type via the `disable_defaults` keyword.
```@docs
Gitignore
License
Readme
ProjectFile
SrcDir
Tests
Readme
License
Git
```
### Continuous Integration (CI)
@ -63,6 +65,7 @@ Documenter
### Miscellaneous
```@docs
Develop
Citation
```

View File

@ -5,9 +5,10 @@ using Base.Filesystem: contractuser
using Dates: month, today, year
using InteractiveUtils: subtypes
using LibGit2: LibGit2, GitRemote
using LibGit2: LibGit2, GitRemote, GitRepo
using Pkg: Pkg, TOML, PackageSpec
using REPL.TerminalMenus: MultiSelectMenu, RadioMenu, request
using UUIDs: uuid4
using Mustache: render
using Parameters: @with_kw_noshow
@ -19,11 +20,14 @@ export
Citation,
Codecov,
Coveralls,
Develop,
Documenter,
Gitignore,
Git,
GitLabCI,
License,
ProjectFile,
Readme,
SrcDir,
Tests,
TravisCI
@ -36,7 +40,6 @@ When implementing a new plugin, subtype this type to have full control over its
abstract type Plugin end
include("template.jl")
include("generate.jl")
include("plugin.jl")
include("interactive.jl")

View File

@ -19,7 +19,7 @@ Return the view to be passed to the text templating engine for this plugin.
`pkg` is the name of the package being generated.
For [`BasicPlugin`](@ref)s, this is used for both the plugin badges (see [`badges`](@ref)) and the template file (see [`source`](@ref)).
For other [`Plugin`](@ref)s, it is used only for badges, but you can always call it yourself as part of your [`gen_plugin`](@ref) implementation.
For other [`Plugin`](@ref)s, it is used only for badges, but you can always call it yourself as part of your [`hook`](@ref) implementation.
By default, an empty `Dict` is returned.
"""
@ -124,19 +124,44 @@ function badges(p::Plugin, t::Template, pkg::AbstractString)
end
"""
gen_plugin(::Plugin, ::Template, pkg::AbstractString)
prehook(::Plugin, ::Template, pkg_dir::AbstractString)
Do some work associated with a plugin **before** any files are generated.
At this point, `pkg_dir` is an empty directory that will eventually contain the package.
"""
prehook(::Plugin, ::Template, ::AbstractString) = nothing
function prehook(p::T, ::Template, ::AbstractString) where T <: BasicPlugin
src = source(p)
src === nothing && return
isfile(src) || throw(ArgumentError("$(nameof(T)): The file $src does not exist"))
end
"""
posthook(::Plugin, ::Template, pkg_dir::AbstractString)
Do some work associated with a plugin **after** after files have been generated.
"""
posthook(::Plugin, ::Template, ::AbstractString) = nothing
"""
hook(::Plugin, ::Template, pkg_dir::AbstractString)
Perform any work associated with a plugin.
`pkg` is the name of the package being generated.
`pkg_dir` is the directory in which the package is being generated (so `basename(pkg_dir)` is the package name).
For [`Plugin`](@ref)s that are not [`BasicPlugin`](@ref)s, this is the only function that really needs to be implemented.
If you want your plugin to do anything at all during package generation, you should implement it here.
If you want your plugin to do something during the main phase of package generation, you should implement it here.
You should **not** implement this function for `BasicPlugin`s.
See also: [`prehook`](@ref) and [`posthook`](@ref).
!!! note
You usually shouldn't implement this function for [`BasicPlugin`](@ref)s.
If you do, it should probably `invoke` the generic method (otherwise, there's no reason to subtype `BasicPlugin`).
"""
gen_plugin(::Plugin, ::Template, ::AbstractString) = nothing
hook(::Plugin, ::Template, ::AbstractString) = nothing
function gen_plugin(p::BasicPlugin, t::Template, pkg_dir::AbstractString)
function hook(p::BasicPlugin, t::Template, pkg_dir::AbstractString)
source(p) === nothing && return
pkg = basename(pkg_dir)
path = joinpath(pkg_dir, destination(p))
@ -184,4 +209,5 @@ include(joinpath("plugins", "defaults.jl"))
include(joinpath("plugins", "coverage.jl"))
include(joinpath("plugins", "ci.jl"))
include(joinpath("plugins", "citation.jl"))
include(joinpath("plugins", "develop.jl"))
include(joinpath("plugins", "documenter.jl"))

View File

@ -1,18 +1,5 @@
const TEST_UUID = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
const TEST_DEP = PackageSpec(; name="Test", uuid=TEST_UUID)
const LICENSES = Dict(
"MIT" => "MIT \"Expat\" License",
"BSD2" => "Simplified \"2-clause\" BSD License",
"BSD3" => "Modified \"3-clause\" BSD License",
"ISC" => "Internet Systems Consortium License",
"ASL" => "Apache License, Version 2.0",
"MPL" => "Mozilla Public License, Version 2.0",
"GPL-2.0+" => "GNU Public License, Version 2.0+",
"GPL-3.0+" => "GNU Public License, Version 3.0+",
"LGPL-2.1+" => "Lesser GNU Public License, Version 2.1+",
"LGPL-3.0+" => "Lesser GNU Public License, Version 3.0+",
"EUPL-1.2+" => "European Union Public Licence, Version 1.2+",
)
badge_order() = [
Documenter{GitLabCI},
@ -25,6 +12,67 @@ badge_order() = [
Coveralls,
]
"""
ProjectFile()
Creates a `Project.toml`.
"""
struct ProjectFile <: Plugin end
# Create Project.toml in the prehook because other hooks might depend on it.
function prehook(::ProjectFile, t::Template, pkg_dir::AbstractString)
toml = Dict(
"name" => basename(pkg_dir),
"uuid" => uuid4(),
"authors" => t.authors,
"compat" => Dict("julia" => compat_version(t.julia_version)),
)
open(io -> TOML.print(io, toml), joinpath(pkg_dir, "Project.toml"), "w")
end
"""
compat_version(v::VersionNumber) -> String
Format a `VersionNumber` to exclude trailing zero components.
"""
function compat_version(v::VersionNumber)
return if v.patch == 0 && v.minor == 0
"$(v.major)"
elseif v.patch == 0
"$(v.major).$(v.minor)"
else
"$(v.major).$(v.minor).$(v.patch)"
end
end
"""
SrcDir(; file="$(contractuser(default_file("src", "module.jl")))")
Creates a module entrypoint.
"""
@with_kw_noshow mutable struct SrcDir <: BasicPlugin
file::String = default_file("src", "module.jl")
destination::String = joinpath("src", "<module>.jl")
end
# Don't display the destination field.
function Base.show(io::IO, ::MIME"text/plain", p::SrcDir)
indent = get(io, :indent, 0)
print(io, repeat(' ', indent), "SrcDir:")
print(io, "\n", repeat(' ', indent + 2), "file: ", show_field(p.file))
end
source(p::SrcDir) = p.file
destination(p::SrcDir) = p.destination
view(::SrcDir, ::Template, pkg::AbstractString) = Dict("PKG" => pkg)
# Update the destination now that we know the package name.
# Kind of hacky, but oh well.
function prehook(p::SrcDir, t::Template, pkg_dir::AbstractString)
invoke(prehook, Tuple{BasicPlugin, Template, AbstractString}, p, t, pkg_dir)
p.destination = joinpath("src", basename(pkg_dir) * ".jl")
end
"""
Readme(;
file="$(contractuser(default_file("README.md")))",
@ -113,32 +161,88 @@ view(::License, t::Template, ::AbstractString) = Dict(
)
"""
Gitignore(; ds_store=true, dev=true)
Git(; ignore=String[], ssh=false, manifest=false, gpgsign=false)
Creates a `.gitignore` file.
Creates a Git repository and a `.gitignore` file.
## Keyword Arguments
- `ds_store::Bool`: Whether or not to ignore MacOS's `.DS_Store` files.
- `dev::Bool`: Whether or not to ignore the directory of locally-developed packages.
- `ignore::Vector{<:AbstractString}`: Patterns to add to the `.gitignore`.
See also: [`gitignore`](@ref).
- `ssh::Bool`: Whether or not to use SSH for the remote.
If left unset, HTTPS is used.
- `manifest::Bool`: Whether or not to commit `Manifest.toml`.
- `gpgsign::Bool`: Whether or not to sign commits with your GPG key.
This option requires that the Git CLI is installed.
"""
@with_kw_noshow struct Gitignore <: Plugin
ds_store::Bool = true
dev::Bool = true
@with_kw_noshow struct Git <: Plugin
ignore::Vector{String} = []
ssh::Bool = false
manifest::Bool = false
gpgsign::Bool = false
end
function render_plugin(p::Gitignore, t::Template)
init = String[]
p.ds_store && push!(init, ".DS_Store")
p.dev && push!(init, "/dev/")
entries = mapreduce(gitignore, append!, values(t.plugins); init=init)
gitignore(p::Git) = p.ignore
# Set up the Git repository.
function prehook(p::Git, t::Template, pkg_dir::AbstractString)
if p.gpgsign && try run(pipeline(`git --version`; stdout=devnull)); false catch; true end
throw(ArgumentError("Git: gpgsign is set but the Git CLI is not installed"))
end
LibGit2.with(LibGit2.init(pkg_dir)) do repo
commit(p, repo, pkg_dir, "Initial commit")
pkg = basename(pkg_dir)
url = if p.ssh
"git@$(t.host):$(t.user)/$pkg.jl.git"
else
"https://$(t.host)/$(t.user)/$pkg.jl"
end
LibGit2.with(GitRemote(repo, "origin", url)) do remote
# TODO: `git pull` still requires some Git branch config.
LibGit2.add_push!(repo, remote, "refs/heads/master")
end
end
end
# Create the .gitignore.
function hook(p::Git, t::Template, pkg_dir::AbstractString)
gen_file(joinpath(pkg_dir, ".gitignore"), render_plugin(p, t))
end
# Commit the files
function posthook(p::Git, t::Template, pkg_dir::AbstractString)
# Ensure that the manifest exists if it's going to be committed.
manifest = joinpath(pkg_dir, "Manifest.toml")
if p.manifest && !isfile(manifest)
touch(manifest)
with_project(Pkg.update, pkg_dir)
end
LibGit2.with(GitRepo(pkg_dir)) do repo
LibGit2.add!(repo, ".")
msg = "Files generated by PkgTemplates"
installed = Pkg.installed()
if haskey(installed, "PkgTemplates")
ver = string(installed["PkgTemplates"])
msg *= "\n\nPkgTemplates version: $ver"
end
commit(p, repo, pkg_dir, msg)
end
end
function commit(p::Git, repo::GitRepo, pkg_dir::AbstractString, msg::AbstractString)
if p.gpgsign
run(pipeline(`git -C $pkg_dir commit -S --allow-empty -m $msg`; stdout=devnull))
else
LibGit2.commit(repo, msg)
end
end
function render_plugin(p::Git, t::Template)
ignore = mapreduce(gitignore, append!, values(t.plugins))
# Only ignore manifests at the repo root.
t.manifest || "Manifest.toml" in entries || push!(entries, "/Manifest.toml")
unique!(sort!(entries))
return join(entries, "\n")
end
function gen_plugin(p::Gitignore, t::Template, pkg_dir::AbstractString)
t.git && gen_file(joinpath(pkg_dir, ".gitignore"), render_plugin(p, t))
p.manifest || "Manifest.toml" in ignore || push!(ignore, "/Manifest.toml")
unique!(sort!(ignore))
return join(ignore, "\n")
end
"""
@ -163,9 +267,18 @@ source(p::Tests) = p.file
destination(::Tests) = joinpath("test", "runtests.jl")
view(::Tests, ::Template, pkg::AbstractString) = Dict("PKG" => pkg)
function gen_plugin(p::Tests, t::Template, pkg_dir::AbstractString)
function prehook(p::Tests, t::Template, pkg_dir::AbstractString)
invoke(prehook, Tuple{BasicPlugin, Template, AbstractString}, p, t, pkg_dir)
p.project && t.julia_version < v"1.2" && @warn string(
"Tests: The project option is set to create a project (supported in Julia 1.2 and later) ",
"but a Julia version older than 1.2 is supported by the Template.",
)
end
function hook(p::Tests, t::Template, pkg_dir::AbstractString)
# Do the normal BasicPlugin behaviour to create the test script.
invoke(gen_plugin, Tuple{BasicPlugin, Template, AbstractString}, p, t, pkg_dir)
invoke(hook, Tuple{BasicPlugin, Template, AbstractString}, p, t, pkg_dir)
# Then set up the test depdendency in the chosen way.
f = p.project ? make_test_project : add_test_dependency

11
src/plugins/develop.jl Normal file
View File

@ -0,0 +1,11 @@
"""
Develop()
Adds generated packages to the current environment by `dev`ing them.
See [here](https://julialang.github.io/Pkg.jl/v1/managing-packages/#Developing-packages-1) for more details.
"""
struct Develop <: Plugin end
function posthook(::Develop, ::Template, pkg_dir::AbstractString)
Pkg.develop(PackageSpec(; path=pkg_dir))
end

View File

@ -85,14 +85,14 @@ function view(p::Documenter{TravisCI}, t::Template, pkg::AbstractString)
return merge(base, Dict("HAS_DEPLOY" => true))
end
function gen_plugin(p::Documenter, t::Template, pkg_dir::AbstractString)
function hook(p::Documenter, t::Template, pkg_dir::AbstractString)
pkg = basename(pkg_dir)
docs_dir = joinpath(pkg_dir, "docs")
# Generate files.
make = render_file(p.make_jl, combined_view(p, t, pkg), tags(p))
gen_file(joinpath(docs_dir, "make.jl"), make)
index = render_file(p.index_md, combined_view(p, t, pkg), tags(p))
gen_file(joinpath(docs_dir, "make.jl"), make)
gen_file(joinpath(docs_dir, "src", "index.md"), index)
# Copy over any assets.

View File

@ -1,4 +1,4 @@
default_plugins() = [Gitignore(), License(), Readme(), Tests()]
default_plugins() = [ProjectFile(), SrcDir(), Git(), License(), Readme(), Tests()]
default_user() = LibGit2.getconfig("github.user", "")
default_version() = VersionNumber(VERSION.major)
@ -26,20 +26,14 @@ A configuration used to generate packages.
### Package Options
- `dir::AbstractString="$(contractuser(Pkg.devdir()))"`: Directory to place packages in.
- `host::AbstractString="github.com"`: URL to the code hosting service where packages will reside.
- `julia_version::VersionNumber=$(repr(default_version()))`: Minimum allowed Julia version.
- `develop::Bool=true`: Whether or not to `develop` new packages in the active environment.
### Git Options
- `git::Bool=true`: Whether or not to create a Git repository for new packages.
- `host::AbstractString="github.com"`: URL to the code hosting service where packages will reside.
- `ssh::Bool=false`: Whether or not to use SSH for the Git remote.
If left unset, HTTPS will be used.
- `manifest::Bool=false`: Whether or not to commit the `Manifest.toml`.
### Template Plugins
- `plugins::Vector{<:Plugin}=Plugin[]`: A list of [`Plugin`](@ref)s used by the template.
- `disable_defaults::Vector{DataType}=DataType[]`: Default plugins to disable.
The default plugins are [`Readme`](@ref), [`License`](@ref), [`Tests`](@ref), and [`Gitignore`](@ref).
The default plugins are [`ProjectFile`](@ref), [`SrcDir`](@ref), [`Tests`](@ref), [`Readme`](@ref), [`License`](@ref), and [`Git`](@ref).
To override a default plugin instead of disabling it altogether, supply it via `plugins`.
### Interactive Usage
@ -57,14 +51,10 @@ julia> t("PkgName")
"""
struct Template
authors::Vector{String}
develop::Bool
dir::String
git::Bool
host::String
julia_version::VersionNumber
manifest::Bool
plugins::Dict{DataType, <:Plugin}
ssh::Bool
user::String
end
@ -78,9 +68,9 @@ function Template(::Val{false}; kwargs...)
authors = getkw(kwargs, :authors)
authors isa Vector || (authors = map(strip, split(authors, ",")))
host = replace(getkw(kwargs, :host), r".*://" => "")
dir = abspath(expanduser(getkw(kwargs, :dir)))
host = replace(getkw(kwargs, :host), r".*://" => "")
julia_version = getkw(kwargs, :julia_version)
disabled = getkw(kwargs, :disable_defaults)
enabled = filter(p -> !(typeof(p) in disabled), default_plugins())
@ -89,26 +79,7 @@ function Template(::Val{false}; kwargs...)
# which means that default plugins get replaced by user values.
plugins = Dict(typeof(p) => p for p in enabled)
# TODO: It might be nice to offer some kind of warn_incompatible function
# to be optionally implemented by plugins instead of hardcoding this case here.
julia = getkw(kwargs, :julia_version)
julia < v"1.2" && haskey(plugins, Tests) && plugins[Tests].project && @warn string(
"The Tests plugin is set to create a project (supported in Julia 1.2 and later)",
"but a Julia version older than 1.2 is supported.",
)
return Template(
authors,
getkw(kwargs, :develop),
dir,
getkw(kwargs, :git),
host,
julia,
getkw(kwargs, :manifest),
plugins,
getkw(kwargs, :ssh),
user,
)
return Template(authors, dir, host, julia_version, plugins, user)
end
# Does the template have a plugin that satisfies some predicate?
@ -121,13 +92,35 @@ getkw(kwargs, k) = get(() -> defaultkw(k), kwargs, k)
# Default Template keyword values.
defaultkw(s::Symbol) = defaultkw(Val(s))
defaultkw(::Val{:authors}) = default_authors()
defaultkw(::Val{:develop}) = true
defaultkw(::Val{:dir}) = Pkg.devdir()
defaultkw(::Val{:disable_defaults}) = DataType[]
defaultkw(::Val{:git}) = true
defaultkw(::Val{:host}) = "github.com"
defaultkw(::Val{:julia_version}) = default_version()
defaultkw(::Val{:manifest}) = false
defaultkw(::Val{:plugins}) = Plugin[]
defaultkw(::Val{:ssh}) = false
defaultkw(::Val{:user}) = default_user()
"""
(::Template)(pkg::AbstractString)
Generate a package named `pkg` from a [`Template`](@ref).
"""
function (t::Template)(pkg::AbstractString)
endswith(pkg, ".jl") && (pkg = pkg[1:end-3])
pkg_dir = joinpath(t.dir, pkg)
ispath(pkg_dir) && throw(ArgumentError("$pkg_dir already exists"))
mkpath(pkg_dir)
try
foreach((prehook, hook, posthook)) do h
@info "Running $(h)s"
foreach(values(t.plugins)) do p
h(p, t, pkg_dir)
end
end
catch
rm(pkg_dir; recursive=true, force=true)
rethrow()
end
@info "New package is at $pkg_dir"
end

5
templates/src/module.jl Normal file
View File

@ -0,0 +1,5 @@
module {{{PKG}}}
# Write your package code here.
end