diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0134ffc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + # Keep GitHub Actions up to date + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + github-actions: + patterns: + - "*" + + # Keep Julia dependencies up to date + - package-ecosystem: "julia" + directory: "/" + schedule: + interval: "weekly" + groups: + julia-deps: + patterns: + - "*" diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 77288dc..f351d87 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -1,49 +1,51 @@ name: CI on: push: - branches: [master] - tags: ["*"] + branches: + - main + - master + tags: ['*'] pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }} + jobs: test: - name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }} + name: Julia ${{ matrix.version }} / ${{ matrix.os }} runs-on: ${{ matrix.os }} + timeout-minutes: 60 + permissions: + actions: write + contents: read strategy: fail-fast: false matrix: version: - - '1.7' - - '1' # automatically expands to the latest stable 1.x release of Julia - - 'nightly' + - '1' + - 'lts' os: - ubuntu-latest - - macOS-latest - windows-latest - arch: - - x64 - - x86 - exclude: - - os: macOS-latest - arch: x86 + - macOS-latest steps: - - uses: actions/checkout@v2 - - uses: julia-actions/setup-julia@v1 + - uses: actions/checkout@v7 + - uses: julia-actions/setup-julia@v3 with: version: ${{ matrix.version }} - arch: ${{ matrix.arch }} - - uses: actions/cache@v1 - env: - cache-name: cache-artifacts - with: - path: ~/.julia/artifacts - key: ${{ runner.os }}-test-${{ env.cache-name }}-${{ hashFiles('**/Project.toml') }} - restore-keys: | - ${{ runner.os }}-test-${{ env.cache-name }}- - ${{ runner.os }}-test- - ${{ runner.os }}- + - uses: julia-actions/cache@v3 - uses: julia-actions/julia-buildpkg@v1 - uses: julia-actions/julia-runtest@v1 - - uses: julia-actions/julia-processcoverage@v1 - - uses: codecov/codecov-action@v1 - with: - file: lcov.info + + # Single aggregate check for branch protection to require + ci-success: + name: CI success + if: always() + needs: [test] + runs-on: ubuntu-latest + steps: + - name: Fail if any needed job did not succeed + if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }} + run: exit 1 diff --git a/.github/workflows/CompatHelper.yml b/.github/workflows/CompatHelper.yml deleted file mode 100644 index 9a88ca6..0000000 --- a/.github/workflows/CompatHelper.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: CompatHelper - -on: - schedule: - - cron: '00 00 * * *' - -jobs: - CompatHelper: - runs-on: ${{ matrix.os }} - strategy: - matrix: - julia-version: [1.2.0] - julia-arch: [x86] - os: [ubuntu-latest] - steps: - - uses: julia-actions/setup-julia@latest - with: - version: ${{ matrix.julia-version }} - - name: Pkg.add("CompatHelper") - run: julia -e 'using Pkg; Pkg.add("CompatHelper")' - - name: CompatHelper.main() - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: julia -e 'using CompatHelper; CompatHelper.main()' \ No newline at end of file diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml new file mode 100644 index 0000000..2a28937 --- /dev/null +++ b/.github/workflows/dependabot-automerge.yml @@ -0,0 +1,10 @@ +name: Dependabot auto-merge +on: pull_request + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + uses: RallypointOne/.github/.github/workflows/dependabot-automerge.yml@main diff --git a/.gitignore b/.gitignore index 99e2cea..1388e96 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,3 @@ .DS_Store /Manifest.toml /dev/ -/docs/build/ -/docs/site/ diff --git a/Project.toml b/Project.toml index 8ac7d10..2765142 100644 --- a/Project.toml +++ b/Project.toml @@ -1,17 +1,9 @@ name = "EasyConfig" uuid = "acab07b0-f158-46d4-8913-50acef6d41fe" +version = "0.2.0" authors = ["joshday "] -version = "0.1.16" - -[deps] -JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" -OrderedCollections = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" -StructTypes = "856f2bd8-1eba-4b0a-8007-ebc267875bd4" [compat] -JSON3 = "1.4.0" -OrderedCollections = "1.3.2" -StructTypes = "1.1.0" julia = "1" [extras] diff --git a/README.md b/README.md index 5550854..7259472 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ [![Build status](https://github.com/JuliaComputing/EasyConfig.jl/workflows/CI/badge.svg)](https://github.com/JuliaComputing/EasyConfig.jl/actions?query=workflow%3ACI+branch%3Amaster) -[![Codecov](https://codecov.io/gh/JuliaComputing/EasyConfig.jl/branch/master/graph/badge.svg)](https://codecov.io/gh/JuliaComputing/EasyConfig.jl) [![deps](https://juliahub.com/docs/EasyConfig/deps.svg)](https://juliahub.com/ui/Packages/EasyConfig/tMFix?t=2) [![version](https://juliahub.com/docs/EasyConfig/version.svg)](https://juliahub.com/ui/Packages/EasyConfig/tMFix) [![pkgeval](https://juliahub.com/docs/EasyConfig/pkgeval.svg)](https://juliahub.com/ui/Packages/EasyConfig/tMFix) @@ -76,18 +75,9 @@ let end ``` - # Note -- Accessing a property that doesn't exist will create an empty `Config()`. -- Clean up stranded empty `Config`s with `delete_empty!(::Config)`. - -```julia -c = Config() - -c.one.two.three.four.five.six == Config() - -# Internally we make the assumption that empty Config's shouldn't be there. -# Some functions will therefore call `delete_empty!` under the hood: -isempty(c) == true -``` +- Accessing a non-existent property creates an `Undefined`. +- This is necessary for assigning deeply nested properties (an eventual `setindex!` after nested `getindex`-es). + - `config.a == Undefined(config, [:a])`, `config.a.b == Undefined(config, [:a, :b])`, etc. + - `config.a.b.c = 1` == `Undefined(config, [:a, :b])[:c] = 1` diff --git a/docs/Manifest.toml b/docs/Manifest.toml deleted file mode 100644 index b748768..0000000 --- a/docs/Manifest.toml +++ /dev/null @@ -1,92 +0,0 @@ -# This file is machine-generated - editing it directly is not advised - -[[Base64]] -uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" - -[[Dates]] -deps = ["Printf"] -uuid = "ade2ca70-3891-5945-98fb-dc099432e06a" - -[[Distributed]] -deps = ["Random", "Serialization", "Sockets"] -uuid = "8ba89e20-285c-5b6f-9357-94700520ee1b" - -[[DocStringExtensions]] -deps = ["LibGit2", "Markdown", "Pkg", "Test"] -git-tree-sha1 = "88bb0edb352b16608036faadcc071adda068582a" -uuid = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae" -version = "0.8.1" - -[[Documenter]] -deps = ["Base64", "Dates", "DocStringExtensions", "InteractiveUtils", "JSON", "LibGit2", "Logging", "Markdown", "REPL", "Test", "Unicode"] -git-tree-sha1 = "646ebc3db49889ffeb4c36f89e5d82c6a26295ff" -uuid = "e30172f5-a6a5-5a46-863b-614d45cd2de4" -version = "0.24.7" - -[[InteractiveUtils]] -deps = ["Markdown"] -uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240" - -[[JSON]] -deps = ["Dates", "Mmap", "Parsers", "Unicode"] -git-tree-sha1 = "b34d7cef7b337321e97d22242c3c2b91f476748e" -uuid = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" -version = "0.21.0" - -[[LibGit2]] -uuid = "76f85450-5226-5b5a-8eaa-529ad045b433" - -[[Libdl]] -uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb" - -[[Logging]] -uuid = "56ddb016-857b-54e1-b83d-db4d58db5568" - -[[Markdown]] -deps = ["Base64"] -uuid = "d6f4376e-aef5-505a-96c1-9c027394607a" - -[[Mmap]] -uuid = "a63ad114-7e13-5084-954f-fe012c677804" - -[[Parsers]] -deps = ["Dates", "Test"] -git-tree-sha1 = "75d07cb840c300084634b4991761886d0d762724" -uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" -version = "1.0.1" - -[[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" - -[[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" diff --git a/docs/Project.toml b/docs/Project.toml deleted file mode 100644 index dfa65cd..0000000 --- a/docs/Project.toml +++ /dev/null @@ -1,2 +0,0 @@ -[deps] -Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" diff --git a/docs/make.jl b/docs/make.jl deleted file mode 100644 index 2bc9122..0000000 --- a/docs/make.jl +++ /dev/null @@ -1,17 +0,0 @@ -using Documenter, EasyConfig - -makedocs(; - modules=[EasyConfig], - format=Documenter.HTML(), - pages=[ - "Home" => "index.md", - ], - repo="https://github.com/joshday/EasyConfig.jl/blob/{commit}{path}#L{line}", - sitename="EasyConfig.jl", - authors="joshday ", - assets=String[], -) - -deploydocs(; - repo="github.com/joshday/EasyConfig.jl", -) diff --git a/docs/src/index.md b/docs/src/index.md deleted file mode 100644 index fa47f43..0000000 --- a/docs/src/index.md +++ /dev/null @@ -1,8 +0,0 @@ -# EasyConfig.jl - -```@index -``` - -```@autodocs -Modules = [EasyConfig] -``` diff --git a/src/EasyConfig.jl b/src/EasyConfig.jl index d132ede..2094f9d 100644 --- a/src/EasyConfig.jl +++ b/src/EasyConfig.jl @@ -1,92 +1,130 @@ module EasyConfig -using OrderedCollections -using StructTypes -using JSON3 +export Config, @config -export Config, delete_empty!, @config - -#-----------------------------------------------------------------------------# Config -""" - Config(; kw...) - Config(::AbstractDict) - -A wrapper around an `OrderedDict{Symbol, Any}` with the special properties: - -1. You can get/set items via `getproperty`/`setproperty!`: - - c = Config() - c.one = 1 - c.one == 1 - -2. Properties that don't exist will be created on the fly: - - c = Config() - c.one.two.three = "neat!" -""" -struct Config <: AbstractDict{Symbol, Any} - d::OrderedDict{Symbol, Any} - function Config(x::Union{NamedTuple, AbstractDict}) - d = OrderedDict{Symbol,Any}() - for (k,v) in pairs(x) - d[Symbol(k)] = _config_value(v) - end - new(d) +#------------------------------------------------------------------------------# Undefined +# Lazy path of nested dicts +struct Undefined{T <: AbstractDict{Symbol, Any}} + parent::T + path::Vector{Symbol} +end +parent(o::Undefined) = getfield(o, :parent) +path(o::Undefined) = getfield(o, :path) + +Base.getindex(o::Undefined, k::Symbol) = Undefined(parent(o), [path(o)..., k]) +function Base.setindex!(o::Undefined{T}, v, key::Symbol) where {T} + v = _value(T, v) # validate before creating intermediate levels + c = parent(o) + for k in path(o) + haskey(c, k) || (c[k] = T()) + c = c[k] end - Config(x::Config) = x + c[key] = v + return parent(o) end -Config(; kw...) = Config(kw) -Config(x...) = Config(OrderedDict(x...)) - -_config_value(x) = x -_config_value(x::AbstractDict) = Config(x) -_config_value(x::Pair) = Config(Symbol(x[1]) => x[2]) -_config_value(x::AbstractArray) = map(_config_value, x) - -OrderedCollections.isordered(::Type{Config}) = true - -delete_empty!(x) = x -function delete_empty!(o::Config) - for (k,v) in pairs(o) - v isa Config && isempty(v) ? delete!(o, k) : delete_empty!(v) +Base.getproperty(o::Undefined, k::Symbol) = getindex(o, k) +Base.setproperty!(o::Undefined, k::Symbol, v) = setindex!(o, v, k) + +Base.getindex(o::Undefined, k::AbstractString) = getindex(o, Symbol(k)) +Base.setindex!(o::Undefined, v, k::AbstractString) = setindex!(o, v, Symbol(k)) +Base.getproperty(o::Undefined, k::AbstractString) = getindex(o, Symbol(k)) +Base.setproperty!(o::Undefined, k::AbstractString, v) = setindex!(o, v, Symbol(k)) + +Base.show(io::IO, o::Undefined) = print(io, "Undefined(", join(path(o), " → "), ')') + +#------------------------------------------------------------------------------# Config +struct Config{D <: AbstractDict{Symbol, Any}} <: AbstractDict{Symbol, Any} + dict::D + function Config(dict::D) where {D <: AbstractDict{Symbol, Any}} + map!(v -> _value(Config{D}, v), values(dict)) + new{D}(dict) end - o +end +Config() = Config(Dict{Symbol,Any}()) +Config(x::Config) = x +Config(x::AbstractDict{Symbol}) = Config{Dict{Symbol,Any}}(x) +Config{D}() where {D} = Config(D()) +function Config{D}(x::AbstractDict{Symbol}) where {D} + out = Config{D}() + for (k, v) in x + out[k] = v + end + out +end +function Config(x::Pair...; kw...) + out = Config() + for (k, v) in Iterators.flatten((x, kw)) + out[k] = v + end + out end -dict(o::Config) = getfield(o, :d) - -StructTypes.StructType(::Type{Config}) = StructTypes.DictType() - -Base.getproperty(o::Config, k::Symbol) = get!(o, k, Config()) -Base.getproperty(o::Config, k) = getproperty(o, Symbol(k)) - -Base.setproperty!(o::Config, k::Symbol, v) = dict(o)[Symbol(k)] = v -Base.setproperty!(o::Config, k, v) = setproperty!(o, Symbol(k), v) - -Base.propertynames(o::Config) = collect(keys(dict(o))) - -Base.getindex(o::Config, k) = getproperty(o, Symbol(k)) -Base.setindex!(o::Config, v, k) = setproperty!(o, Symbol(k), v) - -Base.iterate(o::Config, args...) = iterate(dict(o), args...) -Base.keys(o::Config) = keys(dict(o)) -Base.values(o::Config) = values(dict(o)) +dict(o::Config) = getfield(o, :dict) + +# Dict values are stored as Configs of the same type as their parent +_value(::Type{T}, v) where {T <: Config} = v +_value(::Type{T}, v::AbstractDict{Symbol}) where {T <: Config} = T(v) +_value(::Type{T}, v::T) where {T <: Config} = v +_value(::Type{<:Config}, v::Undefined) = throw(ArgumentError("Cannot store an undefined key path: $v")) + +## Getting and Setting ## +Base.getindex(o::Config, k::Symbol) = get(o, k, Undefined(o, [k])) +Base.setindex!(o::T, v, k::Symbol) where {T <: Config} = setindex!(dict(o), _value(T, v), k) + +Base.getindex(o::Config, k::AbstractString) = getindex(o, Symbol(k)) +Base.setindex!(o::Config, v, k::AbstractString) = setindex!(o, v, Symbol(k)) + +Base.propertynames(o::Config) = keys(o) +Base.getproperty(o::Config, k::Symbol) = getindex(o, k) +Base.setproperty!(o::Config, k::Symbol, v) = setindex!(o, v, k) +Base.getproperty(o::Config, k::AbstractString) = getindex(o, Symbol(k)) +Base.setproperty!(o::Config, k::AbstractString, v) = setindex!(o, v, Symbol(k)) + +## AbstractDict interface ## +Base.get(o::Config, k::Symbol, default) = get(dict(o), k, default) +Base.get(o::Config, k::AbstractString, default) = get(o, Symbol(k), default) +# Return the stored value, which may be a converted `default` +function Base.get!(f::Base.Callable, o::Config, k::Symbol) + haskey(dict(o), k) || (o[k] = f()) + return dict(o)[k] +end +Base.get!(f::Base.Callable, o::Config, k::AbstractString) = get!(f, o, Symbol(k)) +Base.get!(o::Config, k::Union{Symbol, AbstractString}, default) = get!(() -> default, o, k) +Base.iterate(o::Config, s...) = iterate(dict(o), s...) Base.length(o::Config) = length(dict(o)) -Base.isempty(o::Config) = isempty(dict(delete_empty!(o))) -Base.pairs(o::Config) = pairs(dict(o)) +Base.empty(o::Config) = Config(empty(dict(o))) Base.empty!(o::Config) = (empty!(dict(o)); o) -Base.get(o::Config, k, default) = get(dict(o), Symbol(k), default) -Base.get!(o::Config, k, default) = get!(dict(o), Symbol(k), default) -Base.haskey(o::Config, k) = haskey(dict(o), Symbol(k)) +Base.delete!(o::Config, k::Symbol) = (delete!(dict(o), k); o) +Base.delete!(o::Config, k::AbstractString) = delete!(o, Symbol(k)) +Base.merge(a::Config, bs::AbstractDict...) = merge!(copy(a), bs...) +Base.merge!(a::Config, b::Config) = Config(merge!(dict(a), dict(b))) +Base.mergewith(f, a::Config, bs::AbstractDict...) = mergewith!(f, copy(a), bs...) +Base.copy(o::Config) = Config(copy(dict(o))) -Base.delete!(o::Config, k) = (delete!(dict(o), Symbol(k)); o) +#------------------------------------------------------------------------------# show +# What gets used to show nested Configs by the AbstractDict show method: +function Base.show(io::IO, o::Config) + print(io, "Config(") + if !Base.show_circular(io, o) + recur_io = IOContext(io, :SHOWN_SET => o) + join(io, map(((k, v),) -> "$(repr(k)) => $(repr(v; context=recur_io))", collect(o)), ", ") + end + print(io, ')') +end -Base.isequal(a::Config, b::Config) = dict(a) == dict(b) -Base.copy(o::Config) = Config(copy(dict(o))) +#------------------------------------------------------------------------------# deepmerge +# `merge`, but will update the keys of a nested dict rather than replacing the dict entirely +function deepmerge!(a::T, b::AbstractDict) where {T <: AbstractDict} + foreach(pairs(b)) do (k, v) + old = get(a, k, nothing) + a[k] = v isa AbstractDict ? deepmerge!(old isa AbstractDict ? old : T(), v) : v + end + return a +end -Base.merge(a::Config, b::Config) = Config(merge(dict(a), dict(b))) +deepmerge(a, b) = deepmerge!(deepmerge!(Config(), a), b) +deepmerge(a::Config, b) = deepmerge!(deepmerge!(empty(a), a), b) -Base.merge!(a::Config, b::Config) = Config(merge!(dict(a), dict(b))) #-----------------------------------------------------------------------------# @config """ @@ -112,27 +150,23 @@ Create a `Config` with a NamedTuple-like or block syntax. The following example end """ macro config(ex...) - exprs = collect(ex) - if length(exprs) == 1 - ex = only(exprs) - if ex.head == :block - ex = Expr(:tuple, filter(x -> !(x isa LineNumberNode), ex.args)...) - end - else - ex = Expr(:tuple, exprs...) - end - ex.head == :tuple || error("@config input must be a tuple") - all(x -> x.head == :(=), ex.args) || error("Unexpected syntax in @config") - x = gensym() - out = Expr(:block, :(local $x = Config())) - for val in ex.args - lhs, rhs = val.args - push!(out.args, :($(_prepend(x, lhs)) = $rhs)) - end - push!(out.args, x) - esc(out) + args = length(ex) == 1 ? _config_args(only(ex)) : collect(ex) + all(x -> Meta.isexpr(x, :(=), 2), args) || error("@config expects `key = value` expressions") + c = gensym("config") + body = map(x -> :($(_prepend(c, x.args[1])) = $(x.args[2])), args) + esc(Expr(:block, :(local $c = $Config()), body..., c)) +end + +function _config_args(ex) + Meta.isexpr(ex, :block) && return filter(x -> !(x isa LineNumberNode), ex.args) + Meta.isexpr(ex, :tuple) && return ex.args + return [ex] +end + +_prepend(c, ex::Symbol) = :($c.$ex) +function _prepend(c, ex) + Meta.isexpr(ex, :., 2) || error("@config expects keys like `x` or `x.y.z`, got: $ex") + Expr(:., _prepend(c, ex.args[1]), ex.args[2]) end -_prepend(val, e::Symbol) = :($val.$e) -_prepend(val, e) = e.head == :. && (e.args[1] = _prepend(val, e.args[1]); e) end # module diff --git a/test/runtests.jl b/test/runtests.jl index 588fac2..c0ddda7 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,123 +1,213 @@ using EasyConfig -using StructTypes -using OrderedCollections: OrderedCollections, OrderedDict using Test @testset "Constructors" begin @test Config() == Config() + @test isempty(Config()) c = Config() c.x = 1 - @test Config(x=1) == Config(:x => 1) == Config(Dict(:x => 1)) == Config((; x=1)) == c + @test Config(x=1) == Config(:x => 1) == Config("x" => 1) == Config(Dict(:x => 1)) == c + @test Config(:x => 1; y=2) == Config(x=1, y=2) - @testset "AbstractDict values" begin - d = Dict(:x => Dict(:y => 1)) + @testset "Wraps AbstractDict{Symbol, Any} without copying" begin + d = Dict{Symbol, Any}(:x => 1) c = Config(d) + c.y = 2 + @test d[:y] == 2 + end + + @testset "Config(::Config) doesn't rewrap" begin + c = Config(x=1) + @test Config(c) === c + end + + @testset "AbstractDict values become Configs" begin + c = Config(Dict(:x => Dict(:y => Dict(:z => 1)))) @test c.x isa Config + @test c.x.y isa Config + @test c.x.y.z == 1 + c.a = Dict(:b => 2) + @test c.a isa Config + @test c.a.b == 2 + + c = Config(Dict{Symbol, Any}(:x => Dict(:y => Dict(:z => 1)))) + @test c.x isa Config + @test c.x.y isa Config + @test c.x.y.z == 1 + + c = Config(IdDict{Symbol, Any}(:x => Dict(:y => 1))) + @test c.x isa Config{IdDict{Symbol, Any}} end - @testset "Vector{<:AbstractDict} values" begin - d = Dict(:x => [Dict(:y => 1), Dict(:y=> 2)]) - c = Config(d) - @test c.x isa Vector{Config} + @testset "Config values are stored, not copied" begin + c = Config() + s = Config() + c.s = s + s.x = 1 + @test c.s === s + @test c.s.x == 1 end - @testset "Pair values" begin - data = [Dict("x"=>1), "y" => 2] - c = Config(; data) - @test c.data[1].x == 1 - @test c.data[2].y == 2 + @testset "Config{D}" begin + D = IdDict{Symbol, Any} + @test Config{D}() isa Config{D} + c = Config{D}(Dict(:x => 1)) + @test c isa Config{D} + @test c.x == 1 end end -@testset "Set/get property/field" begin +@testset "Get/set" begin c = Config() - c.one."two"["three"][:four] = 5 - @test c.one.two.three.four == 5 - c."test" == Config() - @test propertynames(c) == [:one, :test] -end + c.x = 1 + c[:y] = 2 + c["z"] = 3 + @test c.x == c[:x] == c["x"] == 1 + @test c.y == 2 + @test c.z == 3 + @test sort(collect(propertynames(c))) == [:x, :y, :z] + + @testset "Missing keys are Undefined" begin + c = Config() + @test c.x isa EasyConfig.Undefined + @test c.x.y.z isa EasyConfig.Undefined + @test isempty(c) + end -@testset "Other base methods" begin - c = Config() + @testset "Nested assignment" begin + c = Config() + c.a.b.c.d = 1 + @test c.a.b.c.d == 1 + c.a.b.e = 2 + @test c.a.b.e == 2 + @test c.a.b.c.d == 1 + c[:f][:g] = 3 + @test c.f.g == 3 + end - @test get(c, "test", 1) == 1 - @test c."test" == Config() + @testset "Reusing an Undefined doesn't overwrite earlier writes" begin + c = Config() + u = c.a.b + u.x = 1 + u.y = 2 + @test c.a.b.x == 1 + @test c.a.b.y == 2 + end - @test get!(c, "test2", 1) == 1 - @test c."test2" == 1 - delete!(c, "test") - @test propertynames(c) == [:test2] + @testset "Nested assignment preserves dict type" begin + c = Config(IdDict{Symbol, Any}()) + c.b.c = 1 + c.a = 2 + @test c.b isa Config{IdDict{Symbol, Any}} + end + + @testset "Undefined can't be stored" begin + c = Config() + @test_throws ArgumentError c.b = c.a + @test_throws ArgumentError c.b.c = c.a + @test isempty(c) + end - @test keys(Config(x = 1)) == keys(OrderedDict(:x => 1)) - @test all(values(Config(x = 1)) .== values(OrderedDict(:x => 1))) - @test pairs(Config(x = 1)) == pairs(OrderedDict(:x => 1)) - @test empty!(Config(x=1)) == Config() - @test haskey(Config(x=1), "x") == haskey(Config(x=1), :x) == true + @testset "String keys" begin + c = Config() + c["x"]["y"] = 1 + @test c.x.y == 1 + c."a"."b" = 2 + @test c.a.b == 2 + @test c."a"."b" == 2 + @test c.z["w"] isa EasyConfig.Undefined + end +end - @test OrderedCollections.isordered(Config) +@testset "AbstractDict interface" begin + c = Config(x=1, y=2) + @test length(c) == 2 + @test get(c, :x, 0) == 1 + @test get(c, "x", 0) == 1 + @test get(c, :nope, 0) == 0 + @test haskey(c, :x) + @test !haskey(c, :nope) + @test Dict(c) == Dict(:x => 1, :y => 2) + + @test delete!(c, :x) === c + @test !haskey(c, :x) + delete!(c, "y") + @test isempty(c) - @test Config(x=1) == Config(:x => 1) == Config("x" => 1) + c = Config(x=1) + @test empty!(c) === c + @test isempty(c) a = Config(x=1) b = copy(a) - @test a !== b + @test a == b + b.x = 2 + @test a.x == 1 + + @testset "get! returns the stored value" begin + c = Config() + r = get!(c, :x, Dict(:a => 1)) + @test r isa Config + r.b = 2 + @test c.x.b == 2 + @test get!(c, "x", 0) === r + @test get!(() -> 0, c, :y) == 0 + @test c.y == 0 + end - c = merge(a, Config(x=2,y=3)) - @test c.x == 2 - @test c.y == 3 + @testset "Results keep the dict type" begin + D = IdDict{Symbol, Any} + c = Config(D(:x => 1, :y => 2)) + @test empty(c) isa Config{D} + @test isempty(empty(c)) + @test filter(p -> p[1] == :x, c) == Config(x=1) + @test filter(p -> p[1] == :x, c) isa Config{D} + @test merge(c, Config(z=3)) isa Config{D} + @test mergewith(+, c, Config(x=10)) isa Config{D} + @test mergewith(+, c, Config(x=10)).x == 11 + @test EasyConfig.deepmerge(c, Config(z=3)) isa Config{D} + end +end - # https://github.com/JuliaComputing/EasyConfig.jl/issues/8 +@testset "merge/merge!" begin a = Config(x=1) b = Config(y=Config(z=2)) c = merge(a, b) - @test a == Config(x=1) + @test c == Config(x=1, y=Config(z=2)) + @test a == Config(x=1) # https://github.com/JuliaComputing/EasyConfig.jl/issues/8 merge!(a, b) @test a == Config(x=1, y=Config(z=2)) -end -@testset "from NamedTuple" begin - nt = (x=1, y=(x=1, y=(x=1, y=(x=1,y=2)))) - c = Config(nt) - @test c.y.y.y.y == 2 -end + @test merge(Config(x=1), Dict(:y => Dict(:z => 2))) == Config(x=1, y=Config(z=2)) + @test merge(Config(x=1), Config(y=2), Config(z=3)) == Config(x=1, y=2, z=3) -@testset "from Dict" begin - d = Dict(:x => Dict(:x => Dict(:x => Dict(:x => 2)))) - c = Config(d) - @test c.x.x.x.x == 2 -end + a = Config(y=Config(x=1)) + @test EasyConfig.deepmerge(a, Config(y=Config(z=2))) == Config(y=Config(x=1, z=2)) + @test a == Config(y=Config(x=1)) -@testset "merge!" begin - a = Config(x = 1, y = Config(x = 1)) - b = Config(x = 5, y = Config(x=5, z="hi")) - c = merge(a, b) + a = Config(x=1, y=Config(x=1)) + b = Config(x=5, y=Config(x=5, z="hi")) merge!(a, b) - @test a == c @test a.x == 5 @test a.y.x == 5 @test a.y.z == "hi" end -@testset "isempty/delete_empty!" begin - c = Config() - c.x.x.x - @test isempty(c) - c.x.x.x - EasyConfig.delete_empty!(c) - @test isempty(c) -end +@testset "show" begin + @test repr(Config()) == "Config()" + @test repr(Config(x=1)) == "Config(:x => 1)" + @test repr(Config(x=Config(y="two"))) == "Config(:x => Config(:y => \"two\"))" -@testset "StructTypes" begin - @test isempty(StructTypes.keyvaluepairs(Config())) - c = Config(x=1,y=2) - @test StructTypes.keyvaluepairs(c)[:x] == 1 - @test StructTypes.keyvaluepairs(c)[:y] == 2 + c = Config() + c.self = c + @test occursin("circular reference", repr(c)) + @test occursin("circular reference", repr(MIME"text/plain"(), c)) end @testset "@config" begin val = 5 - c = @config (x.a=1, x.b=2, x.c.d.e.f.g = 3, z = val) + c = @config (x.a=1, x.b=2, x.c.d.e.f.g=3, z=val) @test c.x.a == 1 @test c.x.b == 2 @test c.x.c.d.e.f.g == 3 @@ -138,5 +228,16 @@ end c4.x.c.d.e.f.g = 3 c4.z = val - @test c == c2 == c3 + @test c == c2 == c3 == c4 + + @test @config(x=1) == Config(x=1) + @test @config((x=1,)) == Config(x=1) + @test @config(begin end) == Config() + + # hygiene: macro's internal variable doesn't leak or clash + config = 10 + @test @config(x=config).x == 10 + + @test_throws Exception @eval @config x + 1 + @test_throws Exception @eval @config x[1] = 1 end