mirror of https://github.com/astral-sh/uv
Add support for installing versioned Python executables on Windows (#8663)
Incorporating #8637 into #8458 - Adds `python-managed` feature selection to Windows CI for `python install` tests - Adds trampoline sniffing utilities to `uv-trampoline-builder` - Uses a trampoline to install Python executables into the `PATH` on Windows
This commit is contained in:
parent
f5a7d70642
commit
8d3408fe39
|
|
@ -296,7 +296,7 @@ jobs:
|
|||
# See https://github.com/astral-sh/uv/issues/6940
|
||||
UV_LINK_MODE: copy
|
||||
run: |
|
||||
cargo nextest run --no-default-features --features python,pypi --workspace --status-level skip --failure-output immediate-final --no-fail-fast -j 20 --final-status-level slow
|
||||
cargo nextest run --no-default-features --features python,pypi,python-managed --workspace --status-level skip --failure-output immediate-final --no-fail-fast -j 20 --final-status-level slow
|
||||
|
||||
- name: "Smoke test"
|
||||
working-directory: ${{ env.UV_WORKSPACE }}
|
||||
|
|
|
|||
|
|
@ -5074,6 +5074,7 @@ dependencies = [
|
|||
"uv-pypi-types",
|
||||
"uv-state",
|
||||
"uv-static",
|
||||
"uv-trampoline-builder",
|
||||
"uv-warnings",
|
||||
"which",
|
||||
"windows-registry 0.3.0",
|
||||
|
|
|
|||
|
|
@ -104,21 +104,22 @@ pub fn remove_symlink(path: impl AsRef<Path>) -> std::io::Result<()> {
|
|||
fs_err::remove_file(path.as_ref())
|
||||
}
|
||||
|
||||
/// Create a symlink at `dst` pointing to `src` or, on Windows, copy `src` to `dst`.
|
||||
/// Create a symlink at `dst` pointing to `src` on Unix or copy `src` to `dst` on Windows
|
||||
///
|
||||
/// This does not replace an existing symlink or file at `dst`.
|
||||
///
|
||||
/// This does not fallback to copying on Unix.
|
||||
///
|
||||
/// This function should only be used for files. If targeting a directory, use [`replace_symlink`]
|
||||
/// instead; it will use a junction on Windows, which is more performant.
|
||||
pub fn symlink_copy_fallback_file(
|
||||
src: impl AsRef<Path>,
|
||||
dst: impl AsRef<Path>,
|
||||
) -> std::io::Result<()> {
|
||||
pub fn symlink_or_copy_file(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
fs_err::copy(src.as_ref(), dst.as_ref())?;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(src.as_ref(), dst.as_ref())?;
|
||||
fs_err::os::unix::fs::symlink(src.as_ref(), dst.as_ref())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ uv-platform-tags = { workspace = true }
|
|||
uv-pypi-types = { workspace = true }
|
||||
uv-state = { workspace = true }
|
||||
uv-static = { workspace = true }
|
||||
uv-trampoline-builder = { workspace = true }
|
||||
uv-warnings = { workspace = true }
|
||||
|
||||
anyhow = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
use core::fmt;
|
||||
use fs_err as fs;
|
||||
use itertools::Itertools;
|
||||
use std::cmp::Reverse;
|
||||
use std::ffi::OsStr;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
|
||||
use fs_err as fs;
|
||||
use itertools::Itertools;
|
||||
use same_file::is_same_file;
|
||||
use thiserror::Error;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use uv_fs::{symlink_or_copy_file, LockedFile, Simplified};
|
||||
use uv_state::{StateBucket, StateStore};
|
||||
use uv_static::EnvVars;
|
||||
use uv_trampoline_builder::{windows_python_launcher, Launcher};
|
||||
|
||||
use crate::downloads::Error as DownloadError;
|
||||
use crate::implementation::{
|
||||
|
|
@ -21,9 +26,6 @@ use crate::platform::Error as PlatformError;
|
|||
use crate::platform::{Arch, Libc, Os};
|
||||
use crate::python_version::PythonVersion;
|
||||
use crate::{PythonRequest, PythonVariant};
|
||||
use uv_fs::{LockedFile, Simplified};
|
||||
use uv_static::EnvVars;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
|
|
@ -74,6 +76,8 @@ pub enum Error {
|
|||
},
|
||||
#[error("Failed to find a directory to install executables into")]
|
||||
NoExecutableDirectory,
|
||||
#[error(transparent)]
|
||||
LauncherError(#[from] uv_trampoline_builder::Error),
|
||||
#[error("Failed to read managed Python directory name: {0}")]
|
||||
NameError(String),
|
||||
#[error("Failed to construct absolute path to managed Python directory: {}", _0.user_display())]
|
||||
|
|
@ -425,7 +429,7 @@ impl ManagedPythonInstallation {
|
|||
continue;
|
||||
}
|
||||
|
||||
match uv_fs::symlink_copy_fallback_file(&python, &executable) {
|
||||
match uv_fs::symlink_or_copy_file(&python, &executable) {
|
||||
Ok(()) => {
|
||||
debug!(
|
||||
"Created link {} -> {}",
|
||||
|
|
@ -475,29 +479,68 @@ impl ManagedPythonInstallation {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a link to the Python executable in the given `bin` directory.
|
||||
pub fn create_bin_link(&self, bin: &Path) -> Result<PathBuf, Error> {
|
||||
/// Create a link to the managed Python executable.
|
||||
///
|
||||
/// If the file already exists at the target path, an error will be returned.
|
||||
pub fn create_bin_link(&self, target: &Path) -> Result<(), Error> {
|
||||
let python = self.executable();
|
||||
|
||||
let bin = target.parent().ok_or(Error::NoExecutableDirectory)?;
|
||||
fs_err::create_dir_all(bin).map_err(|err| Error::ExecutableDirectory {
|
||||
to: bin.to_path_buf(),
|
||||
err,
|
||||
})?;
|
||||
|
||||
// TODO(zanieb): Add support for a "default" which
|
||||
let python_in_bin = bin.join(self.key.versioned_executable_name());
|
||||
|
||||
match uv_fs::symlink_copy_fallback_file(&python, &python_in_bin) {
|
||||
Ok(()) => Ok(python_in_bin),
|
||||
if cfg!(unix) {
|
||||
// Note this will never copy on Unix — we use it here to allow compilation on Windows
|
||||
match symlink_or_copy_file(&python, target) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
||||
Err(Error::MissingExecutable(python.clone()))
|
||||
}
|
||||
Err(err) => Err(Error::LinkExecutable {
|
||||
from: python,
|
||||
to: python_in_bin,
|
||||
to: target.to_path_buf(),
|
||||
err,
|
||||
}),
|
||||
}
|
||||
} else if cfg!(windows) {
|
||||
// TODO(zanieb): Install GUI launchers as well
|
||||
let launcher = windows_python_launcher(&python, false)?;
|
||||
|
||||
// OK to use `std::fs` here, `fs_err` does not support `File::create_new` and we attach
|
||||
// error context anyway
|
||||
#[allow(clippy::disallowed_types)]
|
||||
{
|
||||
std::fs::File::create_new(target)
|
||||
.and_then(|mut file| file.write_all(launcher.as_ref()))
|
||||
.map_err(|err| Error::LinkExecutable {
|
||||
from: python,
|
||||
to: target.to_path_buf(),
|
||||
err,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
unimplemented!("Only Windows and Unix systems are supported.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the path is a link to this installation's binary, e.g., as created by
|
||||
/// [`ManagedPythonInstallation::create_bin_link`].
|
||||
pub fn is_bin_link(&self, path: &Path) -> bool {
|
||||
if cfg!(unix) {
|
||||
is_same_file(path, self.executable()).unwrap_or_default()
|
||||
} else if cfg!(windows) {
|
||||
let Some(launcher) = Launcher::try_from_path(path).unwrap_or_default() else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(launcher.kind, uv_trampoline_builder::LauncherKind::Python) {
|
||||
return false;
|
||||
}
|
||||
launcher.python_path == self.executable()
|
||||
} else {
|
||||
unreachable!("Only Windows and Unix are supported")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ workspace = true
|
|||
|
||||
[dependencies]
|
||||
uv-fs = { workspace = true }
|
||||
|
||||
fs-err = {workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
use std::io::{Cursor, Write};
|
||||
use std::path::Path;
|
||||
use std::io::{self, Cursor, Read, Seek, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::Utf8Error;
|
||||
|
||||
use fs_err::File;
|
||||
use thiserror::Error;
|
||||
use uv_fs::Simplified;
|
||||
use zip::write::FileOptions;
|
||||
|
|
@ -30,10 +32,95 @@ const LAUNCHER_AARCH64_GUI: &[u8] =
|
|||
const LAUNCHER_AARCH64_CONSOLE: &[u8] =
|
||||
include_bytes!("../../uv-trampoline/trampolines/uv-trampoline-aarch64-console.exe");
|
||||
|
||||
// See `uv-trampoline::bounce`. These numbers must match.
|
||||
const PATH_LENGTH_SIZE: usize = size_of::<u32>();
|
||||
const MAX_PATH_LENGTH: u32 = 32 * 1024;
|
||||
const MAGIC_NUMBER_SIZE: usize = 4;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Launcher {
|
||||
pub kind: LauncherKind,
|
||||
pub python_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Launcher {
|
||||
/// Read [`Launcher`] metadata from a trampoline executable file.
|
||||
///
|
||||
/// Returns `Ok(None)` if the file is not a trampoline executable.
|
||||
/// Returns `Err` if the file looks like a trampoline executable but is formatted incorrectly.
|
||||
///
|
||||
/// Expects the following metadata to be at the end of the file:
|
||||
///
|
||||
/// ```text
|
||||
/// - file path (no greater than 32KB)
|
||||
/// - file path length (u32)
|
||||
/// - magic number(4 bytes)
|
||||
/// ```
|
||||
///
|
||||
/// This should only be used on Windows, but should just return `Ok(None)` on other platforms.
|
||||
///
|
||||
/// This is an implementation of [`uv-trampoline::bounce::read_trampoline_metadata`] that
|
||||
/// returns errors instead of panicking. Unlike the utility there, we don't assume that the
|
||||
/// file we are reading is a trampoline.
|
||||
#[allow(clippy::cast_possible_wrap)]
|
||||
pub fn try_from_path(path: &Path) -> Result<Option<Self>, Error> {
|
||||
let mut file = File::open(path)?;
|
||||
|
||||
// Read the magic number
|
||||
let Some(kind) = LauncherKind::try_from_file(&mut file)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Seek to the start of the path length.
|
||||
let path_length_offset = (MAGIC_NUMBER_SIZE + PATH_LENGTH_SIZE) as i64;
|
||||
file.seek(io::SeekFrom::End(-path_length_offset))
|
||||
.map_err(|err| {
|
||||
Error::InvalidLauncherSeek("path length".to_string(), path_length_offset, err)
|
||||
})?;
|
||||
|
||||
// Read the path length
|
||||
let mut buffer = [0; PATH_LENGTH_SIZE];
|
||||
file.read_exact(&mut buffer)
|
||||
.map_err(|err| Error::InvalidLauncherRead("path length".to_string(), err))?;
|
||||
|
||||
let path_length = {
|
||||
let raw_length = u32::from_le_bytes(buffer);
|
||||
|
||||
if raw_length > MAX_PATH_LENGTH {
|
||||
return Err(Error::InvalidPathLength(raw_length));
|
||||
}
|
||||
|
||||
// SAFETY: Above we guarantee the length is less than 32KB
|
||||
raw_length as usize
|
||||
};
|
||||
|
||||
// Seek to the start of the path
|
||||
let path_offset = (MAGIC_NUMBER_SIZE + PATH_LENGTH_SIZE + path_length) as i64;
|
||||
file.seek(io::SeekFrom::End(-path_offset)).map_err(|err| {
|
||||
Error::InvalidLauncherSeek("executable path".to_string(), path_offset, err)
|
||||
})?;
|
||||
|
||||
// Read the path
|
||||
let mut buffer = vec![0u8; path_length];
|
||||
file.read_exact(&mut buffer)
|
||||
.map_err(|err| Error::InvalidLauncherRead("executable path".to_string(), err))?;
|
||||
|
||||
let path = PathBuf::from(
|
||||
String::from_utf8(buffer).map_err(|err| Error::InvalidPath(err.utf8_error()))?,
|
||||
);
|
||||
|
||||
Ok(Some(Self {
|
||||
kind,
|
||||
python_path: path,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind of trampoline launcher to create.
|
||||
///
|
||||
/// See [`uv-trampoline::bounce::TrampolineKind`].
|
||||
enum LauncherKind {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LauncherKind {
|
||||
/// The trampoline should execute itself, it's a zipped Python script.
|
||||
Script,
|
||||
/// The trampoline should just execute Python, it's a proxy Python executable.
|
||||
|
|
@ -41,17 +128,61 @@ enum LauncherKind {
|
|||
}
|
||||
|
||||
impl LauncherKind {
|
||||
const fn magic_number(&self) -> &'static [u8; 4] {
|
||||
/// Return the magic number for this [`LauncherKind`].
|
||||
const fn magic_number(self) -> &'static [u8; 4] {
|
||||
match self {
|
||||
Self::Script => b"UVSC",
|
||||
Self::Python => b"UVPY",
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a [`LauncherKind`] from 4 byte buffer.
|
||||
///
|
||||
/// If the buffer does not contain a matching magic number, `None` is returned.
|
||||
fn try_from_bytes(bytes: [u8; MAGIC_NUMBER_SIZE]) -> Option<Self> {
|
||||
if &bytes == Self::Script.magic_number() {
|
||||
return Some(Self::Script);
|
||||
}
|
||||
if &bytes == Self::Python.magic_number() {
|
||||
return Some(Self::Python);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Read a [`LauncherKind`] from a file handle, based on the magic number.
|
||||
///
|
||||
/// This will mutate the file handle, seeking to the end of the file.
|
||||
///
|
||||
/// If the file cannot be read, an [`io::Error`] is returned. If the path is not a launcher,
|
||||
/// `None` is returned.
|
||||
#[allow(clippy::cast_possible_wrap)]
|
||||
pub fn try_from_file(file: &mut File) -> Result<Option<Self>, Error> {
|
||||
// If the file is less than four bytes, it's not a launcher.
|
||||
let Ok(_) = file.seek(io::SeekFrom::End(-(MAGIC_NUMBER_SIZE as i64))) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut buffer = [0; MAGIC_NUMBER_SIZE];
|
||||
file.read_exact(&mut buffer)
|
||||
.map_err(|err| Error::InvalidLauncherRead("magic number".to_string(), err))?;
|
||||
|
||||
Ok(Self::try_from_bytes(buffer))
|
||||
}
|
||||
}
|
||||
|
||||
/// Note: The caller is responsible for adding the path of the wheel we're installing.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
Io(#[from] io::Error),
|
||||
#[error("Only paths with a length up to 32KB are supported but found a length of {0} bytes")]
|
||||
InvalidPathLength(u32),
|
||||
#[error("Failed to parse executable path")]
|
||||
InvalidPath(#[source] Utf8Error),
|
||||
#[error("Failed to seek to {0} at offset {1}")]
|
||||
InvalidLauncherSeek(String, i64, #[source] io::Error),
|
||||
#[error("Failed to read launcher {0}")]
|
||||
InvalidLauncherRead(String, #[source] io::Error),
|
||||
#[error(
|
||||
"Unable to create Windows launcher for: {0} (only x86_64, x86, and arm64 are supported)"
|
||||
)]
|
||||
|
|
@ -192,7 +323,7 @@ mod test {
|
|||
|
||||
use which::which;
|
||||
|
||||
use super::{windows_python_launcher, windows_script_launcher};
|
||||
use super::{windows_python_launcher, windows_script_launcher, Launcher, LauncherKind};
|
||||
|
||||
#[test]
|
||||
#[cfg(all(windows, target_arch = "x86", feature = "production"))]
|
||||
|
|
@ -340,6 +471,13 @@ if __name__ == "__main__":
|
|||
.stdout(stdout_predicate)
|
||||
.stderr(stderr_predicate);
|
||||
|
||||
let launcher = Launcher::try_from_path(console_bin_path.path())
|
||||
.expect("We should succeed at reading the launcher")
|
||||
.expect("The launcher should be valid");
|
||||
|
||||
assert!(launcher.kind == LauncherKind::Script);
|
||||
assert!(launcher.python_path == python_executable_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -371,6 +509,13 @@ if __name__ == "__main__":
|
|||
.success()
|
||||
.stdout("Hello from Python Launcher\r\n");
|
||||
|
||||
let launcher = Launcher::try_from_path(console_bin_path.path())
|
||||
.expect("We should succeed at reading the launcher")
|
||||
.expect("The launcher should be valid");
|
||||
|
||||
assert!(launcher.kind == LauncherKind::Python);
|
||||
assert!(launcher.python_path == python_executable_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -265,7 +265,8 @@ pub(crate) async fn install(
|
|||
installation.ensure_externally_managed()?;
|
||||
installation.ensure_canonical_executables()?;
|
||||
|
||||
if preview.is_disabled() || !cfg!(unix) {
|
||||
if preview.is_disabled() {
|
||||
debug!("Skipping installation of Python executables, use `--preview` to enable.");
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -274,8 +275,10 @@ pub(crate) async fn install(
|
|||
.expect("We should have a bin directory with preview enabled")
|
||||
.as_path();
|
||||
|
||||
match installation.create_bin_link(bin) {
|
||||
Ok(target) => {
|
||||
let target = bin.join(installation.key().versioned_executable_name());
|
||||
|
||||
match installation.create_bin_link(&target) {
|
||||
Ok(()) => {
|
||||
debug!(
|
||||
"Installed executable at {} for {}",
|
||||
target.user_display(),
|
||||
|
|
@ -294,7 +297,7 @@ pub(crate) async fn install(
|
|||
// TODO(zanieb): Add `--force`
|
||||
if reinstall {
|
||||
fs_err::remove_file(&to)?;
|
||||
let target = installation.create_bin_link(bin)?;
|
||||
installation.create_bin_link(&target)?;
|
||||
debug!(
|
||||
"Updated executable at {} to {}",
|
||||
target.user_display(),
|
||||
|
|
@ -395,7 +398,7 @@ pub(crate) async fn install(
|
|||
}
|
||||
}
|
||||
|
||||
if preview.is_enabled() && cfg!(unix) {
|
||||
if preview.is_enabled() {
|
||||
let bin = bin
|
||||
.as_ref()
|
||||
.expect("We should have a bin directory with preview enabled")
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ use futures::StreamExt;
|
|||
use itertools::Itertools;
|
||||
use owo_colors::OwoColorize;
|
||||
|
||||
use same_file::is_same_file;
|
||||
use tracing::{debug, warn};
|
||||
use uv_fs::Simplified;
|
||||
use uv_python::downloads::PythonDownloadRequest;
|
||||
|
|
@ -149,9 +148,9 @@ async fn do_uninstall(
|
|||
})
|
||||
// Only include Python executables that match the installations
|
||||
.filter(|path| {
|
||||
matching_installations.iter().any(|installation| {
|
||||
is_same_file(path, installation.executable()).unwrap_or_default()
|
||||
})
|
||||
matching_installations
|
||||
.iter()
|
||||
.any(|installation| installation.is_bin_link(path.as_path()))
|
||||
})
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
||||
|
|
@ -218,6 +217,7 @@ async fn do_uninstall(
|
|||
.sorted_unstable_by(|a, b| a.key.cmp(&b.key).then_with(|| a.kind.cmp(&b.kind)))
|
||||
{
|
||||
match event.kind {
|
||||
// TODO(zanieb): Track removed executables and report them all here
|
||||
ChangeEventKind::Removed => {
|
||||
writeln!(
|
||||
printer.stderr(),
|
||||
|
|
|
|||
|
|
@ -660,7 +660,15 @@ impl TestContext {
|
|||
.arg("python")
|
||||
.arg("install")
|
||||
.env(EnvVars::UV_PYTHON_INSTALL_DIR, managed)
|
||||
.env(EnvVars::UV_PYTHON_BIN_DIR, bin)
|
||||
.env(EnvVars::UV_PYTHON_BIN_DIR, bin.as_os_str())
|
||||
.env(
|
||||
EnvVars::PATH,
|
||||
std::env::join_paths(
|
||||
std::iter::once(bin)
|
||||
.chain(std::env::split_paths(&env::var("PATH").unwrap_or_default())),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.current_dir(&self.temp_dir);
|
||||
command
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,7 +98,6 @@ fn python_install_preview() {
|
|||
----- stderr -----
|
||||
Installed Python 3.13.0 in [TIME]
|
||||
+ cpython-3.13.0-[PLATFORM]
|
||||
warning: `[TEMP_DIR]/bin` is not on your PATH. To use the installed Python executable, run `export PATH="[TEMP_DIR]/bin:$PATH"`.
|
||||
"###);
|
||||
|
||||
let bin_python = context
|
||||
|
|
@ -143,7 +142,6 @@ fn python_install_preview() {
|
|||
----- stderr -----
|
||||
Installed Python 3.13.0 in [TIME]
|
||||
~ cpython-3.13.0-[PLATFORM]
|
||||
warning: `[TEMP_DIR]/bin` is not on your PATH. To use the installed Python executable, run `export PATH="[TEMP_DIR]/bin:$PATH"`.
|
||||
"###);
|
||||
|
||||
// The executable should still be present in the bin directory
|
||||
|
|
@ -192,7 +190,6 @@ fn python_install_freethreaded() {
|
|||
----- stderr -----
|
||||
Installed Python 3.13.0 in [TIME]
|
||||
+ cpython-3.13.0+freethreaded-[PLATFORM]
|
||||
warning: `[TEMP_DIR]/bin` is not on your PATH. To use the installed Python executable, run `export PATH="[TEMP_DIR]/bin:$PATH"`.
|
||||
"###);
|
||||
|
||||
let bin_python = context
|
||||
|
|
|
|||
Loading…
Reference in New Issue