Skip to content

Commit ec4bcbc

Browse files
authored
Merge pull request #1722 from rstudio/uv-resolve-python-version
Implement `resolve_python_version()`
2 parents 0255260 + 22d3d10 commit ec4bcbc

File tree

3 files changed

+92
-36
lines changed

3 files changed

+92
-36
lines changed

R/package.R

-4
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,3 @@ py_set_qt_qpa_platform_plugin_path <- function(config) {
390390
FALSE
391391

392392
}
393-
394-
reticulate_default_python <- function() {
395-
"3.11"
396-
}

R/py_require.R

+82-20
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ print.python_requirements <- function(x, ...) {
147147
}
148148
python_version <- x$python_version
149149
if (is.null(python_version)) {
150-
python_version <- paste0("[No Python version specified. Will default to '", reticulate_default_python() , "']")
150+
python_version <- paste0("[No Python version specified. Will default to '", resolve_python_version() , "']")
151151
}
152152

153153
requested_from <- as.character(lapply(x$history, function(x) x$requested_from))
@@ -436,20 +436,14 @@ uv_get_or_create_env <- function(packages = py_reqs_get("packages"),
436436
call_args <- list(
437437
packages = packages,
438438
python_version = python_version %||%
439-
paste(reticulate_default_python(), "(reticulate default)"),
439+
paste(resolve_python_version(), "(reticulate default)"),
440440
exclude_newer = exclude_newer
441441
)
442442

443443
if (length(packages))
444444
packages <- as.vector(rbind("--with", packages))
445445

446-
if (length(python_version)) {
447-
constraints <- unlist(strsplit(python_version, ",", fixed = TRUE))
448-
constraints <- paste0(constraints, collapse = ",")
449-
} else {
450-
constraints <- reticulate_default_python()
451-
}
452-
python_version <- c("--python", constraints)
446+
python_version <- c("--python", resolve_python_version(constraints = python_version))
453447

454448
if (!is.null(exclude_newer)) {
455449
# todo, accept a POSIXct/lt, format correctly
@@ -462,7 +456,7 @@ uv_get_or_create_env <- function(packages = py_reqs_get("packages"),
462456
c("--cache-dir", reticulate_managed_uv_cache_dir())
463457

464458
if (is_positron())
465-
withr::local_envvar(c(RUST_LOG=NA))
459+
withr::local_envvar(c(RUST_LOG = NA))
466460

467461
uv_args <- c(
468462
"run",
@@ -482,32 +476,30 @@ uv_get_or_create_env <- function(packages = py_reqs_get("packages"),
482476
# This is an interactive session, but uv will not display any progress bar
483477
# during long downloads because isatty() == FALSE. So we use processx and
484478
# cli to display a calming spinner.
485-
p <- processx::process$new(
486-
uv, uv_args,
487-
stderr = "|",
488-
stdout = "|"
489-
)
490-
uv_stderr <- character()
479+
p <- processx::process$new(uv, uv_args, stderr = "|", stdout = "|")
480+
uv_stdout <- uv_stderr <- character()
491481
p$wait(500)
492482
if (p$is_alive()) {
493483
sp <- cli::make_spinner(template = "Downloading Python dependencies {spin}")
494484
while (p$is_alive()) {
495485
sp$spin()
496486
pr <- p$poll_io(100)
497-
if (pr[["error"]] == "ready") {
498-
# proactively clear pipe to ensure process isn't blocked on write
487+
if (pr[["error"]] == "ready")
499488
uv_stderr[[length(uv_stderr) + 1L]] <- p$read_error()
500-
}
489+
if (pr[["output"]] == "ready")
490+
uv_stdout[[length(uv_stdout) + 1L]] <- p$read_output()
501491
}
502492
sp$finish()
503493
}
504494

505495
uv_stderr <- paste0(c(uv_stderr, p$read_all_error()), collapse = "")
496+
uv_stdout <- paste0(c(uv_stdout, p$read_all_output()), collapse = "")
497+
506498
if (nzchar(uv_stderr))
507499
cat(uv_stderr, if(!endsWith(uv_stderr, "\n")) "\n", file = stderr())
508500

509501
exit_status <- p$get_exit_status()
510-
env_python <- sub("[\r\n]*$", "", p$read_all_output())
502+
env_python <- sub("[\r\n]*$", "", uv_stdout)
511503

512504
} else {
513505
# uv will display a progress bar during long downloads, and we can simply
@@ -578,3 +570,73 @@ uv_cache_dir <- function(uv = uv_binary(bootstrap_install = FALSE)) {
578570
error = function(e) NULL
579571
)
580572
}
573+
574+
575+
uv_python_list <- function() {
576+
x <- system2(uv_binary(), c(
577+
"python list --no-config --python-preference only-managed",
578+
"--only-downloads --color never"
579+
),
580+
stdout = TRUE
581+
)
582+
583+
x <- grep("^cpython-", x, value = TRUE)
584+
x <- sub("^cpython-([^-]+)-.*", "\\1", x)
585+
x <- numeric_version(x, strict = FALSE)
586+
x <- x[!is.na(x)]
587+
x <- sort(x, decreasing = TRUE)
588+
x
589+
}
590+
591+
resolve_python_version <- function(constraints = NULL) {
592+
constraints <- as.character(constraints %||% "")
593+
constraints <- trimws(unlist(strsplit(constraints, ",", fixed = TRUE)))
594+
constraints <- constraints[nzchar(constraints)]
595+
596+
if (length(constraints) == 0) {
597+
return(as.character(uv_python_list()[3L])) # default
598+
}
599+
600+
# reflect a direct version specification like "3.11" or "3.14.0a3"
601+
if (length(constraints) == 1 && !substr(constraints, 1, 1) %in% c("=", ">", "<", "!")) {
602+
return(constraints)
603+
}
604+
605+
# We perform custom constraint resolution to prefer slightly older Python releases.
606+
# uv tends to select the latest version, which often lack package support
607+
# See: https://devguide.python.org/versions/
608+
609+
# Get latest patch for each minor version
610+
candidates <- uv_python_list()
611+
# E.g., candidates might be:
612+
# c("3.13.1", "3.12.8", "3.11.11", "3.10.16", "3.9.21", "3.8.20")
613+
614+
# Reorder candidates to prefer stable versions over bleeding edge
615+
ord <- as.integer(c(3, 4, 2, 5, 1))
616+
ord <- union(ord, seq_along(candidates))
617+
candidates <- candidates[ord]
618+
619+
# Maybe add non-latest patch levels to candidates if they're explicitly
620+
# mentioned in constraints
621+
additional_candidates <- sub("^[<>=!]{1,2}", "", constraints)
622+
additional_candidates <- numeric_version(additional_candidates, strict = FALSE)
623+
additional_candidates <- additional_candidates[!is.na(additional_candidates)]
624+
candidates <- c(candidates, additional_candidates)
625+
626+
for (check in as_version_constraint_checkers(constraints)) {
627+
satisfies_constraint <- check(candidates)
628+
candidates <- candidates[satisfies_constraint]
629+
}
630+
631+
if (!length(candidates)) {
632+
constraints <- paste0(constraints, collapse = ",")
633+
msg <- paste0(
634+
'Requested Python version constraints could not be satisfied.\n',
635+
' constraints: "', constraints, '"\n',
636+
'Hint: Call `py_require(python_version = <string>, action = "set")` to replace constraints.'
637+
)
638+
stop(msg)
639+
}
640+
641+
as.character(candidates[1L])
642+
}

tests/testthat/_snaps/py_require.md

+10-12
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
╰─▶ Because you require numpy<2 and numpy>=2, we can conclude that your
1515
requirements are unsatisfiable.
1616
-- Current requirements -------------------------------------------------
17-
Python: 3.11 (reticulate default)
17+
Python: 3.11.11 (reticulate default)
1818
Packages: numpy, numpy<2, numpy>=2
1919
-------------------------------------------------------------------------
2020
Error in uv_get_or_create_env() :
@@ -38,7 +38,7 @@
3838
╰─▶ Because notexists was not found in the package registry and you require
3939
notexists, we can conclude that your requirements are unsatisfiable.
4040
-- Current requirements -------------------------------------------------
41-
Python: 3.11 (reticulate default)
41+
Python: 3.11.11 (reticulate default)
4242
Packages: numpy, pandas, notexists
4343
-------------------------------------------------------------------------
4444
Error in uv_get_or_create_env() :
@@ -60,13 +60,11 @@
6060
> py_require(python_version = ">=3.10")
6161
> py_require(python_version = "<3.10")
6262
> uv_get_or_create_env()
63-
error: No interpreter found for Python >=3.10, <3.10 in virtual environments or managed installations
64-
-- Current requirements -------------------------------------------------
65-
Python: >=3.10, <3.10
66-
Packages: numpy
67-
-------------------------------------------------------------------------
68-
Error in uv_get_or_create_env() :
69-
Call `py_require()` to remove or replace conflicting requirements.
63+
Error in resolve_python_version(constraints = python_version) :
64+
Requested Python version constraints could not be satisfied.
65+
constraints: ">=3.10,<3.10"
66+
Hint: Call `py_require(python_version = <string>, action = "set")` to replace constraints.
67+
Calls: uv_get_or_create_env -> resolve_python_version
7068
Execution halted
7169
------- session end -------
7270
success: false
@@ -86,7 +84,7 @@
8684
> py_require()
8785
══════════════════════════ Python requirements ══════════════════════════
8886
── Current requirements ─────────────────────────────────────────────────
89-
Python: [No Python version specified. Will default to '3.11']
87+
Python: [No Python version specified. Will default to '3.11.11']
9088
Packages: numpy, pandas, numpy==2
9189
── R package requests ───────────────────────────────────────────────────
9290
R package Python packages Python version
@@ -112,7 +110,7 @@
112110
> py_require()
113111
══════════════════════════ Python requirements ══════════════════════════
114112
── Current requirements ─────────────────────────────────────────────────
115-
Python: [No Python version specified. Will default to '3.11']
113+
Python: [No Python version specified. Will default to '3.11.11']
116114
Packages: numpy, pandas
117115
── R package requests ───────────────────────────────────────────────────
118116
R package Python packages Python version
@@ -140,7 +138,7 @@
140138
> py_require()
141139
══════════════════════════ Python requirements ══════════════════════════
142140
── Current requirements ─────────────────────────────────────────────────
143-
Python: [No Python version specified. Will default to '3.11']
141+
Python: [No Python version specified. Will default to '3.11.11']
144142
Packages: numpy, pandas
145143
Exclude: Anything newer than 1990-01-01
146144
── R package requests ───────────────────────────────────────────────────

0 commit comments

Comments
 (0)