Skip to content

Commit ce8a5b9

Browse files
authored
Merge pull request #1 from JuliaPackaging/sf/rework2
Rework Preferences loading framework
2 parents 491e068 + 1d18679 commit ce8a5b9

File tree

10 files changed

+535
-405
lines changed

10 files changed

+535
-405
lines changed

Project.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,10 @@ version = "1.0.0"
77
TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76"
88

99
[extras]
10-
Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
1110
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
1211

1312
[targets]
14-
test = ["Test", "Pkg"]
13+
test = ["Test"]
1514

1615
[compat]
1716
julia = "1.6"

README.md

Lines changed: 51 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,66 @@
11
# Preferences
22

3-
`Preferences` supports embedding a simple `Dict` of metadata for a package on a per-project basis.
4-
These preferences allow for packages to set simple, persistent pieces of data, and optionally trigger recompilation of the package when the preferences change, to allow for customization of package behavior at compile-time.
3+
The `Preferences` package provides a convenient, integrated way for packages to store configuration switches to persistent TOML files, and use those pieces of information at both run time and compile time.
4+
This enables the user to modify the behavior of a package, and have that choice reflected in everything from run time algorithm choice to code generation at compile time.
5+
Preferences are stored as TOML dictionaries and are, by default, stored within a `(Julia)LocalPreferences.toml` file next to the currently-active project.
6+
If a preference is "exported", it is instead stored within the `(Julia)Project.toml` instead.
7+
The intention is to allow shared projects to contain shared preferences, while allowing for users themselves to override those preferences with their own settings in the `LocalPreferences.toml` file, which should be `.gitignore`d as the name implies.
58

6-
## API Overview
9+
Preferences can be set with depot-wide defaults; if package `Foo` is installed within your global environment and it has preferences set, these preferences will apply as long as your global environment is part of your [`LOAD_PATH`](https://docs.julialang.org/en/v1/manual/code-loading/#Environment-stacks).
10+
Preferences in environments higher up in the environment stack get overridden by the more proximal entries in the load path, ending with the currently active project.
11+
This allows depot-wide preference defaults to exist, with active projects able to merge or even completely overwrite these inherited preferences.
12+
See the docstring for `set_preferences!()` for the full details of how to set preferences to allow or disallow merging.
713

8-
`Preferences` are used primarily through the `@load_preferences`, `@save_preferences` and `@modify_preferences` macros.
9-
These macros will auto-detect the UUID of the calling package, throwing an error if the calling module does not belong to a package.
10-
The function forms can be used to load, save or modify preferences belonging to another package.
14+
Preferences that are accessed during compilation are automatically marked as compile-time preferences, and any change recorded to these preferences will cause the Julia compiler to recompile any cached precompilation `.ji` files for that module.
15+
This allows preferences to be used to influence code generation.
16+
When your package sets a compile-time preference, it is usually best to suggest to the user that they should restart Julia, to allow recompilation to occur.
1117

12-
Example usage:
18+
## API
19+
20+
Preferences use is very simple; it is all based around two functions (which each have convenience macros): `@set_preferences!()` and `@load_preference()`.
21+
22+
* `@load_preference(key, default = nothing)`: This loads a preference named `key` for the current package. If no such preference is found, it returns `default`.
23+
24+
* `@set_preferences!(pairs...)`: This allows setting multiple preferences at once as pairs.
25+
26+
To illustrate the usage, we show a toy module, taken directly from this package's tests:
1327

1428
```julia
15-
using Preferences
29+
module UsesPreferences
30+
31+
function set_backend(new_backend::String)
32+
if !(new_backend in ("OpenCL", "CUDA", "jlFPGA"))
33+
throw(ArgumentError("Invalid backend: \"$(new_backend)\""))
34+
end
1635

17-
function get_preferred_backend()
18-
prefs = @load_preferences()
19-
return get(prefs, "backend", "native")
36+
# Set it in our runtime values, as well as saving it to disk
37+
@set_preferences!("backend" => new_backend)
38+
@info("New backend set; restart your Julia session for this change to take effect!")
2039
end
2140

22-
function set_backend(new_backend)
23-
@modify_preferences!() do prefs
24-
prefs["backend"] = new_backend
41+
const backend = @load_preference("backend", "OpenCL")
42+
43+
# An example that helps us to prove that things are happening at compile-time
44+
function do_computation()
45+
@static if backend == "OpenCL"
46+
return "OpenCL is the best!"
47+
elseif backend == "CUDA"
48+
return "CUDA; so fast, so fresh!"
49+
elseif backend == "jlFPGA"
50+
return "The Future is Now, jlFPGA online!"
51+
else
52+
return nothing
2553
end
2654
end
27-
```
2855

29-
Preferences are stored within the first `Project.toml` that represents an environment that contains the given UUID, even as a transitive dependency.
30-
If no project that contains the given UUID is found, the preference is recorded in the `Project.toml` file of the currently-active project.
31-
The initial state for preferences is an empty dictionary, package authors that wish to have a default value set for their preferences should use the `get(prefs, key, default)` pattern as shown in the code example above.
3256

33-
## Compile-Time Preferences
57+
# A non-compiletime preference
58+
function set_username(username::String)
59+
@set_preferences!("username" => username)
60+
end
61+
function get_username()
62+
return @load_preference("username")
63+
end
3464

35-
If a preference must be known at compile-time, (and hence changing it should invalidate your package's precompiled `.ji` file) access of it should be done through the `Preferences.CompileTime` module.
36-
The exact same API is exposed, but the preferences will be stored within a separate dictionary from normal `Preferences`, and any change made to these preferences will cause your package to be recompiled the next time it is loaded.
37-
Packages that wish to use purely compile-time preferences can simply `using Preferences.CompileTime`, mixed usage will require compile-time usage to access functions and macros via `CompileTime.@load_preferences()`, etc...
65+
end # module UsesPreferences
66+
```

src/Preferences.jl

Lines changed: 217 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,226 @@
11
module Preferences
22

3+
using TOML
4+
using Base: UUID, TOMLCache
5+
6+
export load_preference, @load_preference,
7+
set_preferences!, @set_preferences!
8+
9+
include("utils.jl")
10+
11+
"""
12+
load_preference(uuid_or_module, key, default = nothing)
13+
14+
Load a particular preference from the `Preferences.toml` file, shallowly merging keys
15+
as it walks the hierarchy of load paths, loading preferences from all environments that
16+
list the given UUID as a direct dependency.
17+
18+
Most users should use the `@load_preference` convenience macro which auto-determines the
19+
calling `Module`.
20+
"""
21+
function load_preference(uuid::UUID, key::String, default = nothing)
22+
# Re-use definition in `base/loading.jl` so as to not repeat code.
23+
d = Base.get_preferences(uuid)
24+
if currently_compiling()
25+
Base.record_compiletime_preference(uuid, key)
26+
end
27+
# Drop any nested `__clear__` keys:
28+
function drop_clears(data::Dict)
29+
delete!(data, "__clear__")
30+
for (k, v) in data
31+
if isa(v, Dict)
32+
drop_clears(v)
33+
end
34+
end
35+
return data
36+
end
37+
drop_clears(x) = x
38+
39+
return drop_clears(get(d, key, default))
40+
end
41+
function load_preference(m::Module, key::String, default = nothing)
42+
return load_preference(get_uuid(m), key, default)
43+
end
44+
345
"""
4-
CompileTime
46+
@load_preference(key)
547
6-
This module provides bindings for setting/getting preferences that can be used at compile
7-
time and will cause your `.ji` file to be invalidated when they are changed.
48+
Convenience macro to call `load_preference()` for the current package.
849
"""
9-
module CompileTime
10-
const PREFS_KEY = "compile-preferences"
11-
include("common.jl")
12-
end # module CompileTime
50+
macro load_preference(key, default = nothing)
51+
return quote
52+
load_preference($(esc(get_uuid(__module__))), $(esc(key)), $(esc(default)))
53+
end
54+
end
1355

14-
# Export `CompileTime` but don't `using` it
15-
export CompileTime
1656

17-
# Second copy of code for non-compiletime preferences
18-
const PREFS_KEY = "preferences"
19-
include("common.jl")
57+
"""
58+
process_sentinel_values!(prefs::Dict)
59+
60+
Recursively search for preference values that end in `nothing` or `missing` leaves,
61+
which we handle specially, see the `set_preferences!()` docstring for more detail.
62+
"""
63+
function process_sentinel_values!(prefs::Dict)
64+
# Need to widen `prefs` so that when we try to assign to `__clear__` below,
65+
# we don't error due to a too-narrow type on `prefs`
66+
prefs = Base._typeddict(prefs, Dict{String,Vector{String}}())
67+
68+
clear_keys = get(prefs, "__clear__", String[])
69+
for k in collect(keys(prefs))
70+
if prefs[k] === nothing
71+
# If this should add `k` to the `__clear__` list, do so, then remove `k`
72+
push!(clear_keys, k)
73+
delete!(prefs, k)
74+
elseif prefs[k] === missing
75+
# If this should clear out the mapping for `k`, do so, and drop it from `clear_keys`
76+
delete!(prefs, k)
77+
filter!(x -> x != k, clear_keys)
78+
elseif isa(prefs[k], Dict)
79+
# Recurse for nested dictionaries
80+
prefs[k] = process_sentinel_values!(prefs[k])
81+
end
82+
end
83+
# Store the updated list of clear_keys
84+
if !isempty(clear_keys)
85+
prefs["__clear__"] = collect(Set(clear_keys))
86+
else
87+
delete!(prefs, "__clear__")
88+
end
89+
return prefs
90+
end
91+
92+
# See the `set_preferences!()` docstring below for more details
93+
function set_preferences!(target_toml::String, pkg_name::String, pairs::Pair{String,<:Any}...; force::Bool = false)
94+
# Load the old preferences in first, as we'll merge ours into whatever currently exists
95+
d = Dict{String,Any}()
96+
if isfile(target_toml)
97+
d = Base.parsed_toml(target_toml)
98+
end
99+
prefs = d
100+
if endswith(target_toml, "Project.toml")
101+
if !haskey(prefs, "preferences")
102+
prefs["preferences"] = Dict{String,Any}()
103+
end
104+
# If this is a `(Julia)Project.toml` file, we squirrel everything away under the
105+
# "preferences" key, while for a `Preferences.toml` file it sits at top-level.
106+
prefs = prefs["preferences"]
107+
end
108+
# Index into our package name
109+
if !haskey(prefs, pkg_name)
110+
prefs[pkg_name] = Dict{String,Any}()
111+
end
112+
# Set each preference, erroring unless `force` is set to `true`
113+
for (k, v) in pairs
114+
if !force && haskey(prefs[pkg_name], k) && (v === missing || prefs[pkg_name][k] != v)
115+
throw(ArgumentError("Cannot set preference '$(k)' to '$(v)' for $(pkg_name) in $(target_toml): preference already set to '$(prefs[pkg_name][k])'!"))
116+
end
117+
prefs[pkg_name][k] = v
118+
119+
# Recursively scan for `nothing` and `missing` values that we need to handle specially
120+
prefs[pkg_name] = process_sentinel_values!(prefs[pkg_name])
121+
end
122+
open(target_toml, "w") do io
123+
TOML.print(io, d, sorted=true)
124+
end
125+
return nothing
126+
end
127+
128+
"""
129+
set_preferences!(uuid_or_module, prefs::Pair{String,Any}...; export_prefs=false, force=false)
130+
131+
Sets a series of preferences for the given UUID/Module, identified by the pairs passed in
132+
as `prefs`. Preferences are loaded from `Project.toml` and `LocalPreferences.toml` files
133+
on the load path, merging values together into a cohesive view, with preferences taking
134+
precedence in `LOAD_PATH` order, just as package resolution does. Preferences stored in
135+
`Project.toml` files are considered "exported", as they are easily shared across package
136+
installs, whereas the `LocalPreferences.toml` file is meant to represent local
137+
preferences that are not typically shared. `LocalPreferences.toml` settings override
138+
`Project.toml` settings where appropriate.
139+
140+
After running `set_preferences!(uuid, "key" => value)`, a future invocation of
141+
`load_preference(uuid, "key")` will generally result in `value`, with the exception of
142+
the merging performed by `load_preference()` due to inheritance of preferences from
143+
elements higher up in the `load_path()`. To control this inheritance, there are two
144+
special values that can be passed to `set_preferences!()`: `nothing` and `missing`.
145+
146+
* Passing `missing` as the value causes all mappings of the associated key to be removed
147+
from the current level of `LocalPreferences.toml` settings, allowing preferences set
148+
higher in the chain of preferences to pass through. Use this value when you want to
149+
clear your settings but still inherit any higher settings for this key.
150+
151+
* Passing `nothing` as the value causes all mappings of the associated key to be removed
152+
from the current level of `LocalPreferences.toml` settings and blocks preferences set
153+
higher in the chain of preferences from passing through. Internally, this adds the
154+
preference key to a `__clear__` list in the `LocalPreferences.toml` file, that will
155+
prevent any preferences from leaking through from higher environments.
156+
157+
Note that the behavior of `missing` and `nothing` is both similar (they both clear the
158+
current settings) and diametrically opposed (one allows inheritance of preferences, the
159+
other does not). They can also be composed with a normal `set_preferences!()` call:
160+
161+
```julia
162+
@set_preferences!("compiler_options" => nothing)
163+
@set_preferences!("compiler_options" => Dict("CXXFLAGS" => "-g", LDFLAGS => "-ljulia"))
164+
```
165+
166+
The above snippet first clears the `"compiler_options"` key of any inheriting influence,
167+
then sets a preference option, which guarantees that future loading of that preference
168+
will be exactly what was saved here. If we wanted to re-enable inheritance from higher
169+
up in the chain, we could do the same but passing `missing` first.
170+
171+
The `export_prefs` option determines whether the preferences being set should be stored
172+
within `LocalPreferences.toml` or `Project.toml`.
173+
"""
174+
function set_preferences!(u::UUID, prefs::Pair{String,<:Any}...; export_prefs=false, kwargs...)
175+
# Find the first `Project.toml` that has this UUID as a direct dependency
176+
project_toml, pkg_name = find_first_project_with_uuid(u)
177+
if project_toml === nothing && pkg_name === nothing
178+
# If we couldn't find one, we're going to use `active_project()`
179+
project_toml = Base.active_project()
180+
181+
# And we're going to need to add this UUID as an "extras" dependency:
182+
# We're going to assume you want to name this this dependency in the
183+
# same way as it's been loaded:
184+
pkg_uuid_matches = filter(d -> d.uuid == u, keys(Base.loaded_modules))
185+
if isempty(pkg_uuid_matches)
186+
error("Cannot set preferences of an unknown package that is not loaded!")
187+
end
188+
pkg_name = first(pkg_uuid_matches).name
189+
190+
# Read in the project, add the deps, write it back out!
191+
project = Base.parsed_toml(project_toml)
192+
if !haskey(project, "extras")
193+
project["extras"] = Dict{String,Any}()
194+
end
195+
project["extras"][pkg_name] = string(u)
196+
open(project_toml, "w") do io
197+
TOML.print(io, project; sorted=true)
198+
end
199+
end
200+
201+
# Finally, save the preferences out to either `Project.toml` or `Preferences.toml`
202+
# keyed under that `pkg_name`:
203+
target_toml = project_toml
204+
if !export_prefs
205+
target_toml = joinpath(dirname(project_toml), "LocalPreferences.toml")
206+
end
207+
return set_preferences!(target_toml, pkg_name, prefs...; kwargs...)
208+
end
209+
function set_preferences!(m::Module, prefs::Pair{String,<:Any}...; kwargs...)
210+
return set_preferences!(get_uuid(m), prefs...; kwargs...)
211+
end
212+
213+
"""
214+
@set_preferences!(prefs...)
215+
216+
Convenience macro to call `set_preferences!()` for the current package. Defaults to
217+
setting `force=true`, since a package should have full control over itself, but not
218+
so for setting the preferences in other packages, pending private dependencies.
219+
"""
220+
macro set_preferences!(prefs...)
221+
return quote
222+
set_preferences!($(esc(get_uuid(__module__))), $(esc(prefs...)), force=true)
223+
end
224+
end
20225

21226
end # module Preferences

0 commit comments

Comments
 (0)