Skip to content

Commit

Permalink
Provide functionality to detect "improper qualified access" (#50)
Browse files Browse the repository at this point in the history
* track qualifying modules

* wip

* tweak

* add more functionality

* wip

* more docs and checks

* wip

* wip

* bump project

* switch to `which`

* wip

* wip

* update readme

* don't skip source modules
  • Loading branch information
ericphanson authored May 25, 2024
1 parent ff24993 commit ba63bec
Show file tree
Hide file tree
Showing 13 changed files with 563 additions and 43 deletions.
9 changes: 4 additions & 5 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,16 @@ jobs:
version:
- '1.7' # earliest supported version
- '1' # current release
- 'beta'
- 'nightly'
os:
- ubuntu-latest
arch:
- x64
steps:
- uses: actions/checkout@v4
- uses: julia-actions/setup-julia@v1
# Using `juliaup` over `setup-julia` so we can access beta versions easier
- uses: julia-actions/install-juliaup@v1
with:
version: ${{ matrix.version }}
arch: ${{ matrix.arch }}
julia-version: ${{ matrix.version }}
- uses: julia-actions/cache@v1
- uses: julia-actions/julia-buildpkg@v1
- uses: julia-actions/julia-runtest@v1
Expand Down
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name = "ExplicitImports"
uuid = "7d51a73a-1435-4ff3-83d9-f097790105c7"
authors = ["Eric P. Hanson"]
version = "1.4.5"
version = "1.5.0"

[deps]
AbstractTrees = "1520ce14-60c1-5f80-bbc7-55ef81b5835c"
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ There are various takes on _how problematic_ this issue is, to what extent this

Personally, I don't think this is always a huge issue, and that it's basically fine for packages to use implicit imports if that is their preferred style and they understand the risk. But I do think this issue is somewhat a "hole" in the semver system as it applies to Julia packages, and I wanted to create some tooling to make it easier to mitigate the issue for package authors who would prefer to not rely on implicit imports.

## Ways ExplicitImports.jl can and cannot help

As mentioned, there are two ways to avoid implicit imports: by using explicit imports such as `using X: foo`, or by qualifying names (`X.foo`).

ExplicitImports.jl was initially designed to help make explicit imports more ergonomic, by providing functionality to convert implicit imports into explicit ones (e.g. `print_explicit_imports`), and by providing testing tools to keep a codebase free of implicit imports and without stale explicit imports. There are two missing features here still: checking the explicitly imported names are public in the module they are being imported from (e.g. avoid `using X: _internal_foo`), and a weaker condition, checking that they are owned by the module they are being imported from (e.g. avoid `using LinearAlgebra: map`, since `map` comes from Base).

Since v1.5, ExplicitImports.jl has also gained functionality to make using qualifying names more ergonomic. One pitfall of qualified accesses like `X.foo` is that `foo` may be internal to `X` or may be owned by another module (the same issues faced by explicit imports). The function `improper_qualified_accesses` can detect the latter case.

## Implementation status

ExplicitImports.jl has been used successfully on several codebases, but I would still not describe it as fully mature. That said, it should be ready for use; please file issues if problems arise.
Expand All @@ -38,6 +46,7 @@ See the [API docs](https://ericphanson.github.io/ExplicitImports.jl/dev/api/) fo
- functionality to help convert implicit imports to explicit exports
- functionality to warn about "stale" (unused) explicit imports
- functionality to add to package tests to ensure all imports continue to be explicit (and non-stale)
- functionality to detect "improper" qualified access to names, such as accessing `LinearAlgebra.map` instead of `Base.map` (since `map` is owned by Base, and just happens to be available in the `LinearAlgebra` namespace)

## Example

Expand All @@ -53,6 +62,9 @@ using AbstractTrees: AbstractTrees, Leaves, TreeCursor, children, nodevalue
using JuliaSyntax: JuliaSyntax, @K_str
```

Additionally, module ExplicitImports accesses names from non-owner modules:
- `parent` has owner AbstractTrees but it was accessed from ExplicitImports at /Users/eph/ExplicitImports/src/qualified_names.jl:217:21

````

Note: the `WARNING` is more or less harmless; the way this package is written, it will happen any time there is a clash, even if that clash is not realized in your code. I cannot figure out how to suppress it.
Expand All @@ -71,6 +83,9 @@ using AbstractTrees: children # used at /Users/eph/ExplicitImports/src/get_names
using AbstractTrees: nodevalue # used at /Users/eph/ExplicitImports/src/parse_utilities.jl:96:34
using JuliaSyntax: JuliaSyntax # used at /Users/eph/ExplicitImports/src/parse_utilities.jl:103:15
```

Additionally, module ExplicitImports accesses names from non-owner modules:
- `parent` has owner AbstractTrees but it was accessed from ExplicitImports at /Users/eph/ExplicitImports/src/qualified_names.jl:217:21
````

Note the paths of course will differ depending on the location of the code on your system.
Expand Down
11 changes: 10 additions & 1 deletion docs/src/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,22 @@ print_stale_explicit_imports
stale_explicit_imports
```

## Detecting "improper" access of names from other modules

```@docs
improper_qualified_accesses
print_improper_qualified_accesses
improper_qualified_accesses_nonrecursive
```

## Checks to use in testing

ExplicitImports.jl provides two functions which can be used to regression test that there is no reliance on implicit imports or stale explicit imports:
ExplicitImports.jl provides three functions which can be used to regression test that there is no reliance on implicit imports, no stale explicit imports, and no qualified accesses to names from modules other than their owner as determined by `Base.which`:

```@docs
check_no_implicit_imports
check_no_stale_explicit_imports
check_all_qualified_accesses_via_owners
```

## Usage with scripts (such as `runtests.jl`)
Expand Down
1 change: 1 addition & 0 deletions docs/src/internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ ExplicitImports.find_implicit_imports
ExplicitImports.get_names_used
ExplicitImports.analyze_all_names
ExplicitImports.inspect_session
ExplicitImports.FileAnalysis
```
5 changes: 5 additions & 0 deletions examples/qualified.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module MyMod
using LinearAlgebra
# sum is in `Base`, so we shouldn't access it from LinearAlgebra:
n = LinearAlgebra.sum([1, 2, 3])
end
47 changes: 41 additions & 6 deletions src/ExplicitImports.jl
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ export print_explicit_imports, explicit_imports, check_no_implicit_imports,
export print_explicit_imports_script
export print_stale_explicit_imports, stale_explicit_imports,
check_no_stale_explicit_imports, stale_explicit_imports_nonrecursive
export print_improper_qualified_accesses, improper_qualified_accesses,
improper_qualified_accesses_nonrecursive, check_all_qualified_accesses_via_owners
export StaleImportsException, ImplicitImportsException, UnanalyzableModuleException,
FileNotFoundException
FileNotFoundException, QualifiedAccessesFromNonOwnerException

include("parse_utilities.jl")
include("find_implicit_imports.jl")
include("get_names_used.jl")
include("qualified_names.jl")
include("checks.jl")

const SKIPS_KWARG = """
Expand Down Expand Up @@ -112,22 +115,28 @@ function print_explicit_imports(mod::Module, file=pathof(mod); kw...)
end

"""
print_explicit_imports([io::IO=stdout,] mod::Module, file=pathof(mod); skip=(mod, Base, Core), warn_stale=true, strict=true)
print_explicit_imports([io::IO=stdout,] mod::Module, file=pathof(mod); skip=(mod, Base, Core), warn_stale=true,
warn_improper_qualified_accesses=true, strict=true)
Runs [`explicit_imports`](@ref) and prints the results, along with those of [`stale_explicit_imports`](@ref).
Runs [`explicit_imports`](@ref) and prints the results, along with those of [`stale_explicit_imports`](@ref) and [`improper_qualified_accesses`](@ref).
Note that the particular printing may change in future non-breaking releases of ExplicitImports.
## Keyword arguments
$SKIPS_KWARG
* `warn_stale=true`: if set, this function will also print information about stale explicit imports.
* `warn_improper_qualified_accesses=true`: if set, this function will also print information about any "improper" qualified accesses to names from other modules.
$STRICT_PRINTING_KWARG
* `show_locations=false`: whether or not to print locations of where the names are being used (and, if `warn_stale=true`, where the stale explicit imports are).
* `linewidth=80`: format into lines of up to this length. Set to 0 to indicate one name should be printed per line.
See also [`check_no_implicit_imports`](@ref) and [`check_no_stale_explicit_imports`](@ref).
See also [`check_no_implicit_imports`](@ref), [`check_no_stale_explicit_imports`](@ref), and [`check_all_qualified_accesses_via_owners`](@ref).
"""
function print_explicit_imports(io::IO, mod::Module, file=pathof(mod);
skip=(mod, Base, Core), warn_stale=true, strict=true,
skip=(mod, Base, Core), warn_stale=true,
warn_improper_qualified_accesses=true,
strict=true,
show_locations=false,
linewidth=80,
# internal kwargs
Expand Down Expand Up @@ -170,6 +179,23 @@ function print_explicit_imports(io::IO, mod::Module, file=pathof(mod);
println(io, "- $name", proof)
end
end
else
stale = ()
end
if warn_improper_qualified_accesses
problematic = improper_qualified_accesses_nonrecursive(mod, file;
file_analysis=file_analysis[file])
if !isnothing(problematic) && !isempty(problematic)
word = !isnothing(imports) && isempty(imports) && isempty(stale) ?
"However" : "Additionally"
println(io)
println(io,
"$word, $(name_fn(mod)) accesses names from non-owner modules:")
for row in problematic
println(io,
"- `$(row.name)` has owner $(row.whichmodule) but it was accessed from $(row.accessing_from) at $(row.location)")
end
end
end
end
end
Expand Down Expand Up @@ -292,6 +318,8 @@ end
Runs [`stale_explicit_imports`](@ref) and prints the results.
Note that the particular printing may change in future non-breaking releases of ExplicitImports.
## Keyword arguments
$STRICT_PRINTING_KWARG
Expand Down Expand Up @@ -341,6 +369,8 @@ More keys may be added to the NamedTuples in the future in non-breaking releases
This is an unusual situation (generally `B` should just get `x` directly from `X`, rather than indirectly via `A`), but there are situations in which it arises, so one may need to be careful about naively removing all "stale" explicit imports flagged by this function.
Running [`improper_qualified_accesses`](@ref) on downstream code can help identify such "improper" accesses to names via modules other than their owner.
## Keyword arguments
$STRICT_KWARG
Expand Down Expand Up @@ -423,6 +453,9 @@ function filter_to_module(file_analysis::FileAnalysis, mod::Module)
# Don't do that!
match = module_path -> all(Base.splat(isequal), zip(module_path, mod_path))

per_usage_info = filter(file_analysis.per_usage_info) do nt
return match(nt.module_path)
end
needs_explicit_import = filter(file_analysis.needs_explicit_import) do nt
return match(nt.module_path)
end
Expand All @@ -431,7 +464,7 @@ function filter_to_module(file_analysis::FileAnalysis, mod::Module)
end
mods_found = filter(!isempty, file_analysis.untainted_modules)
tainted = !any(match, mods_found)
return (; needs_explicit_import, unnecessary_explicit_import, tainted)
return (; needs_explicit_import, unnecessary_explicit_import, tainted, per_usage_info)
end

if VERSION < v"1.9-"
Expand Down Expand Up @@ -542,6 +575,8 @@ end
Analyzes the script located at `path` and prints information about reliance on implicit exports as well as any stale explicit imports (if `warn_stale=true`).
Note that the particular printing may change in future non-breaking releases of ExplicitImports.
!!! warning
The script (or at least, all imports in the script) must be run before this function can give reliable results, since it relies on introspecting what names are present in `Main`.
Expand Down
62 changes: 62 additions & 0 deletions src/checks.jl
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,23 @@ function Base.showerror(io::IO, e::UnanalyzableModuleException)
return nothing
end

struct QualifiedAccessesFromNonOwnerException <: Exception
mod::Module
accesses::Vector{@NamedTuple{name::Symbol,location::String,value::Any,
accessing_from::Module,
whichmodule::Module}}
end

function Base.showerror(io::IO, e::QualifiedAccessesFromNonOwnerException)
println(io, "QualifiedAccessesFromNonOwnerException")
println(io,
"Module `$(e.mod)` has qualified accesses to names via modules other than their owner as determined via `Base.which`:")
for row in e.accesses
println(io,
"- `$(row.name)` has owner $(row.whichmodule) but it was accessed from $(row.accessing_from) at $(row.location)")
end
end

"""
check_no_implicit_imports(mod::Module, file=pathof(mod); skip=(mod, Base, Core), ignore::Tuple=(), allow_unanalyzable::Tuple=())
Expand Down Expand Up @@ -170,3 +187,48 @@ function check_no_stale_explicit_imports(mod::Module, file=pathof(mod); ignore::
end
return nothing
end

"""
check_all_qualified_accesses_via_owners(mod::Module, file=pathof(mod); ignore::Tuple=(), require_submodule_access=false)
Checks that neither `mod` nor any of its submodules has accesses to names via modules other than their owner as determined by `Base.which` (unless the name is public or exported in that module),
throwing an `QualifiedAccessesFromNonOwnerException` if so, and returning `nothing` otherwise.
This can be used in a package's tests, e.g.
```julia
@test check_all_qualified_accesses_via_owners(MyPackage) === nothing
```
## Allowing some qualified accesses via non-owner modules
If `ignore` is supplied, it should be a tuple of `Symbol`s, representing names
that are allowed to be accessed from non-owner modules. For example,
```julia
@test check_all_qualified_accesses_via_owners(MyPackage; ignore=(:DataFrame,)) === nothing
```
would check there were no qualified accesses from non-owner modules besides that of the name `DataFrame`.
See also: [`improper_qualified_accesses`](@ref), which also describes the meaning of the keyword argument `require_submodule_access`. Note that while that function may increase in scope and report other kinds of improper accesses, `check_all_qualified_accesses_via_owners` will not.
"""
function check_all_qualified_accesses_via_owners(mod::Module, file=pathof(mod);
ignore::Tuple=(),
require_submodule_access=false)
check_file(file)
for (submodule, problematic) in
improper_qualified_accesses(mod, file; skip=ignore, require_submodule_access)
# drop unnecessary columns
problematic = [(;
(k => v for (k, v) in pairs(row) if k (:public_access,))...)
for row in problematic]
filter!(problematic) do nt
return nt.name ignore
end
if !isempty(problematic)
throw(QualifiedAccessesFromNonOwnerException(submodule, problematic))
end
end
return nothing
end
Loading

2 comments on commit ba63bec

@ericphanson
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JuliaRegistrator
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registration pull request created: JuliaRegistries/General/107639

Tip: Release Notes

Did you know you can add release notes too? Just add markdown formatted text underneath the comment after the text
"Release notes:" and it will be added to the registry PR, and if TagBot is installed it will also be added to the
release that TagBot creates. i.e.

@JuliaRegistrator register

Release notes:

## Breaking changes

- blah

To add them here just re-invoke and the PR will be updated.

Tagging

After the above pull request is merged, it is recommended that a tag is created on this repository for the registered package version.

This will be done automatically if the Julia TagBot GitHub Action is installed, or can be done manually through the github interface, or via:

git tag -a v1.5.0 -m "<description of version>" ba63bec7583e89a0bb9271c9051962396ad27ec8
git push origin v1.5.0

Please sign in to comment.