Skip to content

Commit 7caf143

Browse files
committed
WIP: Add a loading mechanism for compat-dependent API sets
This is a first WIP implementation for providing the underlying mechanism for a #54905. I haven't fully designed out what I want the final thing to look like, but I've thought about it enough that I think we need some hands-on examples for further design exploration, which is what this PR is designed to support. The core idea here is that if you specify in your package ``` [compact] julia = "0.14" MyDependency = "0.3,0.4" ``` Then this mechanism will ask `MyDependency` to provide you a view of its API that is compatible with its 0.3 version, even if 0.4 is installed. The objective of this mechanism is that downstreams of widely used packages (including `Base` and standard libraries) can be upgraded more gradually by allowing some portion of the packages in project to use the new 0.4 API set, while packages that have not yet upgraded can continue to use the 0.3 API set. Currently, whenever a widely used package changes an API there's a big frenzied rush to update downstreams, which just isn't sustainable as the ecosystem grows. In other languages, problems like these are solved by allowing multiple versions of the same package to be loaded. However, in julia, this is not particularly feasible by default because packages may have generic functions that need to be extended and unified and which break if there are multiple copies of such resources. That said, for simple packages, this mechanism could be used to emulate the multiple-version-loading strategy on an opt-in basis. The way that this works is that `loading` gains an additional layer of indirection that is provided the `compat` specification of the loading package. Packages can opt into this by providing a ``` function _get_versioned_api(compat) end ``` function. Where the default is equivalent to `_get_versioned_api(compat) = @__MODULE__`. The loader makes no further assumptions on either the structure of `compat` or how the package processes it. This is intentional to allow evolution without impacting the core loading logic (of course Pkg also looks at the structure of compat, as would the `_get_versioned_api` implementation in Base, so there are some constraints). That said, the envisioned usage of this is that we create a standard (in the sense of being widely used, not in the sense of it being a stdlib) package to help package authors define the APIs of their packages more precisely and with version information. This package would then create a set of modules under the hood that describe these APIs to the sytem and would set up `_get_versioned_api` appropriately (i.e. the mechanism is not intended to be used directly in most cases). To actually make this useful, we will likely need some additional features in the binding system, in particular: 1. #59859 and a few variants thereon to make the API modueles look more transparent. 2. A mechanism to intercept extension of generic function overloads in order to be able to provide compatibility (Design/PR for this forthcoming). Note that this PR itself is WIP, it is not yet fully complete and there is also a Pkg.jl side of this required to put compat info into the manifest.
1 parent 8c42a92 commit 7caf143

File tree

4 files changed

+115
-53
lines changed

4 files changed

+115
-53
lines changed

base/loading.jl

Lines changed: 95 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -256,8 +256,8 @@ struct LoadingCache
256256
env_project_file::Dict{String, Union{Bool, String}}
257257
project_file_manifest_path::Dict{String, Union{Nothing, String}}
258258
require_parsed::Set{String}
259-
identified_where::Dict{Tuple{PkgId, String}, Union{Nothing, Tuple{PkgId, String}}}
260-
identified::Dict{String, Union{Nothing, Tuple{PkgId, String}}}
259+
identified_where::Dict{Tuple{PkgId, String}, Union{Nothing, Tuple{ApiId, String}}}
260+
identified::Dict{String, Union{Nothing, Tuple{ApiId, String}}}
261261
located::Dict{Tuple{PkgId, Union{String, Nothing}}, Union{Tuple{String, String}, Nothing}}
262262
end
263263
const LOADING_CACHE = Ref{Union{LoadingCache, Nothing}}(nothing) # n.b.: all access to and through this are protected by require_lock
@@ -303,10 +303,10 @@ end
303303
# Used by Pkg but not used in loading itself
304304
function find_package(arg) # ::Union{Nothing,String}
305305
@lock require_lock begin
306-
pkgenv = identify_package_env(arg)
307-
pkgenv === nothing && return nothing
308-
pkg, env = pkgenv
309-
return locate_package(pkg, env)
306+
apienv = identify_package_env(arg)
307+
apienv === nothing && return nothing
308+
api, env = apienv
309+
return locate_package(api.pkg, env)
310310
end
311311
end
312312

@@ -338,7 +338,7 @@ function identify_package_env(where::Union{PkgId, Nothing}, name::String)
338338
if where !== nothing
339339
if where.name === name
340340
# Project tries to load itself
341-
return (where, nothing)
341+
return (ApiId(where, nothing), nothing)
342342
elseif where.uuid === nothing
343343
# Project without Project.toml - treat as toplevel load
344344
where = nothing
@@ -351,36 +351,36 @@ function identify_package_env(where::Union{PkgId, Nothing}, name::String)
351351
cache_key = where === nothing ? name : (where, name)
352352
if cache !== nothing
353353
env_cache = where === nothing ? cache.identified : cache.identified_where
354-
pkg_env = get(env_cache, cache_key, missing)
355-
pkg_env === missing || return pkg_env
354+
api_env = get(env_cache, cache_key, missing)
355+
api_env === missing || return api_env
356356
end
357357

358358
# Main part: Search through all environments in the load path to see if we have
359359
# a matching entry.
360-
pkg_env = nothing
360+
api_env = nothing
361361
for env in load_path()
362-
pkgid = environment_deps_get(env, where, name)
362+
apiid = environment_deps_get(env, where, name)
363363
# If we didn't find `where` at all, keep looking through the environment stack
364-
pkgid === nothing && continue
365-
if pkgid.uuid !== nothing || where === nothing
366-
pkg_env = pkgid, env
364+
apiid === nothing && continue
365+
if apiid.pkg.uuid !== nothing || where === nothing
366+
api_env = apiid, env
367367
end
368368
# If we don't have pkgid.uuid, still break here - this is a sentinel that indicates
369369
# that we've found `where` but it did not have the required dependency. We terminate the search.
370370
break
371371
end
372-
if pkg_env === nothing && where !== nothing && is_stdlib(where)
372+
if api_env === nothing && where !== nothing && is_stdlib(where)
373373
# if not found it could be that manifests are from a different julia version/commit
374374
# where stdlib dependencies have changed, so look up deps based on the stdlib Project.toml
375375
# as a fallback
376-
pkg_env = identify_stdlib_project_dep(where, name)
376+
api_env = identify_stdlib_project_dep(where, name)
377377
end
378378

379379
# Cache the result
380380
if cache !== nothing
381-
env_cache[cache_key] = pkg_env
381+
env_cache[cache_key] = api_env
382382
end
383-
return pkg_env
383+
return api_env
384384
end
385385
identify_package_env(name::String) = identify_package_env(nothing, name)
386386

@@ -427,9 +427,13 @@ julia> using LinearAlgebra
427427
julia> Base.identify_package(LinearAlgebra, "Pkg") # Pkg is not a dependency of LinearAlgebra
428428
```
429429
"""
430-
identify_package(where::Module, name::String) = @lock require_lock _nothing_or_first(identify_package_env(where, name))
431-
identify_package(where::PkgId, name::String) = @lock require_lock _nothing_or_first(identify_package_env(where, name))
432-
identify_package(name::String) = @lock require_lock _nothing_or_first(identify_package_env(name))
430+
identify_package(where::Module, name::String) = (api = identify_api(where, name); return api === nothing ? nothing : api.pkg)
431+
identify_package(where::PkgId, name::String) = (api = identify_api(where, name); return api === nothing ? nothing : api.pkg)
432+
identify_package(name::String) = (api = identify_api(name); return api === nothing ? nothing : api.pkg)
433+
434+
identify_api(where::Module, name::String) = @lock require_lock _nothing_or_first(identify_package_env(where, name))
435+
identify_api(where::PkgId, name::String) = @lock require_lock _nothing_or_first(identify_package_env(where, name))
436+
identify_api(name::String) = @lock require_lock _nothing_or_first(identify_package_env(name))
433437

434438
function locate_package_env(pkg::PkgId, stopenv::Union{String, Nothing}=nothing)::Union{Nothing,Tuple{String,String}}
435439
assert_havelock(require_lock)
@@ -708,14 +712,15 @@ function base_project(project_file)
708712
end
709713
end
710714

711-
function package_get_here(project_file, name::String)
715+
function package_get_here(project_file, name::String)::Union{Nothing,ApiId}
712716
# if `where` matches the project, use [deps] section as manifest, and stop searching
713717
pkg_uuid = explicit_project_deps_get(project_file, name)
714-
pkg_uuid === nothing && return PkgId(name)
715-
return PkgId(pkg_uuid, name)
718+
pkg_compat = explicit_project_compat_get(project_file, name)
719+
pkg_uuid === nothing && return ApiId(PkgId(name), pkg_compat)
720+
return ApiId(PkgId(pkg_uuid, name), pkg_compat)
716721
end
717722

718-
function package_get(project_file, where::Union{Nothing, PkgId}, name::String)
723+
function package_get(project_file, where::Union{Nothing, PkgId}, name::String)::Union{Nothing,ApiId}
719724
if where !== nothing
720725
proj = project_file_name_uuid(project_file, where.name)
721726
proj != where && return nothing
@@ -726,7 +731,7 @@ end
726731
ext_may_load_weakdep(exts::String, name::String) = exts == name
727732
ext_may_load_weakdep(exts::Vector{String}, name::String) = name in exts
728733

729-
function package_extension_get(project_file, where::PkgId, name::String)
734+
function package_extension_get(project_file, where::PkgId, name::String)::Union{Nothing,ApiId}
730735
d = parsed_toml(project_file)
731736
exts = get(d, "extensions", nothing)::Union{Dict{String, Any}, Nothing}
732737
if exts !== nothing
@@ -739,7 +744,8 @@ function package_extension_get(project_file, where::PkgId, name::String)
739744
if weakdeps !== nothing
740745
wuuid = get(weakdeps, name, nothing)::Union{String, Nothing}
741746
if wuuid !== nothing
742-
return PkgId(UUID(wuuid), name)
747+
return ApiId(PkgId(UUID(wuuid), name),
748+
explicit_project_compat_get(project_file, name))
743749
end
744750
end
745751
end
@@ -750,7 +756,7 @@ function package_extension_get(project_file, where::PkgId, name::String)
750756
return nothing
751757
end
752758

753-
function environment_deps_get(env::String, where::Union{Nothing,PkgId}, name::String)::Union{Nothing,PkgId}
759+
function environment_deps_get(env::String, where::Union{Nothing,PkgId}, name::String)::Union{Nothing,ApiId}
754760
@assert where === nothing || where.uuid !== nothing
755761
project_file = env_project_file(env)
756762
implicit_manifest = !(project_file isa String)
@@ -760,7 +766,8 @@ function environment_deps_get(env::String, where::Union{Nothing,PkgId}, name::St
760766
# Toplevel load with a directory (implicit manifest) - all we look for is the
761767
# existence of the package name in the directory.
762768
pkg = implicit_manifest_pkgid(env, name)
763-
return pkg
769+
pkg === nothing && return nothing
770+
return ApiId(pkg, nothing)
764771
end
765772
project_file = implicit_manifest_project(env, where)
766773
project_file === nothing && return nothing
@@ -781,7 +788,7 @@ function environment_deps_get(env::String, where::Union{Nothing,PkgId}, name::St
781788
# uses the same code path. Otherwise this is the active project.
782789
pkg = package_get(project_file, where, name)
783790
if pkg !== nothing
784-
if where === nothing && pkg.uuid === nothing
791+
if where === nothing && pkg.pkg.uuid === nothing
785792
# This is a top-level load - even though we didn't find the dependency
786793
# here, we still want to keep looking through the top-level environment stack.
787794
return nothing
@@ -802,7 +809,7 @@ function environment_deps_get(env::String, where::Union{Nothing,PkgId}, name::St
802809
# With an implicit manifest, getting here means that our (implicit) environment
803810
# *has* the package `where`. If we don't find it, it just means that `where` doesn't
804811
# have `name` as a dependency - c.f. the analogous case in `explicit_manifest_deps_get`.
805-
return PkgId(name)
812+
return ApiId(PkgId(name), nothing)
806813
end
807814

808815
# All other cases, dependencies come from the (top-level) manifest
@@ -995,6 +1002,13 @@ function explicit_project_deps_get(project_file::String, name::String)::Union{No
9951002
return nothing
9961003
end
9971004

1005+
function explicit_project_compat_get(project_file::String, name::String)
1006+
d = parsed_toml(project_file)
1007+
compat = get(d, "compat", nothing)::Union{Dict{String, Any}, Nothing}
1008+
compat === nothing && return nothing
1009+
return get(compat, name, nothing)
1010+
end
1011+
9981012
function is_v1_format_manifest(raw_manifest::Dict{String})
9991013
if haskey(raw_manifest, "manifest_format")
10001014
mf = raw_manifest["manifest_format"]
@@ -1018,24 +1032,31 @@ function get_deps(raw_manifest::Dict)
10181032
end
10191033
end
10201034

1021-
function dep_stanza_get(stanza::Dict{String, Any}, name::String)::Union{Nothing, PkgId}
1022-
for (dep, uuid) in stanza
1023-
uuid::String
1024-
if dep === name
1025-
return PkgId(UUID(uuid), name)
1035+
function dep_stanza_get(stanza::Dict{String, Any}, name)::Union{ApiId, Nothing}
1036+
for (dep, uuid_or_obj) in stanza
1037+
if dep == name
1038+
if uuid_or_obj isa String
1039+
# uuid specified, but no API compat
1040+
return ApiId(PkgId(UUID(uuid_or_obj), name), nothing)
1041+
else
1042+
api_compat = get(uuid_or_obj, "compat", nothing)::Union{Nothing, String}
1043+
uuid = get(uuid_or_obj, "uuid", nothing)::Union{Nothing, String}
1044+
uuid === nothing && return nothing
1045+
return ApiId(PkgId(UUID(uuid), name), api_compat)
1046+
end
10261047
end
10271048
end
10281049
return nothing
10291050
end
10301051

1031-
function dep_stanza_get(stanza::Vector{String}, name::String)::Union{Nothing, PkgId}
1032-
name in stanza && return PkgId(name)
1052+
function dep_stanza_get(stanza::Vector{String}, name::String)::Union{Nothing, ApiId}
1053+
name in stanza && return ApiId(PkgId(name), nothing)
10331054
return nothing
10341055
end
10351056

10361057
dep_stanza_get(stanza::Nothing, name::String) = nothing
10371058

1038-
function explicit_manifest_deps_get(project_file::String, where::PkgId, name::String)::Union{Nothing,PkgId}
1059+
function explicit_manifest_deps_get(project_file::String, where::PkgId, name::String)::Union{Nothing,ApiId}
10391060
manifest_file = project_file_manifest_path(project_file)
10401061
manifest_file === nothing && return nothing # manifest not found--keep searching LOAD_PATH
10411062
d = get_deps(parsed_toml(manifest_file))
@@ -1048,7 +1069,7 @@ function explicit_manifest_deps_get(project_file::String, where::PkgId, name::St
10481069
# deps is either a list of names (deps = ["DepA", "DepB"]) or
10491070
# a table of entries (deps = {"DepA" = "6ea...", "DepB" = "55d..."}
10501071
deps = get(entry, "deps", nothing)::Union{Vector{String}, Dict{String, Any}, Nothing}
1051-
local dep::Union{Nothing, PkgId}
1072+
local dep::Union{Nothing, ApiId}
10521073
if UUID(uuid) === where.uuid
10531074
dep = dep_stanza_get(deps, name)
10541075

@@ -1057,15 +1078,15 @@ function explicit_manifest_deps_get(project_file::String, where::PkgId, name::St
10571078
# change to dependency's Project or our Manifest. Return a sentinel here indicating
10581079
# that we know the package, but do not know its UUID. The caller will terminate the
10591080
# search and provide an appropriate error to the user.
1060-
dep === nothing && return PkgId(name)
1081+
dep === nothing && return ApiId(PkgId(name), nothing)
10611082
else
10621083
# Check if we're trying to load into an extension of this package
10631084
extensions = get(entry, "extensions", nothing)
10641085
if extensions !== nothing
10651086
if haskey(extensions, where.name) && where.uuid == uuid5(UUID(uuid), where.name)
10661087
if name == dep_name
10671088
# Extension loads its base package
1068-
return PkgId(UUID(uuid), name)
1089+
return ApiId(PkgId(UUID(uuid), name), nothing)
10691090
end
10701091
exts = extensions[where.name]::Union{String, Vector{String}}
10711092
# Extensions are allowed to load:
@@ -1078,14 +1099,14 @@ function explicit_manifest_deps_get(project_file::String, where::PkgId, name::St
10781099
dep === nothing && continue
10791100
@goto have_dep
10801101
end
1081-
return PkgId(name)
1102+
return ApiId(PkgId(name), nothing)
10821103
end
10831104
end
10841105
continue
10851106
end
10861107

10871108
@label have_dep
1088-
dep.uuid !== nothing && return dep
1109+
dep.pkg.uuid !== nothing && return dep
10891110

10901111
# We have the dep, but it did not specify a UUID. In this case,
10911112
# it must be that the name is unique in the manifest - so lookup
@@ -1097,7 +1118,7 @@ function explicit_manifest_deps_get(project_file::String, where::PkgId, name::St
10971118
entry = first(name_deps::Vector{Any})::Dict{String, Any}
10981119
uuid = get(entry, "uuid", nothing)::Union{String, Nothing}
10991120
uuid === nothing && return PkgId(name)
1100-
return PkgId(UUID(uuid), name)
1121+
return ApiId(PkgId(UUID(uuid), name), nothing)
11011122
end
11021123
end
11031124

@@ -1471,6 +1492,7 @@ function run_module_init(mod::Module, i::Int=1)
14711492
end
14721493
end
14731494

1495+
run_package_callbacks(modkey::ApiId) = run_package_callbacks(modkey.pkg)
14741496
function run_package_callbacks(modkey::PkgId)
14751497
run_extension_callbacks(modkey)
14761498
assert_havelock(require_lock)
@@ -1505,6 +1527,7 @@ const EXT_PRIMED = Dict{PkgId,Vector{PkgId}}() # Extension -> Parent + Triggers
15051527
const EXT_DORMITORY = Dict{PkgId,Vector{ExtensionId}}() # Trigger -> Extensions that can be triggered by it
15061528
const EXT_DORMITORY_FAILED = ExtensionId[]
15071529

1530+
insert_extension_triggers(api::ApiId) = insert_extension_triggers(api.pkg)
15081531
function insert_extension_triggers(pkg::PkgId)
15091532
pkg.uuid === nothing && return
15101533
path_env_loc = locate_package_env(pkg)
@@ -2263,6 +2286,12 @@ function canstart_loading(modkey::PkgId, build_id::UInt128, stalecheck::Bool)
22632286
return cond
22642287
end
22652288

2289+
function start_loading(modkey::ApiId, build_id::UInt128, stalecheck::Bool)
2290+
m = start_loading(modkey.pkg, build_id, true)
2291+
m === nothing && return nothing
2292+
return api_for_loaded_module(modkey, m)
2293+
end
2294+
22662295
function start_loading(modkey::PkgId, build_id::UInt128, stalecheck::Bool)
22672296
# handle recursive and concurrent calls to require
22682297
while true
@@ -2278,6 +2307,7 @@ function start_loading(modkey::PkgId, build_id::UInt128, stalecheck::Bool)
22782307
end
22792308
end
22802309

2310+
end_loading(apikey::ApiId, @nospecialize loaded) = end_loading(apikey.pkg, loaded)
22812311
function end_loading(modkey::PkgId, @nospecialize loaded)
22822312
assert_havelock(require_lock)
22832313
loading = pop!(package_locks, modkey)
@@ -2498,7 +2528,7 @@ function __require(into::Module, mod::Symbol)
24982528
end
24992529
uuidkey, env = uuidkey_env
25002530
if _track_dependencies[]
2501-
path = binpack(uuidkey)
2531+
path = binpack(uuidkey.pkg)
25022532
push!(_require_dependencies, (into, path, UInt64(0), UInt32(0), 0.0))
25032533
end
25042534
return _require_prelocked(uuidkey, env)
@@ -2565,8 +2595,8 @@ function require(uuidkey::PkgId)
25652595
end
25662596
return invoke_in_world(world, __require, uuidkey)
25672597
end
2568-
__require(uuidkey::PkgId) = @lock require_lock _require_prelocked(uuidkey)
2569-
function _require_prelocked(uuidkey::PkgId, env=nothing)
2598+
__require(uuidkey::Union{PkgId, ApiId}) = @lock require_lock _require_prelocked(uuidkey)
2599+
function _require_prelocked(uuidkey::Union{PkgId, ApiId}, env=nothing)
25702600
assert_havelock(require_lock)
25712601
m = start_loading(uuidkey, UInt128(0), true)
25722602
if m === nothing
@@ -2825,6 +2855,23 @@ function __require_prelocked(pkg::PkgId, env)
28252855
return loaded
28262856
end
28272857

2858+
__require_prelocked(apikey::ApiId, env) =
2859+
api_for_loaded_module(apikey, __require_prelocked(apikey.pkg, env))
2860+
2861+
function lookup_api(loaded::Module, api::ApiId)
2862+
if !isdefined(loaded, :_get_versioned_api)
2863+
return loaded
2864+
end
2865+
return loaded._get_versioned_api(api.compat)::Module
2866+
end
2867+
2868+
function api_for_loaded_module(apikey::ApiId, loaded::Module)
2869+
if apikey.compat === nothing
2870+
return loaded
2871+
end
2872+
return invokelatest(lookup_api, loaded, apikey)
2873+
end
2874+
28282875
# load a serialized file directly, including dependencies (without checking staleness except for immediate conflicts)
28292876
# this does not call start_loading / end_loading, so can lead to some odd behaviors
28302877
function _require_from_serialized(uuidkey::PkgId, path::String, ocachepath::Union{String, Nothing}, sourcepath::String)

base/pkgid.jl

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,18 @@ function binunpack(s::String)
4343
name = read(io, String)
4444
return PkgId(UUID(uuid), name)
4545
end
46+
47+
struct ApiId
48+
pkg::PkgId
49+
compat::Union{String, Nothing}
50+
end
51+
ApiId(pkg::PkgId) = ApiId(pkg, nothing)
52+
==(a::ApiId, b::ApiId) = a.pkg == b.pkg && a.compat == b.compat
53+
54+
function hash(api::ApiId, h::UInt)
55+
h = hash(api.pkg, h)
56+
return hash(api.compat, h)
57+
end
58+
59+
show(io::IO, ::MIME"text/plain", api::ApiId) =
60+
print(io, api.pkg, api.compat === nothing ? "" : " at version compat " * api.compat)

0 commit comments

Comments
 (0)