Skip to main content

rez_next_package/python_ast_parser/
mod.rs

1//! Advanced Python AST parser for package.py files using RustPython.
2//!
3//! This module is split into focused submodules:
4//! - `types`: Core data types (`PythonValue`, `ParsingContext`, `PackageData`)
5//! - `eval`: Expression evaluation (`evaluate_*`)
6//! - `extract`: Typed value extraction (`extract_*`)
7//! - `commands`: Rex DSL command parsing (`process_commands_function` etc.)
8
9mod 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/// Advanced Python AST parser for package.py files
22#[derive(Default)]
23pub struct PythonAstParser {
24    /// Context for tracking variables and imports during parsing
25    pub(crate) context: ParsingContext,
26}
27
28impl PythonAstParser {
29    /// Create a new parser instance
30    pub fn new() -> Self {
31        Self {
32            context: ParsingContext::default(),
33        }
34    }
35
36    /// Parse a package.py file using Python AST
37    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    /// Parse a package.py file with context tracking
43    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    /// Process a single AST statement
57    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    /// Process import statements (`import os`)
106    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    /// Process from-import statements (`from os import path`)
125    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    /// Process function definitions (`def commands(): ...`)
143    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    /// Process conditional statements
172    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    /// Process variable assignments and map them to `PackageData` fields
198    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    /// Reject loops because traversing their body once corrupts package metadata.
306    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    /// Reject loops because traversing their body once corrupts package metadata.
318    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    /// Reject try/except because evaluating every branch corrupts package metadata.
330    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    /// Process with statements
342    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    /// Process standalone expression statements (evaluate for side-effects only)
354    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    /// Build a `Package` from the collected `PackageData`
364    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}