sccmod/
module.rs

1use std::{collections::HashMap, fs::DirEntry};
2
3use pyo3::prelude::*;
4
5use crate::{
6    builders::builder_trait::{Builder, BuilderImpl},
7    config,
8    downloaders::{Downloader, DownloaderImpl},
9    file_manager::{recursive_list_dir, PATH_SEP},
10    flavours, log, modulefile,
11    python_interop::{extract_object, load_program},
12    shell::Shell,
13};
14
15pub fn get_submodule_path(parent: &str, submodule: &str) -> String {
16    format!("{parent}/sccmod_submodules/{submodule}")
17}
18
19#[derive(Debug, Clone)]
20pub enum Dependency {
21    Class(String),   // Flavours class
22    Module(String),  // Module name
23    Depends(String), // Dependent module name
24    Deny(String),    // Prevent compiling with this flvaour
25}
26
27#[derive(Debug, Clone)]
28pub enum Environment {
29    Set(String),
30    SetExact(String),
31    Append(String),
32    Prepend(String),
33}
34
35#[derive(Debug, Clone)]
36pub struct Module {
37    /// Name of the module
38    pub name: String,
39
40    /// Module version
41    pub version: String,
42
43    /// Module class (flavours)
44    pub class: String,
45
46    /// Module dependencies
47    pub dependencies: Vec<Dependency>,
48
49    /// Module metadata
50    pub metadata: HashMap<String, String>,
51
52    /// Environment variables to set/change
53    pub environment: Vec<(String, Environment)>,
54
55    /// A list of commands to run before building
56    pub pre_build: Option<Vec<String>>,
57
58    /// A list of commands to run after installing
59    pub post_install: Option<Vec<String>>,
60
61    /// Downloader to download the source code
62    pub downloader: Option<Downloader>,
63
64    /// Builder to build and install the source code
65    pub builder: Option<Builder>,
66
67    pub source_path: String,
68    pub build_path: String,
69    pub install_path: String,
70}
71
72impl Module {
73    /// Parse a flavour into:
74    ///  - flavour_str: a postfix to a path pointing to a flavour directory
75    ///  - build_path: updated build path
76    ///  - install_path: updated install path
77    ///  - modules: module names necessary for installation
78    pub fn parse(
79        &self,
80        flavour: &(&[Module], usize),
81    ) -> (String, String, String, Vec<String>) {
82        // Generate extension to build path based on flavour
83        let mut flavour_str = format!("{PATH_SEP}1{PATH_SEP}"); // '/1/' for revision
84
85        // If no class modules are required, install into `default` flavour
86        if flavour.1 == 0 {
87            flavour_str.push_str(&format!("default"))
88        } else {
89            for (i, flav) in (0..flavour.1).zip(flavour.0.iter()) {
90                flavour_str
91                    .push_str(&format!("{}-{}", &flav.name, &flav.version));
92
93                if i + 1 < flavour.1 {
94                    flavour_str.push('-');
95                }
96            }
97        }
98
99        let build_path = self.build_path.clone() + &flavour_str;
100        let install_path = self.install_path.clone() + &flavour_str;
101
102        // List of modulefiles
103        let modules: Vec<String> =
104            flavour.0.iter().map(|flav| flav.mod_name()).collect();
105
106        (flavour_str, build_path, install_path, modules)
107    }
108
109    pub fn identifier(&self) -> String {
110        format!("{}/{}/{}", self.class, self.name, self.version)
111    }
112
113    pub fn mod_name(&self) -> String {
114        format!("{}/{}", self.name, self.version)
115    }
116
117    /// Download the source code for the module, based on its [`Downloader`].
118    ///
119    /// # Errors
120    /// This will error if the download fails, with an error [`String`]
121    /// containing either an error message or the output of the errored
122    /// command.
123    pub fn download(&self) -> Result<(), String> {
124        if let Some(downloader) = &self.downloader {
125            downloader.download(&self.source_path)
126        } else {
127            log::warn(&format!(
128                "Module '{}' does not hav a builder",
129                self.identifier()
130            ));
131
132            Ok(())
133        }
134    }
135
136    /// Build the source code for this module, based on its [`Builder`].
137    ///
138    /// # Errors
139    /// This will error if the build fails, with an error [`String`] containing
140    /// either an error message or the output of the errored command.
141    pub fn build(
142        &self,
143        flavour: (&[Self], usize), // ([dep0, dep1, ..., depN], num_flavour)
144    ) -> Result<(), String> {
145        if let Some(builder) = &self.builder {
146            if let Some(commands) = &self.pre_build {
147                log::status("Running pre-build commands");
148                let mut shell = Shell::default();
149                shell.set_current_dir(&self.source_path);
150                for cmd in commands {
151                    shell.add_command(cmd);
152                }
153
154                let (result, stdout, stderr) = shell.exec();
155
156                let result =
157                    result.map_err(|_| "Failed to run CMake command")?;
158
159                if !result.success() {
160                    return Err(format!(
161                        "Failed to execute command. Output:\n{}\n{}",
162                        stdout.join("\n"),
163                        stderr.join("\n")
164                    ));
165                }
166
167                log::status("Building...");
168            }
169
170            let (_, build_path, install_path, modules) = self.parse(&flavour);
171
172            builder.build(
173                &self.source_path,
174                &build_path,
175                &install_path,
176                &modules,
177            )
178        } else {
179            log::warn(&format!(
180                "Module '{}' does not have a Builder",
181                self.identifier()
182            ));
183            Ok(())
184        }
185    }
186
187    /// Install the source code for this module based on its [`Builder`].
188    ///
189    /// # Errors
190    /// Errors if the installation fails. The [`Result`] output contains a
191    /// [`String`] with either an error message or the output of the errored
192    /// program.
193    pub fn install(&self, flavour: (&[Module], usize)) -> Result<(), String> {
194        if let Some(builder) = &self.builder {
195            let (_, build_path, install_path, modules) = self.parse(&flavour);
196
197            builder.install(
198                &self.source_path,
199                &build_path,
200                &install_path,
201                &modules,
202            )?;
203
204            if let Some(commands) = &self.post_install {
205                log::status(&"Running post-install commands");
206                let mut shell = Shell::default();
207                shell.set_current_dir(&install_path);
208
209                for module in &modules {
210                    shell.add_command(&format!("module load {}", module));
211                }
212
213                for cmd in commands {
214                    shell.add_command(&cmd);
215                }
216
217                let (result, stdout, stderr) = shell.exec();
218
219                let result = result
220                    .map_err(|_| "Failed to run post-install commands")?;
221
222                if !result.success() {
223                    return Err(format!(
224                        "Failed to execute command. Output:\n{}\n{}",
225                        stdout.join("\n"),
226                        stderr.join("\n")
227                    ));
228                }
229
230                log::status(&"Building...");
231            }
232
233            Ok(())
234        } else {
235            log::warn(&format!(
236                "Module '{}' does not have a Builder",
237                self.identifier()
238            ));
239            Ok(())
240        }
241    }
242
243    /// Extract a [`Module`] object from a python object.
244    ///
245    /// # Errors
246    /// This method will return [`Err(msg)`] if the object cannot be parsed
247    /// successfully. `msg` is a string and contains the error message.
248    pub fn from_object(
249        object: &Bound<PyAny>,
250        config: &config::Config,
251    ) -> Result<Self, String> {
252        Python::with_gil(|_| {
253            let metadata: HashMap<String, String> =
254                extract_object(object, "metadata")?
255                    .call0()
256                    .map_err(|err| format!("Failed to call `metadata`: {err}"))?
257                    .extract()
258                    .map_err(|err| {
259                        format!(
260                    "Failed to convert metadata output to Rust HashMap: {err}"
261                )
262                    })?;
263
264            let name = metadata
265                .get("name")
266                .ok_or("metadata does not contain key 'name'")?
267                .to_owned();
268
269            let version = metadata
270                .get("version")
271                .ok_or("Metadata does not contain key 'version'")?
272                .to_owned();
273
274            let class = metadata
275                .get("class")
276                .ok_or("Metadata does not contain key 'class'")?
277                .to_owned();
278
279            let downloader: Result<Option<Downloader>, String> =
280                match object.getattr("download") {
281                    Ok(download) => Ok(Some(Downloader::from_py(
282                        &download.call0().map_err(|err| {
283                            format!(
284                            "Failed to call `download` in module class: {err}"
285                        )
286                        })?,
287                    )?)),
288                    Err(_) => Ok(None),
289                };
290            let downloader = downloader?;
291
292            let dependencies: Vec<&PyAny> = extract_object(
293                object,
294                "dependencies",
295            )?
296            .call0()
297            .map_err(|err| {
298                format!("Failed to call `build_requirements`: {err}")
299            })?
300            .extract()
301            .map_err(|err| {
302                format!("Failed to convert `dependencies()` to Rust Vec: {err}")
303            })?;
304
305            // Convert dependencies into a Rust vector
306            let mut dependencies: Vec<Dependency> = dependencies.iter().map(|dep| {
307                match dep.get_type().to_string().as_ref() {
308                    "<class 'sccmod.module.Class'>" => {
309                        match dep.getattr("name").map_err(|err| format!("Dependency is a Class instance, but does not contain a .name attribute: {err}"))?.extract::<String>() {
310                            Ok(name) => {
311                                Ok(Dependency::Class(name))
312                            },
313                            Err(e) => Err(format!("Could not convert .name attribute to Rust String: {e}"))
314                        }
315                    },
316                    "<class 'sccmod.module.Deny'>" => {
317                        match dep.getattr("name").map_err(|err| format!("Dependency is a Deny instance, but does not contain a .name attribute: {err}"))?.extract::<String>() {
318                            Ok(name) => {
319                                Ok(Dependency::Deny(name))
320                            },
321                            Err(e) => Err(format!("Could not convert .name attribute to Rust String: {e}"))
322                        }
323                    },
324                    "<class 'sccmod.module.Depends'>" => {
325                        match dep.getattr("name").map_err(|err| format!("Dependency is a Depends instance, but does not contain a .name attribute: {err}"))?.extract::<String>() {
326                            Ok(name) => {
327                                Ok(Dependency::Depends(name))
328                            },
329                            Err(e) => Err(format!("Could not convert .name attribute to Rust String: {e}"))
330                        }
331                    },
332                    _ => Ok(Dependency::Module(dep.to_string())),
333                }
334            }).collect::<Result<Vec<Dependency>, String>>()?;
335
336            let environment: Vec<(String, (String, String))> = extract_object(
337                object,
338                "environment",
339            )?
340            .call0()
341            .map_err(|err| format!("Failed to call '.environment()': {err}"))?
342            .extract()
343            .map_err(|err| {
344                format!("Failed to convert output of `.environment()` to Rust Vec<(String, (String, String))>: {err}")
345            })?;
346
347            // Convert (String, String) to Environment(String)
348            let environment = environment
349                .into_iter()
350                .map(|(name, (op, value))| match op.as_ref() {
351                    "set" => Ok((name, Environment::Set(value))),
352                    "setexact" => Ok((name, Environment::SetExact(value))),
353                    "append" => Ok((name, Environment::Append(value))),
354                    "prepend" => Ok((name, Environment::Prepend(value))),
355                    other => Err(format!(
356                        "Invalid environment variable operation '{other}'"
357                    )),
358                })
359                .collect::<Result<Vec<(String, Environment)>, String>>()?;
360
361            let builder: Result<Option<Builder>, String> = match object
362                .getattr("build")
363            {
364                Ok(download) => Ok(Some(Builder::from_py(
365                    &download.call0().map_err(|err| {
366                        format!("Failed to call `build` in module class: {err}")
367                    })?,
368                )?)),
369                Err(_) => Ok(None),
370            };
371            let builder = builder?;
372
373            let pre_build: Option<Vec<String>> = match extract_object(object, "pre_build") {
374                Ok(obj) => Some(
375                    obj.call0()
376                        .map_err(|err| {
377                            format!("Failed to call 'pre_build()` in module class: {err}")
378                        })?
379                        .extract()
380                        .map_err(|err| {
381                            format!("Failed to convert object to Rust Vec<String>: {err}")
382                        })?,
383                ),
384                Err(_) => None,
385            };
386
387            let post_install: Option<Vec<String>> = match extract_object(object, "post_install") {
388                Ok(obj) => Some(
389                    obj.call0()
390                        .map_err(|err| {
391                            format!("Failed to call 'post_install()` in module class: {err}")
392                        })?
393                        .extract()
394                        .map_err(|err| {
395                            format!("Failed to convert object to Rust Vec<String>: {err}")
396                        })?,
397                ),
398                Err(_) => None,
399            };
400
401            let source_path = format!(
402                "{}{PATH_SEP}{}{PATH_SEP}{}",
403                config.build_root, name, version
404            );
405
406            let build_path = format!("{source_path}/sccmod_build");
407
408            let install_path = format!(
409                "{1:}{0:}{2:}{0:}{3:}-{4:}",
410                PATH_SEP, config.install_root, class, name, version
411            );
412
413            Ok(Self {
414                name,
415                version,
416                class,
417                dependencies,
418                environment,
419                metadata,
420                pre_build,
421                post_install,
422                downloader,
423                builder,
424                source_path,
425                build_path,
426                install_path,
427            })
428        })
429    }
430
431    pub fn modulefile(&self) -> Result<(), String> {
432        // Write modulefile
433        log::status(&format!("Writing Modulefile for {}", self.mod_name()));
434        let conf = config::read()?;
435        let dir = format!(
436            "{}{PATH_SEP}{}{PATH_SEP}{}{PATH_SEP}{}",
437            conf.modulefile_root, self.class, self.name, self.version
438        );
439        let dir = std::path::Path::new(&dir);
440
441        let content = modulefile::generate(&self);
442
443        std::fs::create_dir_all(dir.parent().unwrap()).unwrap();
444        std::fs::write(dir, content)
445            .map_err(|err| format!("Failed to write modulefile: {err}"))
446    }
447}
448
449/// List all available modules
450///
451/// # Errors
452/// Will error if:
453///  - The configuration file cannot be read (see [`Config`])
454///  - Any specified directory cannot be read (see [`recursive_list_dir`])
455pub fn get_modules() -> Result<Vec<Module>, String> {
456    config::read().and_then(|config| {
457        config // Extract module paths
458            .sccmod_module_paths
459            .iter()
460            .flat_map(|path| {
461                // Expand search paths recursively to get *all* files
462                recursive_list_dir(path).map_or_else(
463                    || vec![Err("Failed to extract paths".to_string())],
464                    |paths| {
465                        // Map path -> Ok(path)
466                        paths.into_iter().map(Ok).collect()
467                    },
468                )
469            })
470            .collect::<Result<Vec<DirEntry>, _>>()? // Collect and propagate Result
471            .iter()
472            .map(|path| {
473                // Extract modules from files
474                Python::with_gil(|py| {
475                    let program = load_program(&py, &path.path())?;
476                    let modules: Vec<_> = program
477                        .getattr("generate")
478                        .map_err(|err| format!("Failed to load generator: {err}"))?
479                        .call0()
480                        .map_err(|err| format!("Failed to call generator: {err}"))?
481                        .extract()
482                        .map_err(|err| {
483                            format!("Failed to convert output of `generate` to Vec: {err}")
484                        })?;
485
486                    modules // Map python objects to Modules
487                        .iter()
488                        .map(|module| Module::from_object(module, &config))
489                        .collect::<Result<Vec<Module>, String>>()
490                })
491            })
492            .flat_map(|v| {
493                // Flat map vectors to extract errors
494                v.map_or_else(
495                    |err| vec![Err(format!("Something went wrong: {err}"))],
496                    |vec| vec.into_iter().map(Ok).collect(),
497                )
498            })
499            .collect::<Result<Vec<_>, _>>() // Collect as result
500    })
501}
502
503/// Download a module.
504///
505/// # Errors
506/// Errors if [`Module.download`] fails.
507pub fn download(module: &Module) -> Result<(), String> {
508    log::status(&format!("Downloading '{}-{}'", module.name, module.version));
509    module.download()
510}
511
512/// Download and build a module.
513///
514/// # Errors
515/// Errors if [`Module.download`] fails or [`Module.build`] fails.
516pub fn build(module: &Module) -> Result<(), String> {
517    download(module)?;
518
519    log::status(&format!("Building '{}-{}'", module.name, module.version));
520
521    let flavs = flavours::generate(module)?;
522
523    for flav in &flavs {
524        log::info(&format!("Building flavour {}", flavours::gen_name(flav)));
525        module.build((&flav.0, flav.1))?;
526    }
527
528    Ok(())
529}
530
531/// Download, build and install a module.
532///
533/// # Errors
534/// Errors if [`Module.download`], [`Module.build`] or [`Module.install`] fails.
535pub fn install(module: &Module) -> Result<(), String> {
536    build(module)?;
537
538    log::status(&format!("Installing '{}-{}'", module.name, module.version));
539
540    let flavs = flavours::generate(module)?;
541
542    for flav in &flavs {
543        log::info(&format!("Installing flavour {}", flavours::gen_name(flav)));
544        module.install((&flav.0, flav.1))?;
545    }
546
547    module.modulefile()
548}
549
550pub fn modulefile(module: &Module) -> Result<(), String> {
551    module.modulefile()
552}