Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Return user friendly exe for Windows Store Python #133

Merged
merged 3 commits into from
Jul 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion crates/pet-core/src/python_environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,20 @@ impl PythonEnvironmentBuilder {
symlinks: None,
}
}
pub fn from_environment(env: PythonEnvironment) -> Self {
Self {
kind: env.kind,
display_name: env.display_name,
name: env.name,
executable: env.executable,
version: env.version,
prefix: env.prefix,
manager: env.manager,
project: env.project,
arch: env.arch,
symlinks: env.symlinks,
}
}

pub fn display_name(mut self, display_name: Option<String>) -> Self {
self.display_name = display_name;
Expand Down Expand Up @@ -269,7 +283,7 @@ impl PythonEnvironmentBuilder {
}

fn update_symlinks_and_exe(&mut self, symlinks: Option<Vec<PathBuf>>) {
let mut all = vec![];
let mut all = self.symlinks.clone().unwrap_or_default();
if let Some(ref exe) = self.executable {
all.push(exe.clone());
}
Expand Down Expand Up @@ -334,13 +348,25 @@ fn get_shortest_executable(
exes: &Option<Vec<PathBuf>>,
) -> Option<PathBuf> {
// For windows store, the exe should always be the one in the WindowsApps folder.
// & it must be the exe that is of the form Python3.12.exe
// We will never use Python.exe nor Python3.exe as the shortest paths
// See README.md
if *kind == Some(PythonEnvironmentKind::WindowsStore) {
if let Some(exes) = exes {
if let Some(exe) = exes.iter().find(|e| {
e.to_string_lossy().contains("AppData")
&& e.to_string_lossy().contains("Local")
&& e.to_string_lossy().contains("Microsoft")
&& e.to_string_lossy().contains("WindowsApps")
// Exe must be in the WindowsApps directory.
&& e.parent()
.map(|p| p.ends_with("WindowsApps"))
.unwrap_or_default()
// Always give preference to the exe Python3.12.exe or the like,
// Over Python.exe and Python3.exe
// This is to be consistent with the exe we choose for the Windows Store env.
// See README.md
&& e.file_name().map(|f| f.to_string_lossy().to_lowercase().starts_with("python3.")).unwrap_or_default()
}) {
return Some(exe.clone());
}
Expand Down Expand Up @@ -386,3 +412,27 @@ pub fn get_environment_key(env: &PythonEnvironment) -> Option<PathBuf> {
None
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
#[cfg(windows)]
fn shorted_exe_path_windows_store() {
let exes = vec![
PathBuf::from("C:\\Users\\user\\AppData\\Local\\Microsoft\\WindowsApps\\Python3.12.exe"),
PathBuf::from("C:\\Users\\user\\AppData\\Local\\Microsoft\\WindowsApps\\Python3.exe"),
PathBuf::from("C:\\Users\\user\\AppData\\Local\\Microsoft\\WindowsApps\\Python.exe"),
PathBuf::from("C:\\Users\\donja\\AppData\\Local\\Microsoft\\WindowsApps\\PythonSoftwareFoundation.Python.3.10_qbz5n2kfra8p0\\python.exe"),
PathBuf::from("C:\\Users\\donja\\AppData\\Local\\Microsoft\\WindowsApps\\PythonSoftwareFoundation.Python.3.10_qbz5n2kfra8p0\\python3.exe"),
PathBuf::from("C:\\Users\\donja\\AppData\\Local\\Microsoft\\WindowsApps\\PythonSoftwareFoundation.Python.3.10_qbz5n2kfra8p0\\python12.exe"),
];
assert_eq!(
get_shortest_executable(&Some(PythonEnvironmentKind::WindowsStore), &Some(exes)),
Some(PathBuf::from(
"C:\\Users\\user\\AppData\\Local\\Microsoft\\WindowsApps\\Python3.12.exe"
))
);
}
}
43 changes: 41 additions & 2 deletions crates/pet-windows-store/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

## Known Issues

- Note possible to get the `version` information, hence not returned
- Note possible to get the `version` information, hence not returned (env will need to be resolved)
- If there are multiple versions of Windows Store Python installed,
none of the environments returned will contain the exes `.../WindowsApps/python.exe` or `.../WindowsApps/python3.exe`.
This is becase we will need to spawn both of these exes to figure out the env it belongs to.
For now, we will avoid that.
Upon resolving `.../WindowsApps/python.exe` or `.../WindowsApps/python3.exe` we will return the right information.

```rust
for directory under `<home>/AppData/Local/Microsoft/WindowsApps`:
Expand All @@ -17,7 +22,41 @@ for directory under `<home>/AppData/Local/Microsoft/WindowsApps`:
key = `<app_model_key>/Repository/Packages/<package_name>`
env_path = `<key>/(PackageRootFolder)`
display_name = `<key>/(DisplayName)`
exe = `python.exe`

// Get the first 2 parts of the version from the path
// directory = \AppData\Local\Microsoft\WindowsApps\PythonSoftwareFoundation.Python.3.9_qbz5n2kfra8p0\python.exe
// In this case first 2 parts are `3.9`
// Now look for a file named `python3.9.exe` in the `WindowsApps` directory (parent directory)
// If it exists, then use that as a symlink as well
// As a result that exe will have a shorter path, hence thats what users will see
exe = `python.exe` or `pythonX.Y.exe`

// No way to get the full version information.
👍 track this environment
```

## Notes

### Why will `/WindowsApps/python3.exe` & `/WindowsApps/python.exe` will never be returned as preferred exes

Assume we have Pythoon 3.10 and Python 3.12 installed from Windows Store.
Now we'll have the following exes in the `WindowsApps` directory:

- `/WindowsApps/python3.10.exe`
- `/WindowsApps/python3.12.exe`
- `/WindowsApps/python3.exe`
- `/WindowsApps/python.exe`.

However we will not know what Python3.exe and Python.exe point to.
The only way to determine this is by running the exe and checking the version.
But that will slow discovery, hence we will not spawn those and never return them either during a regular discovery.

### `/WindowsApps/python3.exe` & `/WindowsApps/python.exe` can get returned as symlinks

If user has just Python 3.10 installed, then `/WindowsApps/python3.exe` & `/WindowsApps/python3.10.exe` will be returned as symlinks.

Similarly, if caller of the API attempts to resolve either one of the above exes, then we'll end up spawning the exe and we get the fully qualified path such as the following:

- `C:\\Program Files\\WindowsApps\\PythonSoftwareFoundation.Python.3.10_3.10.3056.0_x64__qbz5n2kfra8p0\\python.exe`.

From here we know the enviroment details, and the original exe will be returned as a symlink.
50 changes: 50 additions & 0 deletions crates/pet-windows-store/src/environments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ use pet_core::{arch::Architecture, python_environment::PythonEnvironmentBuilder}
#[cfg(windows)]
use pet_fs::path::norm_case;
#[cfg(windows)]
use pet_python_utils::executable::find_executables;
#[cfg(windows)]
use regex::Regex;
use std::path::PathBuf;
#[cfg(windows)]
Expand All @@ -39,6 +41,8 @@ struct PotentialPython {
exe: Option<PathBuf>,
#[allow(dead_code)]
version: String,
#[allow(dead_code)]
symlinks: Vec<PathBuf>,
}

#[cfg(windows)]
Expand Down Expand Up @@ -121,6 +125,7 @@ pub fn list_store_pythons(environment: &EnvVariables) -> Option<Vec<PythonEnviro
path: Some(path.clone()),
name: Some(name.clone()),
version: simple_version.to_string(),
symlinks: find_symlinks(path, simple_version.to_string()),
..Default::default()
};
potential_matches.insert(simple_version.to_string(), item);
Expand All @@ -146,6 +151,7 @@ pub fn list_store_pythons(environment: &EnvVariables) -> Option<Vec<PythonEnviro
let item = PotentialPython {
exe: Some(path.clone()),
version: simple_version.to_string(),
symlinks: find_symlinks(path, simple_version.to_string()),
..Default::default()
};
potential_matches.insert(simple_version.to_string(), item);
Expand Down Expand Up @@ -177,6 +183,50 @@ pub fn list_store_pythons(environment: &EnvVariables) -> Option<Vec<PythonEnviro
Some(python_envs)
}

/// Given an exe from a sub directory of WindowsApp path, find the symlinks (reparse points)
/// for the same environment but from the WindowsApp directory.
#[cfg(windows)]
fn find_symlinks(exe_in_windows_app_path: PathBuf, version: String) -> Vec<PathBuf> {
let mut symlinks = vec![];
if let Some(bin_dir) = exe_in_windows_app_path.parent() {
if let Some(windows_app_path) = bin_dir.parent() {
// Ensure we're in the right place
if windows_app_path.ends_with("WindowsApp") {
return vec![];
}

let possible_exe =
windows_app_path.join(PathBuf::from(format!("python{}.exe", version)));
if possible_exe.exists() {
symlinks.push(possible_exe);
}

// How many exes do we have that look like with Python3.x.exe
// If we have Python3.12.exe & Python3.10.exe, then we have absolutely no idea whether
// the exes Python3.exe and Python.exe belong to 3.12 or 3.10 without spawning.
// In those cases we will not bother figuring those out.
// However if we have just one Python exe of the form Python3.x.ex, then python.exe and Python3.exe are symlinks.
let mut number_of_python_exes_with_versions = 0;
let mut exes = vec![];
find_executables(windows_app_path)
.into_iter()
.for_each(|exe| {
if let Some(name) = exe.file_name().and_then(|s| s.to_str()) {
if name.to_lowercase().starts_with("python3.") {
number_of_python_exes_with_versions += 1;
}
exes.push(exe);
}
});

if number_of_python_exes_with_versions == 1 {
symlinks.append(&mut exes);
}
}
}
symlinks
}

#[cfg(windows)]
#[derive(Debug)]
struct StorePythonInfo {
Expand Down
26 changes: 23 additions & 3 deletions crates/pet-windows-store/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ impl Locator for WindowsStore {

#[cfg(windows)]
fn try_from(&self, env: &PythonEnv) -> Option<PythonEnvironment> {
use std::path::PathBuf;

use pet_core::python_environment::PythonEnvironmentBuilder;
use pet_virtualenv::is_virtualenv;

// Assume we create a virtual env from a python install,
Expand All @@ -69,11 +72,28 @@ impl Locator for WindowsStore {
if is_virtualenv(env) {
return None;
}
let list_of_possible_exes = vec![env.executable.clone()]
.into_iter()
.chain(env.symlinks.clone().unwrap_or_default().into_iter())
.collect::<Vec<PathBuf>>();
if let Some(environments) = self.find_with_cache() {
for found_env in environments {
if let Some(ref python_executable_path) = found_env.executable {
if python_executable_path == &env.executable {
return Some(found_env);
if let Some(symlinks) = &found_env.symlinks {
// Check if we have found this exe.
if list_of_possible_exes
.iter()
.any(|exe| symlinks.contains(exe))
{
// Its possible the env discovery was not aware of the symlink
// E.g. if we are asked to resolve `../WindowsApp/python.exe`
// We will have no idea, hence this will get spawned, and then exe
// might be something like `../WindowsApp/PythonSoftwareFoundation.Python.3.10...`
// However the env found by the locator will almost never contain python.exe nor python3.exe
// See README.md
// As a result, we need to add those symlinks here.
let builder = PythonEnvironmentBuilder::from_environment(found_env.clone())
.symlinks(env.symlinks.clone());
return Some(builder.build());
}
}
}
Expand Down
Loading