origen/commands/
env.rs

1//! Manage an application's UV environment.
2mod migration;
3
4use super::_prelude::*;
5use crate::python::{python_version, uv_version, MIN_PYTHON_VERSION, PYTHON_CONFIG};
6use origen::core::status::search_for;
7use origen::core::term::*;
8use semver::VersionReq;
9use std::process::Command;
10
11pub const BASE_CMD: &'static str = "env";
12static MINIMUM_UV_VERSION: &str = "0.12.5";
13
14/// Python versions below this cannot execute UV's Windows console-script
15/// launchers. See `provision_with_pip`.
16static MINIMUM_UV_LAUNCHER_PYTHON: &str = "3.8.0";
17
18gen_core_cmd_funcs__no_exts__no_app_opts!(
19    BASE_CMD,
20    "Manage your application's Origen/Python environment",
21    { |cmd: App| { cmd.arg_required_else_help(true) } },
22    core_subcmd__no_exts__no_app_opts!(
23        "setup",
24        "Create or synchronize the UV environment from uv.lock",
25        {
26            |cmd: App| {
27                cmd.arg(
28                    Arg::new("origen")
29                        .long("origen")
30                        .help("Point this application at an Origen source checkout. Note: this permanently records the path in the application's pyproject.toml and uv.lock; remove it with 'uv remove origen'")
31                        .action(SetArg),
32                )
33            }
34        }
35    ),
36    core_subcmd__no_exts__no_app_opts!(
37        "update",
38        "Upgrade and synchronize the application's locked dependencies",
39        { |cmd: App| { cmd } }
40    ),
41    core_subcmd__no_exts__no_app_opts!(
42        "migrate",
43        "Convert a Poetry project to PEP 621 metadata and UV sources",
44        {
45            |cmd: App| {
46                cmd.arg(
47                    Arg::new("dry-run")
48                        .long("dry-run")
49                        .help("Print the proposed pyproject.toml diff without changing files")
50                        .action(SetArgTrue),
51                )
52                .arg(
53                    Arg::new("project")
54                        .long("project")
55                        .help("Project directory or pyproject.toml path; defaults to the nearest project")
56                        .value_name("PATH")
57                        .action(SetArg),
58                )
59            }
60        }
61    )
62);
63
64pub(crate) fn add_prephase_cmds(cmd: App) -> App {
65    let migrate = App::new("migrate")
66        .arg(Arg::new("dry-run").long("dry-run").action(SetArgTrue))
67        .arg(
68            Arg::new("project")
69                .long("project")
70                .value_name("PATH")
71                .action(SetArg),
72        );
73    cmd.subcommand(
74        App::new(BASE_CMD)
75            .disable_help_flag(true)
76            .subcommand(migrate),
77    )
78}
79
80pub(crate) fn run_pre_phase(invocation: &clap::ArgMatches) -> origen::Result<i32> {
81    migration::run(
82        invocation
83            .subcommand_matches("migrate")
84            .expect("pre-phase env execution requires the migrate subcommand"),
85    )?;
86    Ok(0)
87}
88
89pub(crate) fn guard_uv_manifest(path: &std::path::Path) -> origen::Result<()> {
90    migration::guard_uv_manifest(path)
91}
92
93pub(crate) fn ensure_uv_available() -> origen::Result<()> {
94    let required = VersionReq::parse(&format!(">={}", MINIMUM_UV_VERSION)).unwrap();
95    if let Some(version) = uv_version() {
96        if required.matches(&version) {
97            displayln!("UV {} is available", version);
98            return Ok(());
99        }
100    }
101    Err(origen::Error::new(&format!(
102        "UV >= {} is required. Install the standalone UV binary from https://docs.astral.sh/uv/getting-started/installation/ and rerun the command.",
103        MINIMUM_UV_VERSION
104    )))
105}
106
107pub fn run(invocation: &clap::ArgMatches) -> origen::Result<()> {
108    if let Some(migrate) = invocation.subcommand_matches("migrate") {
109        return migration::run(migrate);
110    }
111
112    let app_root = &origen::app()
113        .ok_or_else(|| {
114            origen::Error::new(
115                "'origen env setup' and 'origen env update' require an Origen application",
116            )
117        })?
118        .root;
119    let pyproject = app_root.join("pyproject.toml");
120    if !pyproject.exists() {
121        display_redln!(
122            "Application pyproject.toml was not found at {}",
123            pyproject.display()
124        );
125        std::process::exit(1);
126    }
127    guard_uv_manifest(&pyproject)?;
128    require_python();
129    require_uv();
130
131    match invocation.subcommand_name() {
132        Some("update") => {
133            run_uv(app_root, &["lock", "--upgrade"])?;
134            provision(app_root)?;
135        }
136        Some("setup") => {
137            if let Some(path) = invocation
138                .subcommand_matches("setup")
139                .unwrap()
140                .get_one::<String>("origen")
141            {
142                let path = std::path::Path::new(path)
143                    .canonicalize()
144                    .expect("The path supplied to --origen does not exist");
145                let (found, root) = search_for(vec![".origen_dev_workspace"], false, &path);
146                if !found {
147                    display_redln!(
148                        "An Origen source checkout was not found at {}",
149                        path.display()
150                    );
151                    std::process::exit(1);
152                }
153                let package = root.join("python").join("origen");
154                let package_str = package.to_string_lossy();
155                displayln!(
156                    "Adding '{}' as a path dependency. This edits pyproject.toml and uv.lock; run 'uv remove origen' to undo it before committing.",
157                    package_str
158                );
159                run_uv(app_root, &["add", package_str.as_ref()])?;
160            }
161            provision(app_root)?;
162        }
163        _ => unreachable!(),
164    }
165    Ok(())
166}
167
168/// Install the locked environment.
169///
170/// Normally this is a plain `uv sync`. Windows with Python 3.7 takes a
171/// different route; see `provision_with_pip` for why.
172fn provision(app_root: &std::path::Path) -> origen::Result<()> {
173    if needs_pip_provisioning() {
174        provision_with_pip(app_root)
175    } else {
176        run_uv(app_root, &["sync", "--all-groups", "--no-editable"])
177    }
178}
179
180/// UV writes Windows console scripts as a trampoline with a zip archive
181/// appended. The trampoline runs `python.exe <the .exe itself>` and relies on
182/// the interpreter executing that archive. CPython's old C `zipimport`, used up
183/// to and including 3.7, rejects it and falls back to parsing the executable as
184/// source, so *every* console script in such an environment is unusable --
185/// `pytest`, and `origen` itself. CPython 3.8 replaced `zipimport` with the
186/// pure-Python implementation, which reads it correctly.
187///
188/// pip's launchers are built differently and do work there, which is how this
189/// combination behaved before O2 moved to UV. Retire this path when O2 drops
190/// Python 3.7, or when UV's launcher becomes readable by it.
191fn needs_pip_provisioning() -> bool {
192    if !cfg!(windows) {
193        return false;
194    }
195    let minimum = semver::Version::parse(MINIMUM_UV_LAUNCHER_PYTHON).unwrap();
196    python_version().map_or(false, |version| version < minimum)
197}
198
199fn provision_with_pip(app_root: &std::path::Path) -> origen::Result<()> {
200    displayln!(
201        "Python {} on Windows cannot execute UV's console-script launchers, so this \
202         environment will be installed with pip. The contents still come from uv.lock.",
203        python_version().map_or("<unknown>".to_string(), |v| v.to_string())
204    );
205
206    let venv = app_root.join(".venv");
207    // Pin the interpreter. Without it UV picks its own preferred Python for the
208    // environment, which is not necessarily the one that was discovered here --
209    // it may even download a different version - and the environment would then
210    // not match the interpreter the rest of the workspace is using.
211    let interpreter = discovered_python_executable()?;
212    run_uv(
213        app_root,
214        &[
215            "venv",
216            "--seed",
217            // Replace any existing environment. Unlike `uv sync`, installing
218            // with pip cannot prune packages that are no longer in the lock, so
219            // starting clean is what keeps this path equivalent to a sync.
220            "--clear",
221            "--python",
222            &interpreter,
223            &venv.to_string_lossy(),
224        ],
225    )?;
226
227    // Export the resolved lock rather than re-resolving, so pip installs
228    // exactly what `uv sync` would have. The project itself is excluded here
229    // and installed separately, matching `--no-editable`.
230    let requirements =
231        std::env::temp_dir().join(format!("origen-uv-requirements-{}.txt", std::process::id()));
232    let requirements_arg = requirements.to_string_lossy().to_string();
233    let export = run_uv(
234        app_root,
235        &[
236            "export",
237            "--frozen",
238            "--all-groups",
239            "--no-hashes",
240            "--no-emit-project",
241            "-o",
242            &requirements_arg,
243        ],
244    );
245    if export.is_err() {
246        let _ = std::fs::remove_file(&requirements);
247        return export;
248    }
249
250    let python = venv.join("Scripts").join("python.exe");
251    let result = (|| -> origen::Result<()> {
252        run_python(
253            &python,
254            &["-m", "pip", "install", "-r", &requirements_arg],
255            app_root,
256        )?;
257        if is_installable_project(app_root) {
258            run_python(
259                &python,
260                &["-m", "pip", "install", "--no-deps", "."],
261                app_root,
262            )
263        } else {
264            displayln!("Project is virtual (tool.uv package = false); dependencies only.");
265            Ok(())
266        }
267    })();
268    let _ = std::fs::remove_file(&requirements);
269    result
270}
271
272/// Does UV install this project itself, or only its dependencies?
273///
274/// A project marked `[tool.uv] package = false` is virtual: `uv sync` installs
275/// its dependencies and never builds the project. Installing it with pip anyway
276/// makes setuptools guess at the layout and publish whatever directories it
277/// happens to find.
278fn is_installable_project(root: &std::path::Path) -> bool {
279    let contents = match std::fs::read_to_string(root.join("pyproject.toml")) {
280        Ok(c) => c,
281        Err(_) => return true,
282    };
283    let parsed: toml::Value = match toml::from_str(&contents) {
284        Ok(v) => v,
285        Err(_) => return true,
286    };
287    parsed
288        .get("tool")
289        .and_then(|t| t.get("uv"))
290        .and_then(|uv| uv.get("package"))
291        .and_then(|p| p.as_bool())
292        .unwrap_or(true)
293}
294
295fn run_python(
296    python: &std::path::Path,
297    args: &[&str],
298    cwd: &std::path::Path,
299) -> origen::Result<()> {
300    let mut command = Command::new(python);
301    command.args(args).current_dir(cwd);
302    log_debug!("Running Python command: {:?}", command);
303    displayln!("+ {} {}", python.display(), args.join(" "));
304    let status = command.status()?;
305    if !status.success() {
306        bail!("'{}' failed with status {}", args.join(" "), status)
307    }
308    Ok(())
309}
310
311/// Resolve the discovered Python command to a concrete interpreter path, so it
312/// can be handed to UV unambiguously.
313fn discovered_python_executable() -> origen::Result<String> {
314    let output = Command::new(&PYTHON_CONFIG.command)
315        .args(["-c", "import sys; print(sys.executable)"])
316        .output()?;
317    if !output.status.success() {
318        bail!(
319            "Could not resolve the path of Python command '{}'",
320            PYTHON_CONFIG.command
321        )
322    }
323    let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
324    if path.is_empty() {
325        bail!(
326            "Python command '{}' did not report an executable path",
327            PYTHON_CONFIG.command
328        )
329    }
330    Ok(path)
331}
332
333fn require_python() {
334    print!("Is a suitable Python available? ... ");
335    if PYTHON_CONFIG.available {
336        greenln("YES");
337    } else {
338        redln("NO");
339        display_redln!(
340            "Could not find Python >= {}. Install a supported Python and try again.",
341            MIN_PYTHON_VERSION
342        );
343        std::process::exit(1);
344    }
345}
346
347fn run_uv(root: &std::path::Path, args: &[&str]) -> origen::Result<()> {
348    let mut command = Command::new("uv");
349    command.arg("--project").arg(root).args(args);
350    log_debug!("Running UV command: {:?}", command);
351    // Echoed unconditionally: provisioning is long-running and its failures are
352    // otherwise opaque, particularly in CI where no verbosity flag is passed.
353    displayln!("+ uv {}", args.join(" "));
354    let status = command.status()?;
355    if !status.success() {
356        bail!("UV command failed with status {}", status);
357    }
358    Ok(())
359}
360
361fn require_uv() {
362    if let Err(error) = ensure_uv_available() {
363        display_redln!("{}", error);
364        std::process::exit(1);
365    }
366}