1mod 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
14static 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
168fn 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
180fn 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 let interpreter = discovered_python_executable()?;
212 run_uv(
213 app_root,
214 &[
215 "venv",
216 "--seed",
217 "--clear",
221 "--python",
222 &interpreter,
223 &venv.to_string_lossy(),
224 ],
225 )?;
226
227 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
272fn 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
311fn 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 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}