Skip to content

Commit f8fa71c

Browse files
authored
Return user friendly exe for Windows Store Python (#133)
1 parent 95dc38d commit f8fa71c

File tree

4 files changed

+165
-6
lines changed

4 files changed

+165
-6
lines changed

crates/pet-core/src/python_environment.rs

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,20 @@ impl PythonEnvironmentBuilder {
208208
symlinks: None,
209209
}
210210
}
211+
pub fn from_environment(env: PythonEnvironment) -> Self {
212+
Self {
213+
kind: env.kind,
214+
display_name: env.display_name,
215+
name: env.name,
216+
executable: env.executable,
217+
version: env.version,
218+
prefix: env.prefix,
219+
manager: env.manager,
220+
project: env.project,
221+
arch: env.arch,
222+
symlinks: env.symlinks,
223+
}
224+
}
211225

212226
pub fn display_name(mut self, display_name: Option<String>) -> Self {
213227
self.display_name = display_name;
@@ -269,7 +283,7 @@ impl PythonEnvironmentBuilder {
269283
}
270284

271285
fn update_symlinks_and_exe(&mut self, symlinks: Option<Vec<PathBuf>>) {
272-
let mut all = vec![];
286+
let mut all = self.symlinks.clone().unwrap_or_default();
273287
if let Some(ref exe) = self.executable {
274288
all.push(exe.clone());
275289
}
@@ -334,13 +348,25 @@ fn get_shortest_executable(
334348
exes: &Option<Vec<PathBuf>>,
335349
) -> Option<PathBuf> {
336350
// For windows store, the exe should always be the one in the WindowsApps folder.
351+
// & it must be the exe that is of the form Python3.12.exe
352+
// We will never use Python.exe nor Python3.exe as the shortest paths
353+
// See README.md
337354
if *kind == Some(PythonEnvironmentKind::WindowsStore) {
338355
if let Some(exes) = exes {
339356
if let Some(exe) = exes.iter().find(|e| {
340357
e.to_string_lossy().contains("AppData")
341358
&& e.to_string_lossy().contains("Local")
342359
&& e.to_string_lossy().contains("Microsoft")
343360
&& e.to_string_lossy().contains("WindowsApps")
361+
// Exe must be in the WindowsApps directory.
362+
&& e.parent()
363+
.map(|p| p.ends_with("WindowsApps"))
364+
.unwrap_or_default()
365+
// Always give preference to the exe Python3.12.exe or the like,
366+
// Over Python.exe and Python3.exe
367+
// This is to be consistent with the exe we choose for the Windows Store env.
368+
// See README.md
369+
&& e.file_name().map(|f| f.to_string_lossy().to_lowercase().starts_with("python3.")).unwrap_or_default()
344370
}) {
345371
return Some(exe.clone());
346372
}
@@ -386,3 +412,27 @@ pub fn get_environment_key(env: &PythonEnvironment) -> Option<PathBuf> {
386412
None
387413
}
388414
}
415+
416+
#[cfg(test)]
417+
mod tests {
418+
use super::*;
419+
420+
#[test]
421+
#[cfg(windows)]
422+
fn shorted_exe_path_windows_store() {
423+
let exes = vec![
424+
PathBuf::from("C:\\Users\\user\\AppData\\Local\\Microsoft\\WindowsApps\\Python3.12.exe"),
425+
PathBuf::from("C:\\Users\\user\\AppData\\Local\\Microsoft\\WindowsApps\\Python3.exe"),
426+
PathBuf::from("C:\\Users\\user\\AppData\\Local\\Microsoft\\WindowsApps\\Python.exe"),
427+
PathBuf::from("C:\\Users\\donja\\AppData\\Local\\Microsoft\\WindowsApps\\PythonSoftwareFoundation.Python.3.10_qbz5n2kfra8p0\\python.exe"),
428+
PathBuf::from("C:\\Users\\donja\\AppData\\Local\\Microsoft\\WindowsApps\\PythonSoftwareFoundation.Python.3.10_qbz5n2kfra8p0\\python3.exe"),
429+
PathBuf::from("C:\\Users\\donja\\AppData\\Local\\Microsoft\\WindowsApps\\PythonSoftwareFoundation.Python.3.10_qbz5n2kfra8p0\\python12.exe"),
430+
];
431+
assert_eq!(
432+
get_shortest_executable(&Some(PythonEnvironmentKind::WindowsStore), &Some(exes)),
433+
Some(PathBuf::from(
434+
"C:\\Users\\user\\AppData\\Local\\Microsoft\\WindowsApps\\Python3.12.exe"
435+
))
436+
);
437+
}
438+
}

crates/pet-windows-store/README.md

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
## Known Issues
44

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

712
```rust
813
for directory under `<home>/AppData/Local/Microsoft/WindowsApps`:
@@ -17,7 +22,41 @@ for directory under `<home>/AppData/Local/Microsoft/WindowsApps`:
1722
key = `<app_model_key>/Repository/Packages/<package_name>`
1823
env_path = `<key>/(PackageRootFolder)`
1924
display_name = `<key>/(DisplayName)`
20-
exe = `python.exe`
25+
26+
// Get the first 2 parts of the version from the path
27+
// directory = \AppData\Local\Microsoft\WindowsApps\PythonSoftwareFoundation.Python.3.9_qbz5n2kfra8p0\python.exe
28+
// In this case first 2 parts are `3.9`
29+
// Now look for a file named `python3.9.exe` in the `WindowsApps` directory (parent directory)
30+
// If it exists, then use that as a symlink as well
31+
// As a result that exe will have a shorter path, hence thats what users will see
32+
exe = `python.exe` or `pythonX.Y.exe`
33+
2134
// No way to get the full version information.
2235
👍 track this environment
2336
```
37+
38+
## Notes
39+
40+
### Why will `/WindowsApps/python3.exe` & `/WindowsApps/python.exe` will never be returned as preferred exes
41+
42+
Assume we have Pythoon 3.10 and Python 3.12 installed from Windows Store.
43+
Now we'll have the following exes in the `WindowsApps` directory:
44+
45+
- `/WindowsApps/python3.10.exe`
46+
- `/WindowsApps/python3.12.exe`
47+
- `/WindowsApps/python3.exe`
48+
- `/WindowsApps/python.exe`.
49+
50+
However we will not know what Python3.exe and Python.exe point to.
51+
The only way to determine this is by running the exe and checking the version.
52+
But that will slow discovery, hence we will not spawn those and never return them either during a regular discovery.
53+
54+
### `/WindowsApps/python3.exe` & `/WindowsApps/python.exe` can get returned as symlinks
55+
56+
If user has just Python 3.10 installed, then `/WindowsApps/python3.exe` & `/WindowsApps/python3.10.exe` will be returned as symlinks.
57+
58+
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:
59+
60+
- `C:\\Program Files\\WindowsApps\\PythonSoftwareFoundation.Python.3.10_3.10.3056.0_x64__qbz5n2kfra8p0\\python.exe`.
61+
62+
From here we know the enviroment details, and the original exe will be returned as a symlink.

crates/pet-windows-store/src/environments.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ use pet_core::{arch::Architecture, python_environment::PythonEnvironmentBuilder}
1414
#[cfg(windows)]
1515
use pet_fs::path::norm_case;
1616
#[cfg(windows)]
17+
use pet_python_utils::executable::find_executables;
18+
#[cfg(windows)]
1719
use regex::Regex;
1820
use std::path::PathBuf;
1921
#[cfg(windows)]
@@ -39,6 +41,8 @@ struct PotentialPython {
3941
exe: Option<PathBuf>,
4042
#[allow(dead_code)]
4143
version: String,
44+
#[allow(dead_code)]
45+
symlinks: Vec<PathBuf>,
4246
}
4347

4448
#[cfg(windows)]
@@ -121,6 +125,7 @@ pub fn list_store_pythons(environment: &EnvVariables) -> Option<Vec<PythonEnviro
121125
path: Some(path.clone()),
122126
name: Some(name.clone()),
123127
version: simple_version.to_string(),
128+
symlinks: find_symlinks(path, simple_version.to_string()),
124129
..Default::default()
125130
};
126131
potential_matches.insert(simple_version.to_string(), item);
@@ -146,6 +151,7 @@ pub fn list_store_pythons(environment: &EnvVariables) -> Option<Vec<PythonEnviro
146151
let item = PotentialPython {
147152
exe: Some(path.clone()),
148153
version: simple_version.to_string(),
154+
symlinks: find_symlinks(path, simple_version.to_string()),
149155
..Default::default()
150156
};
151157
potential_matches.insert(simple_version.to_string(), item);
@@ -177,6 +183,50 @@ pub fn list_store_pythons(environment: &EnvVariables) -> Option<Vec<PythonEnviro
177183
Some(python_envs)
178184
}
179185

186+
/// Given an exe from a sub directory of WindowsApp path, find the symlinks (reparse points)
187+
/// for the same environment but from the WindowsApp directory.
188+
#[cfg(windows)]
189+
fn find_symlinks(exe_in_windows_app_path: PathBuf, version: String) -> Vec<PathBuf> {
190+
let mut symlinks = vec![];
191+
if let Some(bin_dir) = exe_in_windows_app_path.parent() {
192+
if let Some(windows_app_path) = bin_dir.parent() {
193+
// Ensure we're in the right place
194+
if windows_app_path.ends_with("WindowsApp") {
195+
return vec![];
196+
}
197+
198+
let possible_exe =
199+
windows_app_path.join(PathBuf::from(format!("python{}.exe", version)));
200+
if possible_exe.exists() {
201+
symlinks.push(possible_exe);
202+
}
203+
204+
// How many exes do we have that look like with Python3.x.exe
205+
// If we have Python3.12.exe & Python3.10.exe, then we have absolutely no idea whether
206+
// the exes Python3.exe and Python.exe belong to 3.12 or 3.10 without spawning.
207+
// In those cases we will not bother figuring those out.
208+
// However if we have just one Python exe of the form Python3.x.ex, then python.exe and Python3.exe are symlinks.
209+
let mut number_of_python_exes_with_versions = 0;
210+
let mut exes = vec![];
211+
find_executables(windows_app_path)
212+
.into_iter()
213+
.for_each(|exe| {
214+
if let Some(name) = exe.file_name().and_then(|s| s.to_str()) {
215+
if name.to_lowercase().starts_with("python3.") {
216+
number_of_python_exes_with_versions += 1;
217+
}
218+
exes.push(exe);
219+
}
220+
});
221+
222+
if number_of_python_exes_with_versions == 1 {
223+
symlinks.append(&mut exes);
224+
}
225+
}
226+
}
227+
symlinks
228+
}
229+
180230
#[cfg(windows)]
181231
#[derive(Debug)]
182232
struct StorePythonInfo {

crates/pet-windows-store/src/lib.rs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ impl Locator for WindowsStore {
6161

6262
#[cfg(windows)]
6363
fn try_from(&self, env: &PythonEnv) -> Option<PythonEnvironment> {
64+
use std::path::PathBuf;
65+
66+
use pet_core::python_environment::PythonEnvironmentBuilder;
6467
use pet_virtualenv::is_virtualenv;
6568

6669
// Assume we create a virtual env from a python install,
@@ -69,11 +72,28 @@ impl Locator for WindowsStore {
6972
if is_virtualenv(env) {
7073
return None;
7174
}
75+
let list_of_possible_exes = vec![env.executable.clone()]
76+
.into_iter()
77+
.chain(env.symlinks.clone().unwrap_or_default().into_iter())
78+
.collect::<Vec<PathBuf>>();
7279
if let Some(environments) = self.find_with_cache() {
7380
for found_env in environments {
74-
if let Some(ref python_executable_path) = found_env.executable {
75-
if python_executable_path == &env.executable {
76-
return Some(found_env);
81+
if let Some(symlinks) = &found_env.symlinks {
82+
// Check if we have found this exe.
83+
if list_of_possible_exes
84+
.iter()
85+
.any(|exe| symlinks.contains(exe))
86+
{
87+
// Its possible the env discovery was not aware of the symlink
88+
// E.g. if we are asked to resolve `../WindowsApp/python.exe`
89+
// We will have no idea, hence this will get spawned, and then exe
90+
// might be something like `../WindowsApp/PythonSoftwareFoundation.Python.3.10...`
91+
// However the env found by the locator will almost never contain python.exe nor python3.exe
92+
// See README.md
93+
// As a result, we need to add those symlinks here.
94+
let builder = PythonEnvironmentBuilder::from_environment(found_env.clone())
95+
.symlinks(env.symlinks.clone());
96+
return Some(builder.build());
7797
}
7898
}
7999
}

0 commit comments

Comments
 (0)