rez_next_package/python_ast_parser/
mod.rs1mod commands;
10mod eval;
11mod extract;
12mod types;
13
14use crate::Package;
15use rez_next_common::RezCoreError;
16use rez_next_version::Version;
17use rustpython_ast::{Expr, Stmt, Suite};
18use rustpython_parser::Parse;
19use types::{PackageData, ParsingContext};
20
21#[derive(Default)]
23pub struct PythonAstParser {
24 pub(crate) context: ParsingContext,
26}
27
28impl PythonAstParser {
29 pub fn new() -> Self {
31 Self {
32 context: ParsingContext::default(),
33 }
34 }
35
36 pub fn parse_package_py(content: &str) -> Result<Package, RezCoreError> {
38 let mut parser = Self::new();
39 parser.parse_package_py_with_context(content)
40 }
41
42 fn parse_package_py_with_context(&mut self, content: &str) -> Result<Package, RezCoreError> {
44 let ast = Suite::parse(content, "package.py")
45 .map_err(|e| RezCoreError::PackageParse(format!("Python syntax error: {}", e)))?;
46
47 let mut package_data = PackageData::new();
48
49 for stmt in &ast {
50 self.process_statement(stmt, &mut package_data)?;
51 }
52
53 Self::build_package(package_data)
54 }
55
56 fn process_statement(
58 &mut self,
59 stmt: &Stmt,
60 package_data: &mut PackageData,
61 ) -> Result<(), RezCoreError> {
62 match stmt {
63 Stmt::Assign(assign) => {
64 if let Some(Expr::Name(name_expr)) = assign.targets.first() {
65 self.process_assignment(&name_expr.id, &assign.value, package_data)?;
66 }
67 }
68 Stmt::FunctionDef(func_def) => {
69 self.process_function_definition(func_def, package_data)?;
70 }
71 Stmt::Import(import) => {
72 self.process_import_statement(import)?;
73 }
74 Stmt::ImportFrom(import_from) => {
75 self.process_import_from_statement(import_from)?;
76 }
77 Stmt::If(if_stmt) => {
78 self.process_if_statement(if_stmt, package_data)?;
79 }
80 Stmt::For(for_stmt) => {
81 self.process_for_statement(for_stmt, package_data)?;
82 }
83 Stmt::While(while_stmt) => {
84 self.process_while_statement(while_stmt, package_data)?;
85 }
86 Stmt::Try(try_stmt) => {
87 self.process_try_statement(try_stmt, package_data)?;
88 }
89 Stmt::With(with_stmt) => {
90 self.process_with_statement(with_stmt, package_data)?;
91 }
92 Stmt::Expr(expr_stmt) => {
93 self.process_expression_statement(&expr_stmt.value, package_data)?;
94 }
95 Stmt::Pass(_) => {}
96 _ => {
97 return Err(RezCoreError::PackageParse(format!(
98 "Unsupported package.py statement: {stmt:?}"
99 )));
100 }
101 }
102 Ok(())
103 }
104
105 fn process_import_statement(
107 &mut self,
108 import: &rustpython_ast::StmtImport,
109 ) -> Result<(), RezCoreError> {
110 for alias in &import.names {
111 let module_name = alias.name.as_str();
112 let alias_name = alias
113 .asname
114 .as_ref()
115 .map(|s| s.as_str())
116 .unwrap_or(module_name);
117 self.context
118 .imports
119 .insert(alias_name.to_string(), module_name.to_string());
120 }
121 Ok(())
122 }
123
124 fn process_import_from_statement(
126 &mut self,
127 import_from: &rustpython_ast::StmtImportFrom,
128 ) -> Result<(), RezCoreError> {
129 if let Some(module) = &import_from.module {
130 for alias in &import_from.names {
131 let name = alias.name.as_str();
132 let alias_name = alias.asname.as_ref().map(|s| s.as_str()).unwrap_or(name);
133 let full_name = format!("{}.{}", module, name);
134 self.context
135 .imports
136 .insert(alias_name.to_string(), full_name);
137 }
138 }
139 Ok(())
140 }
141
142 fn process_function_definition(
144 &mut self,
145 func_def: &rustpython_ast::StmtFunctionDef,
146 package_data: &mut PackageData,
147 ) -> Result<(), RezCoreError> {
148 match func_def.name.as_str() {
149 "commands" => {
150 self.process_commands_function(&func_def.body, package_data)?;
151 }
152 "pre_commands" => {
153 self.process_pre_commands_function(&func_def.body, package_data)?;
154 }
155 "post_commands" => {
156 self.process_post_commands_function(&func_def.body, package_data)?;
157 }
158 "pre_test_commands" => {
159 self.process_pre_test_commands_function(&func_def.body, package_data)?;
160 }
161 _ => {
162 return Err(RezCoreError::PackageParse(format!(
163 "Unsupported package.py function: {}",
164 func_def.name
165 )));
166 }
167 }
168 Ok(())
169 }
170
171 fn process_if_statement(
173 &mut self,
174 if_stmt: &rustpython_ast::StmtIf,
175 package_data: &mut PackageData,
176 ) -> Result<(), RezCoreError> {
177 match self.evaluate_expression(&if_stmt.test)? {
178 types::PythonValue::Boolean(true) => {
179 for stmt in &if_stmt.body {
180 self.process_statement(stmt, package_data)?;
181 }
182 }
183 types::PythonValue::Boolean(false) => {
184 for stmt in &if_stmt.orelse {
185 self.process_statement(stmt, package_data)?;
186 }
187 }
188 _ => {
189 return Err(RezCoreError::PackageParse(
190 "Package condition is not statically boolean".to_string(),
191 ));
192 }
193 }
194 Ok(())
195 }
196
197 fn process_assignment(
199 &mut self,
200 var_name: &str,
201 value: &Expr,
202 package_data: &mut PackageData,
203 ) -> Result<(), RezCoreError> {
204 let python_value = self.evaluate_expression(value)?;
205 self.context
206 .variables
207 .insert(var_name.to_string(), python_value);
208
209 match var_name {
210 "name" => {
211 package_data.name = Some(self.extract_string_value(value)?);
212 }
213 "version" => {
214 package_data.version = Some(self.extract_string_value(value)?);
215 }
216 "description" => {
217 package_data.description = Some(self.extract_string_value(value)?);
218 }
219 "build_command" => {
220 package_data.build_command = Some(self.extract_string_value(value)?);
221 }
222 "build_system" => {
223 package_data.build_system = Some(self.extract_string_value(value)?);
224 }
225 "uuid" => {
226 package_data.uuid = Some(self.extract_string_value(value)?);
227 }
228 "authors" => {
229 package_data.authors = self.extract_string_list(value)?;
230 }
231 "requires" => {
232 package_data.requires = self.extract_string_list(value)?;
233 }
234 "build_requires" => {
235 package_data.build_requires = self.extract_string_list(value)?;
236 }
237 "private_build_requires" => {
238 package_data.private_build_requires = self.extract_string_list(value)?;
239 }
240 "tools" => {
241 package_data.tools = self.extract_string_list(value)?;
242 }
243 "variants" => {
244 package_data.variants = self.extract_variants(value)?;
245 }
246 "tests" => {
247 package_data.tests = self.extract_tests(value)?;
248 }
249 "commands" => {
250 if let Ok(s) = self.extract_string_value(value) {
251 package_data.commands_function = Some(s);
252 }
253 }
254 "pre_commands" => {
255 package_data.pre_commands = Some(self.extract_string_value(value)?);
256 }
257 "post_commands" => {
258 package_data.post_commands = Some(self.extract_string_value(value)?);
259 }
260 "pre_test_commands" => {
261 package_data.pre_test_commands = Some(self.extract_string_value(value)?);
262 }
263 "pre_build_commands" => {
264 package_data.pre_build_commands = Some(self.extract_string_value(value)?);
265 }
266 "requires_rez_version" => {
267 package_data.requires_rez_version = Some(self.extract_string_value(value)?);
268 }
269 "help" => {
270 package_data.help = Some(self.extract_string_value(value)?);
271 }
272 "relocatable" => {
273 package_data.relocatable = self.extract_bool_value(value)?;
274 }
275 "cachable" => {
276 package_data.cachable = self.extract_bool_value(value)?;
277 }
278 "base" => {
279 package_data.base = Some(self.extract_string_value(value)?);
280 }
281 "hashed_variants" => {
282 package_data.hashed_variants = self.extract_bool_value(value)?;
283 }
284 "has_plugins" => {
285 package_data.has_plugins = self.extract_bool_value(value)?;
286 }
287 "plugin_for" => {
288 package_data.plugin_for = self.extract_string_list(value)?;
289 }
290 "format_version" => {
291 package_data.format_version = Some(self.extract_int_value(value)?);
292 }
293 "preprocess" => {
294 package_data.preprocess = Some(self.extract_string_value(value)?);
295 }
296 _ => {
297 package_data
298 .extra_fields
299 .insert(var_name.to_string(), format!("{:?}", value));
300 }
301 }
302 Ok(())
303 }
304
305 fn process_for_statement(
307 &mut self,
308 for_stmt: &rustpython_ast::StmtFor,
309 package_data: &mut PackageData,
310 ) -> Result<(), RezCoreError> {
311 let _ = package_data;
312 Err(RezCoreError::PackageParse(format!(
313 "Unsupported package.py for loop: {for_stmt:?}"
314 )))
315 }
316
317 fn process_while_statement(
319 &mut self,
320 while_stmt: &rustpython_ast::StmtWhile,
321 package_data: &mut PackageData,
322 ) -> Result<(), RezCoreError> {
323 let _ = package_data;
324 Err(RezCoreError::PackageParse(format!(
325 "Unsupported package.py while loop: {while_stmt:?}"
326 )))
327 }
328
329 fn process_try_statement(
331 &mut self,
332 try_stmt: &rustpython_ast::StmtTry,
333 package_data: &mut PackageData,
334 ) -> Result<(), RezCoreError> {
335 let _ = package_data;
336 Err(RezCoreError::PackageParse(format!(
337 "Unsupported package.py try statement: {try_stmt:?}"
338 )))
339 }
340
341 fn process_with_statement(
343 &mut self,
344 with_stmt: &rustpython_ast::StmtWith,
345 package_data: &mut PackageData,
346 ) -> Result<(), RezCoreError> {
347 let _ = package_data;
348 Err(RezCoreError::PackageParse(format!(
349 "Unsupported package.py with statement: {with_stmt:?}"
350 )))
351 }
352
353 fn process_expression_statement(
355 &mut self,
356 expr: &Expr,
357 _package_data: &mut PackageData,
358 ) -> Result<(), RezCoreError> {
359 self.evaluate_expression(expr)?;
360 Ok(())
361 }
362
363 fn build_package(data: PackageData) -> Result<Package, RezCoreError> {
365 let name = data
366 .name
367 .ok_or_else(|| RezCoreError::PackageParse("Missing 'name' field".to_string()))?;
368
369 let mut package = Package::new(name);
370
371 if let Some(version_str) = data.version {
372 package.version = Some(
373 Version::parse(&version_str)
374 .map_err(|e| RezCoreError::PackageParse(format!("Invalid version: {}", e)))?,
375 );
376 }
377
378 package.description = data.description;
379 package.build_command = data.build_command;
380 package.build_system = data.build_system;
381 package.pre_commands = data.pre_commands;
382 package.post_commands = data.post_commands;
383 package.pre_test_commands = data.pre_test_commands;
384 package.pre_build_commands = data.pre_build_commands;
385 package.tests = data.tests;
386 package.requires_rez_version = data.requires_rez_version;
387 package.uuid = data.uuid;
388 package.authors = data.authors;
389 package.requires = data.requires;
390 package.build_requires = data.build_requires;
391 package.private_build_requires = data.private_build_requires;
392 package.tools = data.tools;
393 package.variants = data.variants;
394 package.help = data.help;
395 package.relocatable = data.relocatable;
396 package.cachable = data.cachable;
397 package.commands = data.commands_function.clone();
398 package.commands_function = data.commands_function;
399 package.base = data.base;
400 package.hashed_variants = data.hashed_variants;
401 package.has_plugins = data.has_plugins;
402 package.plugin_for = data.plugin_for;
403 package.format_version = data.format_version;
404 package.preprocess = data.preprocess;
405
406 package.validate()?;
407
408 Ok(package)
409 }
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 #[test]
417 fn test_parse_package_with_new_fields() {
418 let package_py_content = r#"
419name = "test_package"
420version = "1.0.0"
421description = "Test package with new fields"
422base = "base_package"
423hashed_variants = True
424has_plugins = True
425plugin_for = ["maya", "nuke"]
426format_version = 2
427preprocess = "some_preprocess_function"
428"#;
429
430 let result = PythonAstParser::parse_package_py(package_py_content);
431 assert!(
432 result.is_ok(),
433 "Failed to parse package.py: {:?}",
434 result.err()
435 );
436
437 let package = result.unwrap();
438 assert_eq!(package.name, "test_package");
439 assert_eq!(package.base, Some("base_package".to_string()));
440 assert_eq!(package.hashed_variants, Some(true));
441 assert_eq!(package.has_plugins, Some(true));
442 assert_eq!(package.plugin_for, vec!["maya", "nuke"]);
443 assert_eq!(package.format_version, Some(2));
444 assert_eq!(
445 package.preprocess,
446 Some("some_preprocess_function".to_string())
447 );
448 }
449
450 #[test]
451 fn test_parse_package_with_false_boolean_fields() {
452 let package_py_content = r#"
453name = "test_package"
454version = "1.0.0"
455hashed_variants = False
456has_plugins = False
457"#;
458
459 let result = PythonAstParser::parse_package_py(package_py_content);
460 assert!(
461 result.is_ok(),
462 "Failed to parse package.py: {:?}",
463 result.err()
464 );
465
466 let package = result.unwrap();
467 assert_eq!(package.hashed_variants, Some(false));
468 assert_eq!(package.has_plugins, Some(false));
469 }
470
471 #[test]
472 fn test_parse_package_with_conditional_logic() {
473 let package_py_content = r#"
474name = "test_package"
475version = "1.0.0"
476
477import os
478if os.name == "nt":
479 requires = ["windows-lib"]
480else:
481 requires = ["unix-lib"]
482
483def commands():
484 env.PATH.append("/usr/local/bin")
485 env.PYTHONPATH.prepend("/opt/python")
486"#;
487
488 let result = PythonAstParser::parse_package_py(package_py_content);
489 assert!(
490 result.is_ok(),
491 "Failed to parse package.py: {:?}",
492 result.err()
493 );
494
495 let package = result.unwrap();
496 assert_eq!(package.name, "test_package");
497 assert!(!package.requires.is_empty());
498 }
499
500 #[test]
501 fn test_parse_package_with_expressions() {
502 let package_py_content = r#"
503name = "test_package"
504version = "1.0.0"
505
506base_version = "2.0"
507version = base_version + ".1"
508
509authors = ["author1"] + ["author2"]
510"#;
511
512 let result = PythonAstParser::parse_package_py(package_py_content);
513 assert!(
514 result.is_ok(),
515 "Failed to parse package.py: {:?}",
516 result.err()
517 );
518
519 let package = result.unwrap();
520 assert_eq!(package.name, "test_package");
521 }
522
523 #[test]
524 fn test_commands_select_os_getenv_default_branch() {
525 let package = PythonAstParser::parse_package_py(
526 r#"
527name = "test_package"
528version = "1.0.0"
529import os
530def commands():
531 if os.getenv("REZ_NEXT_TEST_UNSET", "false").lower() == "true":
532 env.MODE = "enabled"
533 else:
534 env.MODE = "disabled"
535"#,
536 )
537 .unwrap();
538
539 assert_eq!(
540 package.commands.as_deref(),
541 Some("env.setenv('MODE', 'disabled')")
542 );
543 }
544
545 #[test]
546 fn test_commands_evaluate_platform_alias_with_local_variables() {
547 let package = PythonAstParser::parse_package_py(
548 r#"
549name = "test_package"
550version = "15.2.1"
551def commands():
552 extension = ".exe" if system.platform == "windows" else ""
553 executable = "Tool{{this.version.major}}.{{this.version.minor}}{0}".format(extension)
554 if system.platform == "windows":
555 alias("tool", "{0} $*".format(executable))
556 else:
557 alias("tool", "{0} $@".format(executable))
558"#,
559 )
560 .unwrap();
561
562 let suffix = if cfg!(windows) { ".exe $*" } else { " $@" };
563 assert_eq!(
564 package.commands.as_deref(),
565 Some(format!(
566 "alias('tool', 'Tool{{this.version.major}}.{{this.version.minor}}{suffix}')"
567 ))
568 .as_deref()
569 );
570 }
571
572 #[test]
573 fn test_dynamic_metadata_is_rejected() {
574 let error = PythonAstParser::parse_package_py(
575 r#"
576name = "test_package"
577version = load_version()
578"#,
579 )
580 .expect_err("dynamic metadata must not be converted into debug text");
581
582 assert!(
583 error.to_string().contains("Unsupported function call"),
584 "unexpected error: {error}"
585 );
586 }
587
588 #[test]
589 fn test_unknown_command_is_rejected() {
590 let error = PythonAstParser::parse_package_py(
591 r#"
592name = "test_package"
593version = "1.0.0"
594def commands():
595 env.PATH.extend("/unsupported")
596"#,
597 )
598 .expect_err("unsupported commands must not disappear from the environment");
599
600 assert!(
601 error.to_string().contains("Unsupported command statement"),
602 "unexpected error: {error}"
603 );
604 }
605
606 #[test]
607 fn test_dynamic_metadata_function_is_rejected() {
608 let error = PythonAstParser::parse_package_py(
609 r#"
610name = "test_package"
611@early()
612def version():
613 return "1.0.0"
614"#,
615 )
616 .expect_err("dynamic metadata functions require Python execution");
617
618 assert!(
619 error
620 .to_string()
621 .contains("Unsupported package.py function"),
622 "unexpected error: {error}"
623 );
624 }
625
626 #[test]
627 fn test_dynamic_control_flow_is_rejected() {
628 let error = PythonAstParser::parse_package_py(
629 r#"
630name = "test_package"
631version = "1.0.0"
632for requirement in discover_requirements():
633 requires = [requirement]
634"#,
635 )
636 .expect_err("loops must not be approximated by executing their body once");
637
638 assert!(
639 error
640 .to_string()
641 .contains("Unsupported package.py for loop"),
642 "unexpected error: {error}"
643 );
644 }
645
646 #[test]
647 fn test_structured_package_test_is_rejected_until_supported() {
648 let error = PythonAstParser::parse_package_py(
649 r#"
650name = "test_package"
651version = "1.0.0"
652tests = {
653 "unit": {
654 "command": "python -m pytest",
655 "requires": ["pytest"],
656 },
657}
658"#,
659 )
660 .expect_err("structured test metadata must not be flattened into debug text");
661
662 assert!(
663 error.to_string().contains("Unsupported test definition"),
664 "unexpected error: {error}"
665 );
666 }
667
668 #[test]
669 fn test_package_test_command_preserves_nested_quotes() {
670 let package = PythonAstParser::parse_package_py(
671 r#"
672name = "test_package"
673version = "1.0.0"
674tests = {
675 "import": "python -c \"import test_package; print(test_package.VALUE)\"",
676}
677"#,
678 )
679 .unwrap();
680
681 assert_eq!(
682 package.tests.get("import").map(String::as_str),
683 Some(r#"python -c "import test_package; print(test_package.VALUE)""#)
684 );
685 }
686}