diff --git a/Cargo.lock b/Cargo.lock index e79e37b6e8..3880a9982a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1971,6 +1971,7 @@ dependencies = [ "ruff_python_ast", "rustc-hash 2.0.0", "salsa", + "thiserror", "tracing", ] diff --git a/crates/red_knot/src/main.rs b/crates/red_knot/src/main.rs index bddaa5000a..1a6a555be2 100644 --- a/crates/red_knot/src/main.rs +++ b/crates/red_knot/src/main.rs @@ -3,6 +3,7 @@ use std::sync::Mutex; use clap::Parser; use crossbeam::channel as crossbeam_channel; +use red_knot_workspace::site_packages::site_packages_dirs_of_venv; use tracing::subscriber::Interest; use tracing::{Level, Metadata}; use tracing_subscriber::filter::LevelFilter; @@ -41,6 +42,17 @@ struct Args { )] current_directory: Option, + #[arg( + long, + help = "Path to the virtual environment the project uses", + long_help = "\ +Path to the virtual environment the project uses. \ +If provided, red-knot will use the `site-packages` directory of this virtual environment \ +to resolve type information for the project's third-party dependencies.", + value_name = "PATH" + )] + venv_path: Option, + #[arg( long, value_name = "DIRECTORY", @@ -87,6 +99,7 @@ pub fn main() -> anyhow::Result<()> { current_directory, custom_typeshed_dir, extra_search_path: extra_paths, + venv_path, target_version, verbosity, watch, @@ -120,6 +133,17 @@ pub fn main() -> anyhow::Result<()> { let workspace_metadata = WorkspaceMetadata::from_path(system.current_directory(), &system).unwrap(); + let site_packages = if let Some(venv_path) = venv_path { + let venv_path = system.canonicalize_path(&venv_path).unwrap_or(venv_path); + assert!( + system.is_directory(&venv_path), + "Provided venv-path {venv_path} is not a directory!" + ); + site_packages_dirs_of_venv(&venv_path, &system).unwrap() + } else { + vec![] + }; + // TODO: Respect the settings from the workspace metadata. when resolving the program settings. let program_settings = ProgramSettings { target_version: target_version.into(), @@ -127,7 +151,7 @@ pub fn main() -> anyhow::Result<()> { extra_paths, src_root: workspace_metadata.root().to_path_buf(), custom_typeshed: custom_typeshed_dir, - site_packages: vec![], + site_packages, }, }; diff --git a/crates/red_knot_module_resolver/src/path.rs b/crates/red_knot_module_resolver/src/path.rs index ec58973495..7dc5948386 100644 --- a/crates/red_knot_module_resolver/src/path.rs +++ b/crates/red_knot_module_resolver/src/path.rs @@ -446,6 +446,12 @@ impl SearchPath { } /// Create a new search path pointing to the `site-packages` directory on disk + /// + /// TODO: the validation done here is somewhat redundant given that `site-packages` + /// are already validated at a higher level by the time we get here. + /// However, removing the validation here breaks some file-watching tests -- and + /// ultimately we'll probably want all search paths to be validated before a + /// `Program` is instantiated, so it doesn't seem like a huge priority right now. pub(crate) fn site_packages( system: &dyn System, root: SystemPathBuf, diff --git a/crates/red_knot_module_resolver/src/resolver.rs b/crates/red_knot_module_resolver/src/resolver.rs index 56ddf68b76..8150643b54 100644 --- a/crates/red_knot_module_resolver/src/resolver.rs +++ b/crates/red_knot_module_resolver/src/resolver.rs @@ -128,12 +128,16 @@ fn try_resolve_module_resolution_settings( site_packages, } = program.search_paths(db.upcast()); + if !extra_paths.is_empty() { + tracing::info!("Extra search paths: {extra_paths:?}"); + } + if let Some(custom_typeshed) = custom_typeshed { tracing::info!("Custom typeshed directory: {custom_typeshed}"); } - if !extra_paths.is_empty() { - tracing::info!("extra search paths: {extra_paths:?}"); + if !site_packages.is_empty() { + tracing::info!("Site-packages directories: {site_packages:?}"); } let system = db.system(); diff --git a/crates/red_knot_workspace/Cargo.toml b/crates/red_knot_workspace/Cargo.toml index 35c8cb0efa..39abc06207 100644 --- a/crates/red_knot_workspace/Cargo.toml +++ b/crates/red_knot_workspace/Cargo.toml @@ -24,6 +24,7 @@ crossbeam = { workspace = true } notify = { workspace = true } rustc-hash = { workspace = true } salsa = { workspace = true } +thiserror = { workspace = true } tracing = { workspace = true } [dev-dependencies] diff --git a/crates/red_knot_workspace/resources/test/empty-unix-venv/.gitignore b/crates/red_knot_workspace/resources/test/empty-unix-venv/.gitignore new file mode 100644 index 0000000000..f59ec20aab --- /dev/null +++ b/crates/red_knot_workspace/resources/test/empty-unix-venv/.gitignore @@ -0,0 +1 @@ +* \ No newline at end of file diff --git a/crates/red_knot_workspace/resources/test/empty-unix-venv/CACHEDIR.TAG b/crates/red_knot_workspace/resources/test/empty-unix-venv/CACHEDIR.TAG new file mode 100644 index 0000000000..bc1ecb967a --- /dev/null +++ b/crates/red_knot_workspace/resources/test/empty-unix-venv/CACHEDIR.TAG @@ -0,0 +1 @@ +Signature: 8a477f597d28d172789f06886806bc55 \ No newline at end of file diff --git a/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/activate b/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/activate new file mode 100644 index 0000000000..06480874a0 --- /dev/null +++ b/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/activate @@ -0,0 +1,108 @@ +# Copyright (c) 2020-202x The virtualenv developers +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +# This file must be used with "source bin/activate" *from bash* +# you cannot run it directly + + +if [ "${BASH_SOURCE-}" = "$0" ]; then + echo "You must source this script: \$ source $0" >&2 + exit 33 +fi + +deactivate () { + unset -f pydoc >/dev/null 2>&1 || true + + # reset old environment variables + # ! [ -z ${VAR+_} ] returns true if VAR is declared at all + if ! [ -z "${_OLD_VIRTUAL_PATH:+_}" ] ; then + PATH="$_OLD_VIRTUAL_PATH" + export PATH + unset _OLD_VIRTUAL_PATH + fi + if ! [ -z "${_OLD_VIRTUAL_PYTHONHOME+_}" ] ; then + PYTHONHOME="$_OLD_VIRTUAL_PYTHONHOME" + export PYTHONHOME + unset _OLD_VIRTUAL_PYTHONHOME + fi + + # The hash command must be called to get it to forget past + # commands. Without forgetting past commands the $PATH changes + # we made may not be respected + hash -r 2>/dev/null + + if ! [ -z "${_OLD_VIRTUAL_PS1+_}" ] ; then + PS1="$_OLD_VIRTUAL_PS1" + export PS1 + unset _OLD_VIRTUAL_PS1 + fi + + unset VIRTUAL_ENV + unset VIRTUAL_ENV_PROMPT + if [ ! "${1-}" = "nondestructive" ] ; then + # Self destruct! + unset -f deactivate + fi +} + +# unset irrelevant variables +deactivate nondestructive + +VIRTUAL_ENV='/Users/alexw/dev/ruff/crates/red_knot_workspace/resources/test/empty-test-venv' +if ([ "$OSTYPE" = "cygwin" ] || [ "$OSTYPE" = "msys" ]) && $(command -v cygpath &> /dev/null) ; then + VIRTUAL_ENV=$(cygpath -u "$VIRTUAL_ENV") +fi +export VIRTUAL_ENV + +_OLD_VIRTUAL_PATH="$PATH" +PATH="$VIRTUAL_ENV/bin:$PATH" +export PATH + +if [ "x" != x ] ; then + VIRTUAL_ENV_PROMPT="" +else + VIRTUAL_ENV_PROMPT=$(basename "$VIRTUAL_ENV") +fi +export VIRTUAL_ENV_PROMPT + +# unset PYTHONHOME if set +if ! [ -z "${PYTHONHOME+_}" ] ; then + _OLD_VIRTUAL_PYTHONHOME="$PYTHONHOME" + unset PYTHONHOME +fi + +if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT-}" ] ; then + _OLD_VIRTUAL_PS1="${PS1-}" + PS1="(${VIRTUAL_ENV_PROMPT}) ${PS1-}" + export PS1 +fi + +# Make sure to unalias pydoc if it's already there +alias pydoc 2>/dev/null >/dev/null && unalias pydoc || true + +pydoc () { + python -m pydoc "$@" +} + +# The hash command must be called to get it to forget past +# commands. Without forgetting past commands the $PATH changes +# we made may not be respected +hash -r 2>/dev/null diff --git a/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/activate.bat b/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/activate.bat new file mode 100644 index 0000000000..4ed9b55308 --- /dev/null +++ b/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/activate.bat @@ -0,0 +1,59 @@ +@REM Copyright (c) 2020-202x The virtualenv developers +@REM +@REM Permission is hereby granted, free of charge, to any person obtaining +@REM a copy of this software and associated documentation files (the +@REM "Software"), to deal in the Software without restriction, including +@REM without limitation the rights to use, copy, modify, merge, publish, +@REM distribute, sublicense, and/or sell copies of the Software, and to +@REM permit persons to whom the Software is furnished to do so, subject to +@REM the following conditions: +@REM +@REM The above copyright notice and this permission notice shall be +@REM included in all copies or substantial portions of the Software. +@REM +@REM THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +@REM EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +@REM MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +@REM NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +@REM LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +@REM OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +@REM WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +@for %%i in ("/Users/alexw/dev/ruff/crates/red_knot_workspace/resources/test/empty-test-venv") do @set "VIRTUAL_ENV=%%~fi" + +@set "VIRTUAL_ENV_PROMPT=" +@if NOT DEFINED VIRTUAL_ENV_PROMPT ( + @for %%d in ("%VIRTUAL_ENV%") do @set "VIRTUAL_ENV_PROMPT=%%~nxd" +) + +@if defined _OLD_VIRTUAL_PROMPT ( + @set "PROMPT=%_OLD_VIRTUAL_PROMPT%" +) else ( + @if not defined PROMPT ( + @set "PROMPT=$P$G" + ) + @if not defined VIRTUAL_ENV_DISABLE_PROMPT ( + @set "_OLD_VIRTUAL_PROMPT=%PROMPT%" + ) +) +@if not defined VIRTUAL_ENV_DISABLE_PROMPT ( + @set "PROMPT=(%VIRTUAL_ENV_PROMPT%) %PROMPT%" +) + +@REM Don't use () to avoid problems with them in %PATH% +@if defined _OLD_VIRTUAL_PYTHONHOME @goto ENDIFVHOME + @set "_OLD_VIRTUAL_PYTHONHOME=%PYTHONHOME%" +:ENDIFVHOME + +@set PYTHONHOME= + +@REM if defined _OLD_VIRTUAL_PATH ( +@if not defined _OLD_VIRTUAL_PATH @goto ENDIFVPATH1 + @set "PATH=%_OLD_VIRTUAL_PATH%" +:ENDIFVPATH1 +@REM ) else ( +@if defined _OLD_VIRTUAL_PATH @goto ENDIFVPATH2 + @set "_OLD_VIRTUAL_PATH=%PATH%" +:ENDIFVPATH2 + +@set "PATH=%VIRTUAL_ENV%\bin;%PATH%" diff --git a/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/activate.csh b/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/activate.csh new file mode 100644 index 0000000000..e0e8bc4876 --- /dev/null +++ b/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/activate.csh @@ -0,0 +1,76 @@ +# Copyright (c) 2020-202x The virtualenv developers +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +# This file must be used with "source bin/activate.csh" *from csh*. +# You cannot run it directly. +# Created by Davide Di Blasi . + +set newline='\ +' + +alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH:q" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT:q" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate && unalias pydoc' + +# Unset irrelevant variables. +deactivate nondestructive + +setenv VIRTUAL_ENV '/Users/alexw/dev/ruff/crates/red_knot_workspace/resources/test/empty-test-venv' + +set _OLD_VIRTUAL_PATH="$PATH:q" +setenv PATH "$VIRTUAL_ENV:q/bin:$PATH:q" + + + +if ('' != "") then + setenv VIRTUAL_ENV_PROMPT '' +else + setenv VIRTUAL_ENV_PROMPT "$VIRTUAL_ENV:t:q" +endif + +if ( $?VIRTUAL_ENV_DISABLE_PROMPT ) then + if ( $VIRTUAL_ENV_DISABLE_PROMPT == "" ) then + set do_prompt = "1" + else + set do_prompt = "0" + endif +else + set do_prompt = "1" +endif + +if ( $do_prompt == "1" ) then + # Could be in a non-interactive environment, + # in which case, $prompt is undefined and we wouldn't + # care about the prompt anyway. + if ( $?prompt ) then + set _OLD_VIRTUAL_PROMPT="$prompt:q" + if ( "$prompt:q" =~ *"$newline:q"* ) then + : + else + set prompt = '('"$VIRTUAL_ENV_PROMPT:q"') '"$prompt:q" + endif + endif +endif + +unset env_name +unset do_prompt + +alias pydoc python -m pydoc + +rehash diff --git a/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/activate.fish b/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/activate.fish new file mode 100644 index 0000000000..a9044de83e --- /dev/null +++ b/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/activate.fish @@ -0,0 +1,124 @@ +# Copyright (c) 2020-202x The virtualenv developers +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +# This file must be used using `source bin/activate.fish` *within a running fish ( http://fishshell.com ) session*. +# Do not run it directly. + +function _bashify_path -d "Converts a fish path to something bash can recognize" + set fishy_path $argv + set bashy_path $fishy_path[1] + for path_part in $fishy_path[2..-1] + set bashy_path "$bashy_path:$path_part" + end + echo $bashy_path +end + +function _fishify_path -d "Converts a bash path to something fish can recognize" + echo $argv | tr ':' '\n' +end + +function deactivate -d 'Exit virtualenv mode and return to the normal environment.' + # reset old environment variables + if test -n "$_OLD_VIRTUAL_PATH" + # https://github.com/fish-shell/fish-shell/issues/436 altered PATH handling + if test (echo $FISH_VERSION | head -c 1) -lt 3 + set -gx PATH (_fishify_path "$_OLD_VIRTUAL_PATH") + else + set -gx PATH $_OLD_VIRTUAL_PATH + end + set -e _OLD_VIRTUAL_PATH + end + + if test -n "$_OLD_VIRTUAL_PYTHONHOME" + set -gx PYTHONHOME "$_OLD_VIRTUAL_PYTHONHOME" + set -e _OLD_VIRTUAL_PYTHONHOME + end + + if test -n "$_OLD_FISH_PROMPT_OVERRIDE" + and functions -q _old_fish_prompt + # Set an empty local `$fish_function_path` to allow the removal of `fish_prompt` using `functions -e`. + set -l fish_function_path + + # Erase virtualenv's `fish_prompt` and restore the original. + functions -e fish_prompt + functions -c _old_fish_prompt fish_prompt + functions -e _old_fish_prompt + set -e _OLD_FISH_PROMPT_OVERRIDE + end + + set -e VIRTUAL_ENV + set -e VIRTUAL_ENV_PROMPT + + if test "$argv[1]" != 'nondestructive' + # Self-destruct! + functions -e pydoc + functions -e deactivate + functions -e _bashify_path + functions -e _fishify_path + end +end + +# Unset irrelevant variables. +deactivate nondestructive + +set -gx VIRTUAL_ENV '/Users/alexw/dev/ruff/crates/red_knot_workspace/resources/test/empty-test-venv' + +# https://github.com/fish-shell/fish-shell/issues/436 altered PATH handling +if test (echo $FISH_VERSION | head -c 1) -lt 3 + set -gx _OLD_VIRTUAL_PATH (_bashify_path $PATH) +else + set -gx _OLD_VIRTUAL_PATH $PATH +end +set -gx PATH "$VIRTUAL_ENV"'/bin' $PATH + +# Prompt override provided? +# If not, just use the environment name. +if test -n '' + set -gx VIRTUAL_ENV_PROMPT '' +else + set -gx VIRTUAL_ENV_PROMPT (basename "$VIRTUAL_ENV") +end + +# Unset `$PYTHONHOME` if set. +if set -q PYTHONHOME + set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME + set -e PYTHONHOME +end + +function pydoc + python -m pydoc $argv +end + +if test -z "$VIRTUAL_ENV_DISABLE_PROMPT" + # Copy the current `fish_prompt` function as `_old_fish_prompt`. + functions -c fish_prompt _old_fish_prompt + + function fish_prompt + # Run the user's prompt first; it might depend on (pipe)status. + set -l prompt (_old_fish_prompt) + + printf '(%s) ' $VIRTUAL_ENV_PROMPT + + string join -- \n $prompt # handle multi-line prompts + end + + set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV" +end diff --git a/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/activate.nu b/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/activate.nu new file mode 100644 index 0000000000..1de75538f4 --- /dev/null +++ b/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/activate.nu @@ -0,0 +1,117 @@ +# Copyright (c) 2020-202x The virtualenv developers +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +# virtualenv activation module +# Activate with `overlay use activate.nu` +# Deactivate with `deactivate`, as usual +# +# To customize the overlay name, you can call `overlay use activate.nu as foo`, +# but then simply `deactivate` won't work because it is just an alias to hide +# the "activate" overlay. You'd need to call `overlay hide foo` manually. + +export-env { + def is-string [x] { + ($x | describe) == 'string' + } + + def has-env [...names] { + $names | each {|n| + $n in $env + } | all {|i| $i == true} + } + + # Emulates a `test -z`, but btter as it handles e.g 'false' + def is-env-true [name: string] { + if (has-env $name) { + # Try to parse 'true', '0', '1', and fail if not convertible + let parsed = (do -i { $env | get $name | into bool }) + if ($parsed | describe) == 'bool' { + $parsed + } else { + not ($env | get -i $name | is-empty) + } + } else { + false + } + } + + let virtual_env = '/Users/alexw/dev/ruff/crates/red_knot_workspace/resources/test/empty-test-venv' + let bin = 'bin' + + let is_windows = ($nu.os-info.family) == 'windows' + let path_name = (if (has-env 'Path') { + 'Path' + } else { + 'PATH' + } + ) + + let venv_path = ([$virtual_env $bin] | path join) + let new_path = ($env | get $path_name | prepend $venv_path) + + # If there is no default prompt, then use the env name instead + let virtual_env_prompt = (if ('' | is-empty) { + ($virtual_env | path basename) + } else { + '' + }) + + let new_env = { + $path_name : $new_path + VIRTUAL_ENV : $virtual_env + VIRTUAL_ENV_PROMPT : $virtual_env_prompt + } + + let new_env = (if (is-env-true 'VIRTUAL_ENV_DISABLE_PROMPT') { + $new_env + } else { + # Creating the new prompt for the session + let virtual_prefix = $'(char lparen)($virtual_env_prompt)(char rparen) ' + + # Back up the old prompt builder + let old_prompt_command = (if (has-env 'PROMPT_COMMAND') { + $env.PROMPT_COMMAND + } else { + '' + }) + + let new_prompt = (if (has-env 'PROMPT_COMMAND') { + if 'closure' in ($old_prompt_command | describe) { + {|| $'($virtual_prefix)(do $old_prompt_command)' } + } else { + {|| $'($virtual_prefix)($old_prompt_command)' } + } + } else { + {|| $'($virtual_prefix)' } + }) + + $new_env | merge { + PROMPT_COMMAND : $new_prompt + VIRTUAL_PREFIX : $virtual_prefix + } + }) + + # Environment variables that will be loaded as the virtual env + load-env $new_env +} + +export alias pydoc = python -m pydoc +export alias deactivate = overlay hide activate diff --git a/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/activate.ps1 b/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/activate.ps1 new file mode 100644 index 0000000000..2d2bc9ab7b --- /dev/null +++ b/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/activate.ps1 @@ -0,0 +1,82 @@ +# Copyright (c) 2020-202x The virtualenv developers +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +$script:THIS_PATH = $myinvocation.mycommand.path +$script:BASE_DIR = Split-Path (Resolve-Path "$THIS_PATH/..") -Parent + +function global:deactivate([switch] $NonDestructive) { + if (Test-Path variable:_OLD_VIRTUAL_PATH) { + $env:PATH = $variable:_OLD_VIRTUAL_PATH + Remove-Variable "_OLD_VIRTUAL_PATH" -Scope global + } + + if (Test-Path function:_old_virtual_prompt) { + $function:prompt = $function:_old_virtual_prompt + Remove-Item function:\_old_virtual_prompt + } + + if ($env:VIRTUAL_ENV) { + Remove-Item env:VIRTUAL_ENV -ErrorAction SilentlyContinue + } + + if ($env:VIRTUAL_ENV_PROMPT) { + Remove-Item env:VIRTUAL_ENV_PROMPT -ErrorAction SilentlyContinue + } + + if (!$NonDestructive) { + # Self destruct! + Remove-Item function:deactivate + Remove-Item function:pydoc + } +} + +function global:pydoc { + python -m pydoc $args +} + +# unset irrelevant variables +deactivate -nondestructive + +$VIRTUAL_ENV = $BASE_DIR +$env:VIRTUAL_ENV = $VIRTUAL_ENV + +if ("" -ne "") { + $env:VIRTUAL_ENV_PROMPT = "" +} +else { + $env:VIRTUAL_ENV_PROMPT = $( Split-Path $env:VIRTUAL_ENV -Leaf ) +} + +New-Variable -Scope global -Name _OLD_VIRTUAL_PATH -Value $env:PATH + +$env:PATH = "$env:VIRTUAL_ENV/bin:" + $env:PATH +if (!$env:VIRTUAL_ENV_DISABLE_PROMPT) { + function global:_old_virtual_prompt { + "" + } + $function:_old_virtual_prompt = $function:prompt + + function global:prompt { + # Add the custom prefix to the existing prompt + $previous_prompt_value = & $function:_old_virtual_prompt + ("(" + $env:VIRTUAL_ENV_PROMPT + ") " + $previous_prompt_value) + } +} diff --git a/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/activate_this.py b/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/activate_this.py new file mode 100644 index 0000000000..b3d0821f45 --- /dev/null +++ b/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/activate_this.py @@ -0,0 +1,59 @@ +# Copyright (c) 2020-202x The virtualenv developers +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +""" +Activate virtualenv for current interpreter: + +import runpy +runpy.run_path(this_file) + +This can be used when you must use an existing Python interpreter, not the virtualenv bin/python. +""" # noqa: D415 + +from __future__ import annotations + +import os +import site +import sys + +try: + abs_file = os.path.abspath(__file__) +except NameError as exc: + msg = "You must use import runpy; runpy.run_path(this_file)" + raise AssertionError(msg) from exc + +bin_dir = os.path.dirname(abs_file) +base = bin_dir[: -len("bin") - 1] # strip away the bin part from the __file__, plus the path separator + +# prepend bin to PATH (this file is inside the bin directory) +os.environ["PATH"] = os.pathsep.join([bin_dir, *os.environ.get("PATH", "").split(os.pathsep)]) +os.environ["VIRTUAL_ENV"] = base # virtual env is right above bin directory +os.environ["VIRTUAL_ENV_PROMPT"] = "" or os.path.basename(base) # noqa: SIM222 + +# add the virtual environments libraries to the host python import mechanism +prev_length = len(sys.path) +for lib in "../lib/python3.12/site-packages".split(os.pathsep): + path = os.path.realpath(os.path.join(bin_dir, lib)) + site.addsitedir(path) +sys.path[:] = sys.path[prev_length:] + sys.path[0:prev_length] + +sys.real_prefix = sys.prefix +sys.prefix = base diff --git a/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/deactivate.bat b/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/deactivate.bat new file mode 100644 index 0000000000..95af1351b0 --- /dev/null +++ b/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/deactivate.bat @@ -0,0 +1,39 @@ +@REM Copyright (c) 2020-202x The virtualenv developers +@REM +@REM Permission is hereby granted, free of charge, to any person obtaining +@REM a copy of this software and associated documentation files (the +@REM "Software"), to deal in the Software without restriction, including +@REM without limitation the rights to use, copy, modify, merge, publish, +@REM distribute, sublicense, and/or sell copies of the Software, and to +@REM permit persons to whom the Software is furnished to do so, subject to +@REM the following conditions: +@REM +@REM The above copyright notice and this permission notice shall be +@REM included in all copies or substantial portions of the Software. +@REM +@REM THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +@REM EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +@REM MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +@REM NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +@REM LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +@REM OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +@REM WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +@set VIRTUAL_ENV= +@set VIRTUAL_ENV_PROMPT= + +@REM Don't use () to avoid problems with them in %PATH% +@if not defined _OLD_VIRTUAL_PROMPT @goto ENDIFVPROMPT + @set "PROMPT=%_OLD_VIRTUAL_PROMPT%" + @set _OLD_VIRTUAL_PROMPT= +:ENDIFVPROMPT + +@if not defined _OLD_VIRTUAL_PYTHONHOME @goto ENDIFVHOME + @set "PYTHONHOME=%_OLD_VIRTUAL_PYTHONHOME%" + @set _OLD_VIRTUAL_PYTHONHOME= +:ENDIFVHOME + +@if not defined _OLD_VIRTUAL_PATH @goto ENDIFVPATH + @set "PATH=%_OLD_VIRTUAL_PATH%" + @set _OLD_VIRTUAL_PATH= +:ENDIFVPATH \ No newline at end of file diff --git a/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/pydoc.bat b/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/pydoc.bat new file mode 100644 index 0000000000..8a8d590d22 --- /dev/null +++ b/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/pydoc.bat @@ -0,0 +1,22 @@ +@REM Copyright (c) 2020-202x The virtualenv developers +@REM +@REM Permission is hereby granted, free of charge, to any person obtaining +@REM a copy of this software and associated documentation files (the +@REM "Software"), to deal in the Software without restriction, including +@REM without limitation the rights to use, copy, modify, merge, publish, +@REM distribute, sublicense, and/or sell copies of the Software, and to +@REM permit persons to whom the Software is furnished to do so, subject to +@REM the following conditions: +@REM +@REM The above copyright notice and this permission notice shall be +@REM included in all copies or substantial portions of the Software. +@REM +@REM THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +@REM EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +@REM MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +@REM NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +@REM LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +@REM OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +@REM WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +python.exe -m pydoc %* \ No newline at end of file diff --git a/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/python b/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/python new file mode 120000 index 0000000000..f14ea3e16c --- /dev/null +++ b/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/python @@ -0,0 +1 @@ +/Users/alexw/.pyenv/versions/3.12.4/bin/python3.12 \ No newline at end of file diff --git a/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/python3 b/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/python3 new file mode 120000 index 0000000000..d8654aa0e2 --- /dev/null +++ b/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/python3 @@ -0,0 +1 @@ +python \ No newline at end of file diff --git a/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/python3.12 b/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/python3.12 new file mode 120000 index 0000000000..d8654aa0e2 --- /dev/null +++ b/crates/red_knot_workspace/resources/test/empty-unix-venv/bin/python3.12 @@ -0,0 +1 @@ +python \ No newline at end of file diff --git a/crates/red_knot_workspace/resources/test/empty-unix-venv/lib/python3.12/site-packages/_virtualenv.pth b/crates/red_knot_workspace/resources/test/empty-unix-venv/lib/python3.12/site-packages/_virtualenv.pth new file mode 100644 index 0000000000..1c3ff99867 --- /dev/null +++ b/crates/red_knot_workspace/resources/test/empty-unix-venv/lib/python3.12/site-packages/_virtualenv.pth @@ -0,0 +1 @@ +import _virtualenv \ No newline at end of file diff --git a/crates/red_knot_workspace/resources/test/empty-unix-venv/lib/python3.12/site-packages/_virtualenv.py b/crates/red_knot_workspace/resources/test/empty-unix-venv/lib/python3.12/site-packages/_virtualenv.py new file mode 100644 index 0000000000..f5a0280481 --- /dev/null +++ b/crates/red_knot_workspace/resources/test/empty-unix-venv/lib/python3.12/site-packages/_virtualenv.py @@ -0,0 +1,103 @@ +"""Patches that are applied at runtime to the virtual environment.""" + +from __future__ import annotations + +import os +import sys + +VIRTUALENV_PATCH_FILE = os.path.join(__file__) + + +def patch_dist(dist): + """ + Distutils allows user to configure some arguments via a configuration file: + https://docs.python.org/3/install/index.html#distutils-configuration-files. + + Some of this arguments though don't make sense in context of the virtual environment files, let's fix them up. + """ # noqa: D205 + # we cannot allow some install config as that would get packages installed outside of the virtual environment + old_parse_config_files = dist.Distribution.parse_config_files + + def parse_config_files(self, *args, **kwargs): + result = old_parse_config_files(self, *args, **kwargs) + install = self.get_option_dict("install") + + if "prefix" in install: # the prefix governs where to install the libraries + install["prefix"] = VIRTUALENV_PATCH_FILE, os.path.abspath(sys.prefix) + for base in ("purelib", "platlib", "headers", "scripts", "data"): + key = f"install_{base}" + if key in install: # do not allow global configs to hijack venv paths + install.pop(key, None) + return result + + dist.Distribution.parse_config_files = parse_config_files + + +# Import hook that patches some modules to ignore configuration values that break package installation in case +# of virtual environments. +_DISTUTILS_PATCH = "distutils.dist", "setuptools.dist" +# https://docs.python.org/3/library/importlib.html#setting-up-an-importer + + +class _Finder: + """A meta path finder that allows patching the imported distutils modules.""" + + fullname = None + + # lock[0] is threading.Lock(), but initialized lazily to avoid importing threading very early at startup, + # because there are gevent-based applications that need to be first to import threading by themselves. + # See https://github.com/pypa/virtualenv/issues/1895 for details. + lock = [] # noqa: RUF012 + + def find_spec(self, fullname, path, target=None): # noqa: ARG002 + if fullname in _DISTUTILS_PATCH and self.fullname is None: + # initialize lock[0] lazily + if len(self.lock) == 0: + import threading + + lock = threading.Lock() + # there is possibility that two threads T1 and T2 are simultaneously running into find_spec, + # observing .lock as empty, and further going into hereby initialization. However due to the GIL, + # list.append() operation is atomic and this way only one of the threads will "win" to put the lock + # - that every thread will use - into .lock[0]. + # https://docs.python.org/3/faq/library.html#what-kinds-of-global-value-mutation-are-thread-safe + self.lock.append(lock) + + from functools import partial + from importlib.util import find_spec + + with self.lock[0]: + self.fullname = fullname + try: + spec = find_spec(fullname, path) + if spec is not None: + # https://www.python.org/dev/peps/pep-0451/#how-loading-will-work + is_new_api = hasattr(spec.loader, "exec_module") + func_name = "exec_module" if is_new_api else "load_module" + old = getattr(spec.loader, func_name) + func = self.exec_module if is_new_api else self.load_module + if old is not func: + try: # noqa: SIM105 + setattr(spec.loader, func_name, partial(func, old)) + except AttributeError: + pass # C-Extension loaders are r/o such as zipimporter with <3.7 + return spec + finally: + self.fullname = None + return None + + @staticmethod + def exec_module(old, module): + old(module) + if module.__name__ in _DISTUTILS_PATCH: + patch_dist(module) + + @staticmethod + def load_module(old, name): + module = old(name) + if module.__name__ in _DISTUTILS_PATCH: + patch_dist(module) + return module + + +sys.meta_path.insert(0, _Finder()) diff --git a/crates/red_knot_workspace/resources/test/empty-unix-venv/pyvenv.cfg b/crates/red_knot_workspace/resources/test/empty-unix-venv/pyvenv.cfg new file mode 100644 index 0000000000..b044f0a820 --- /dev/null +++ b/crates/red_knot_workspace/resources/test/empty-unix-venv/pyvenv.cfg @@ -0,0 +1,6 @@ +home = /Users/alexw/.pyenv/versions/3.12.4/bin +implementation = CPython +uv = 0.2.32 +version_info = 3.12.4 +include-system-site-packages = false +relocatable = false diff --git a/crates/red_knot_workspace/src/lib.rs b/crates/red_knot_workspace/src/lib.rs index f0b3f62a98..45a27012fc 100644 --- a/crates/red_knot_workspace/src/lib.rs +++ b/crates/red_knot_workspace/src/lib.rs @@ -1,4 +1,5 @@ pub mod db; pub mod lint; +pub mod site_packages; pub mod watch; pub mod workspace; diff --git a/crates/red_knot_workspace/src/site_packages.rs b/crates/red_knot_workspace/src/site_packages.rs new file mode 100644 index 0000000000..b457d6daca --- /dev/null +++ b/crates/red_knot_workspace/src/site_packages.rs @@ -0,0 +1,170 @@ +//! Utilities for finding the `site-packages` directory, +//! into which third-party packages are installed. +//! +//! The routines exposed by this module have different behaviour depending +//! on the platform of the *host machine*, which may be +//! different from the *target platform for type checking*. (A user +//! might be running red-knot on a Windows machine, but might +//! reasonably ask us to type-check code assuming that the code runs +//! on Linux.) + +use std::io; + +use ruff_db::system::{System, SystemPath, SystemPathBuf}; + +/// Attempt to retrieve the `site-packages` directory +/// associated with a given Python installation. +/// +/// `sys_prefix_path` is equivalent to the value of [`sys.prefix`] +/// at runtime in Python. For the case of a virtual environment, where a +/// Python binary is at `/.venv/bin/python`, `sys.prefix` is the path to +/// the virtual environment the Python binary lies inside, i.e. `/.venv`, +/// and `site-packages` will be at `.venv/Lib/site-packages`. System +/// Python installations generally work the same way: if a system Python +/// installation lies at `/opt/homebrew/bin/python`, `sys.prefix` will be +/// `/opt/homebrew`, and `site-packages` will be at +/// `/opt/homebrew/Lib/site-packages`. +/// +/// This routine does not verify that `sys_prefix_path` points +/// to an existing directory on disk; it is assumed that this has already +/// been checked. +/// +/// [`sys.prefix`]: https://docs.python.org/3/library/sys.html#sys.prefix +#[cfg(target_os = "windows")] +fn site_packages_dir_from_sys_prefix( + sys_prefix_path: &SystemPath, + system: &dyn System, +) -> Result { + let site_packages = venv_path.join("Lib/site-packages"); + system + .is_directory(&site_packages) + .then_some(site_packages) + .ok_or(SitePackagesDiscoveryError::NoSitePackagesDirFound) +} + +/// Attempt to retrieve the `site-packages` directory +/// associated with a given Python installation. +/// +/// `sys_prefix_path` is equivalent to the value of [`sys.prefix`] +/// at runtime in Python. For the case of a virtual environment, where a +/// Python binary is at `/.venv/bin/python`, `sys.prefix` is the path to +/// the virtual environment the Python binary lies inside, i.e. `/.venv`, +/// and `site-packages` will be at `.venv/lib/python3.X/site-packages`. +/// System Python installations generally work the same way: if a system +/// Python installation lies at `/opt/homebrew/bin/python`, `sys.prefix` +/// will be `/opt/homebrew`, and `site-packages` will be at +/// `/opt/homebrew/lib/python3.X/site-packages`. +/// +/// This routine does not verify that `sys_prefix_path` points +/// to an existing directory on disk; it is assumed that this has already +/// been checked. +/// +/// [`sys.prefix`]: https://docs.python.org/3/library/sys.html#sys.prefix +#[cfg(not(target_os = "windows"))] +fn site_packages_dir_from_sys_prefix( + sys_prefix_path: &SystemPath, + system: &dyn System, +) -> Result { + // In the Python standard library's `site.py` module (used for finding `site-packages` + // at runtime), we can find this in [the non-Windows branch]: + // + // ```py + // libdirs = [sys.platlibdir] + // if sys.platlibdir != "lib": + // libdirs.append("lib") + // ``` + // + // Pyright therefore searches for both a `lib/python3.X/site-packages` directory + // and a `lib64/python3.X/site-packages` directory on non-MacOS Unix systems, + // since `sys.platlibdir` can sometimes be equal to `"lib64"`. + // + // However, we only care about the `site-packages` directory insofar as it allows + // us to discover Python source code that can be used for inferring type + // information regarding third-party dependencies. That means that we don't need + // to care about any possible `lib64/site-packages` directories, since + // [the `sys`-module documentation] states that `sys.platlibdir` is *only* ever + // used for C extensions, never for pure-Python modules. + // + // [the non-Windows branch]: https://github.com/python/cpython/blob/a8be8fc6c4682089be45a87bd5ee1f686040116c/Lib/site.py#L401-L410 + // [the `sys`-module documentation]: https://docs.python.org/3/library/sys.html#sys.platlibdir + for entry_result in system.read_directory(&sys_prefix_path.join("lib"))? { + let Ok(entry) = entry_result else { + continue; + }; + if !entry.file_type().is_directory() { + continue; + } + + let path = entry.path(); + + // The `python3.x` part of the `site-packages` path can't be computed from + // the `--target-version` the user has passed, as they might be running Python 3.12 locally + // even if they've requested that we type check their code "as if" they're running 3.8. + // + // The `python3.x` part of the `site-packages` path *could* be computed + // by parsing the virtual environment's `pyvenv.cfg` file. + // Right now that seems like overkill, but in the future we may need to parse + // the `pyvenv.cfg` file anyway, in which case we could switch to that method + // rather than iterating through the whole directory until we find + // an entry where the last component of the path starts with `python3.` + if !path + .components() + .next_back() + .is_some_and(|last_part| last_part.as_str().starts_with("python3.")) + { + continue; + } + + let site_packages_candidate = path.join("site-packages"); + if system.is_directory(&site_packages_candidate) { + return Ok(site_packages_candidate); + } + } + Err(SitePackagesDiscoveryError::NoSitePackagesDirFound) +} + +#[derive(Debug, thiserror::Error)] +pub enum SitePackagesDiscoveryError { + #[error("Failed to search the virtual environment directory for `site-packages` due to {0}")] + CouldNotReadLibDirectory(#[from] io::Error), + #[error("Could not find the `site-packages` directory in the virtual environment")] + NoSitePackagesDirFound, +} + +/// Given a validated, canonicalized path to a virtual environment, +/// return a list of `site-packages` directories that are available from that environment. +/// +/// See the documentation for `site_packages_dir_from_sys_prefix` for more details. +/// +/// TODO: Currently we only ever return 1 path from this function: +/// the `site-packages` directory that is actually inside the virtual environment. +/// Some `site-packages` directories are able to also access system `site-packages` and +/// user `site-packages`, however. +pub fn site_packages_dirs_of_venv( + venv_path: &SystemPath, + system: &dyn System, +) -> Result, SitePackagesDiscoveryError> { + Ok(vec![site_packages_dir_from_sys_prefix(venv_path, system)?]) +} + +#[cfg(test)] +mod tests { + use ruff_db::system::{OsSystem, System, SystemPath}; + + use crate::site_packages::site_packages_dirs_of_venv; + + #[test] + // Windows venvs have different layouts, and we only have a Unix venv committed for now. + // This test is skipped on Windows until we commit a Windows venv. + #[cfg(not(target_os = "windows"))] + fn can_find_site_packages_dir_in_committed_venv() { + let path_to_venv = SystemPath::new("resources/test/empty-unix-venv"); + let system = OsSystem::default(); + + // if this doesn't hold true, the premise of the test is incorrect. + assert!(system.is_directory(path_to_venv)); + + let site_packages_dirs = site_packages_dirs_of_venv(path_to_venv, &system).unwrap(); + assert_eq!(site_packages_dirs.len(), 1); + } +}