diff --git a/.editorconfig b/.editorconfig index 5696cb0f..d1d29f14 100644 --- a/.editorconfig +++ b/.editorconfig @@ -12,6 +12,10 @@ insert_final_newline = true indent_style = space indent_size = 4 +[*.py] +indent_style = space +indent_size = 4 + # Tab indentation (no size specified) [Makefile] indent_style = tab diff --git a/packages/mrml-python/Cargo.toml b/packages/mrml-python/Cargo.toml index 029e19c0..382616f2 100644 --- a/packages/mrml-python/Cargo.toml +++ b/packages/mrml-python/Cargo.toml @@ -16,5 +16,8 @@ name = "mrml" crate-type = ["cdylib"] [dependencies] -mrml = { version = "3.0.3", path = "../mrml-core" } +mrml = { version = "3.0.3", path = "../mrml-core", features = [ + "http-loader-ureq", + "local-loader", +] } pyo3 = { version = "0.20.0", features = ["extension-module"] } diff --git a/packages/mrml-python/resources/partials/hello-world.mjml b/packages/mrml-python/resources/partials/hello-world.mjml new file mode 100644 index 00000000..ff87f274 --- /dev/null +++ b/packages/mrml-python/resources/partials/hello-world.mjml @@ -0,0 +1 @@ +Hello World diff --git a/packages/mrml-python/resources/with-http-include.mjml b/packages/mrml-python/resources/with-http-include.mjml new file mode 100644 index 00000000..d5bb8cce --- /dev/null +++ b/packages/mrml-python/resources/with-http-include.mjml @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/mrml-python/resources/with-local-include.mjml b/packages/mrml-python/resources/with-local-include.mjml new file mode 100644 index 00000000..1a8d76ee --- /dev/null +++ b/packages/mrml-python/resources/with-local-include.mjml @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/mrml-python/resources/with-multi-include.mjml b/packages/mrml-python/resources/with-multi-include.mjml new file mode 100644 index 00000000..f71ac19d --- /dev/null +++ b/packages/mrml-python/resources/with-multi-include.mjml @@ -0,0 +1,8 @@ + + + + + + diff --git a/packages/mrml-python/src/lib.rs b/packages/mrml-python/src/lib.rs index 71c65bf8..e5051297 100644 --- a/packages/mrml-python/src/lib.rs +++ b/packages/mrml-python/src/lib.rs @@ -1,7 +1,10 @@ use std::borrow::Cow; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use mrml::prelude::parser::http_loader::{HttpIncludeLoader, UreqFetcher}; use mrml::prelude::parser::loader::IncludeLoader; +use mrml::prelude::parser::local_loader::LocalIncludeLoader; use mrml::prelude::parser::memory_loader::MemoryIncludeLoader; use mrml::prelude::parser::noop_loader::NoopIncludeLoader; use pyo3::exceptions::PyOSError; @@ -15,11 +18,37 @@ pub struct NoopIncludeLoaderOptions; #[derive(Clone, Debug, Default)] pub struct MemoryIncludeLoaderOptions(HashMap); +#[pyclass] +#[derive(Clone, Debug, Default)] +pub struct LocalIncludeLoaderOptions(PathBuf); + +#[pyclass] +#[derive(Clone, Debug)] +pub enum HttpIncludeLoaderOptionsMode { + Allow, + Deny, +} + +impl Default for HttpIncludeLoaderOptionsMode { + fn default() -> Self { + Self::Allow + } +} + +#[pyclass] +#[derive(Clone, Debug, Default)] +pub struct HttpIncludeLoaderOptions { + mode: HttpIncludeLoaderOptionsMode, + list: HashSet, +} + // #[pyclass] #[derive(FromPyObject, Clone, Debug)] pub enum ParserIncludeLoaderOptions { Noop(NoopIncludeLoaderOptions), Memory(MemoryIncludeLoaderOptions), + Local(LocalIncludeLoaderOptions), + Http(HttpIncludeLoaderOptions), } impl Default for ParserIncludeLoaderOptions { @@ -35,6 +64,17 @@ impl ParserIncludeLoaderOptions { Self::Memory(MemoryIncludeLoaderOptions(inner)) => { Box::new(MemoryIncludeLoader::from(inner)) } + Self::Local(LocalIncludeLoaderOptions(inner)) => { + Box::new(LocalIncludeLoader::new(inner)) + } + Self::Http(HttpIncludeLoaderOptions { mode, list }) => match mode { + HttpIncludeLoaderOptionsMode::Allow => { + Box::new(HttpIncludeLoader::::new_allow(list)) + } + HttpIncludeLoaderOptionsMode::Deny => { + Box::new(HttpIncludeLoader::::new_deny(list)) + } + }, } } } @@ -44,6 +84,8 @@ impl IntoPy for ParserIncludeLoaderOptions { match self { Self::Noop(inner) => inner.into_py(py), Self::Memory(inner) => inner.into_py(py), + Self::Local(inner) => inner.into_py(py), + Self::Http(inner) => inner.into_py(py), } } } @@ -60,6 +102,34 @@ pub fn memory_loader(data: Option>) -> ParserIncludeLoad ParserIncludeLoaderOptions::Memory(MemoryIncludeLoaderOptions(data.unwrap_or_default())) } +#[pyfunction] +#[pyo3(name = "local_loader", signature = (data = None))] +pub fn local_loader(data: Option) -> PyResult { + let path = match data.map(PathBuf::from) { + Some(path) if path.is_absolute() => path, + Some(path) => std::env::current_dir() + .map_err(|err| PyErr::new::(err.to_string()))? + .join(path), + None => std::env::current_dir() + .map_err(|err| PyErr::new::(err.to_string()))?, + }; + Ok(ParserIncludeLoaderOptions::Local( + LocalIncludeLoaderOptions(path), + )) +} + +#[pyfunction] +#[pyo3(name = "http_loader", signature = (mode = None, list = None))] +pub fn http_loader( + mode: Option, + list: Option>, +) -> ParserIncludeLoaderOptions { + ParserIncludeLoaderOptions::Http(HttpIncludeLoaderOptions { + mode: mode.unwrap_or(HttpIncludeLoaderOptionsMode::Allow), + list: list.unwrap_or_default(), + }) +} + #[pyclass] #[derive(Clone, Debug, Default)] pub struct ParserOptions { @@ -147,10 +217,15 @@ fn to_html( fn register(_py: Python<'_>, m: &PyModule) -> PyResult<()> { m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_function(wrap_pyfunction!(to_html, m)?)?; m.add_function(wrap_pyfunction!(noop_loader, m)?)?; + m.add_function(wrap_pyfunction!(local_loader, m)?)?; + m.add_function(wrap_pyfunction!(http_loader, m)?)?; m.add_function(wrap_pyfunction!(memory_loader, m)?)?; Ok(()) } diff --git a/packages/mrml-python/tests/test_loader.py b/packages/mrml-python/tests/test_loader.py new file mode 100644 index 00000000..82386c2e --- /dev/null +++ b/packages/mrml-python/tests/test_loader.py @@ -0,0 +1,123 @@ +import mrml + + +def test_memory_loader(): + parser_options = mrml.ParserOptions( + include_loader=mrml.memory_loader( + { + "hello-world.mjml": "Hello World!", + } + ) + ) + result = mrml.to_html( + '', + parser_options=parser_options, + ) + assert result.startswith("") + + +def test_local_loader_success(): + parser_options = mrml.ParserOptions( + include_loader=mrml.local_loader("./resources/partials") + ) + result = mrml.to_html( + '', + parser_options=parser_options, + ) + assert result.startswith("") + + +def test_local_loader_missing(): + parser_options = mrml.ParserOptions( + include_loader=mrml.local_loader("./resources/partials") + ) + try: + mrml.to_html( + '', + parser_options=parser_options, + ) + assert False + except Exception as err: + assert err + + +def test_http_loader_success(): + parser_options = mrml.ParserOptions( + include_loader=mrml.http_loader( + mode=mrml.HttpIncludeLoaderOptionsMode.Allow, + list=set(["https://gist.githubusercontent.com"]), + ) + ) + result = mrml.to_html( + """ + + + +""", + parser_options=parser_options, + ) + assert result.startswith("") + + +def test_http_loader_failed_not_in_allow_list(): + parser_options = mrml.ParserOptions( + include_loader=mrml.http_loader( + mode=mrml.HttpIncludeLoaderOptionsMode.Allow, + list=set([]), + ) + ) + try: + mrml.to_html( + """ + + + +""", + parser_options=parser_options, + ) + assert False + except Exception as err: + assert err + + +def test_http_loader_success_allow_everything(): + parser_options = mrml.ParserOptions( + include_loader=mrml.http_loader(mode=mrml.HttpIncludeLoaderOptionsMode.Deny) + ) + mrml.to_html( + """ + + + +""", + parser_options=parser_options, + ) + + +def test_http_loader_failed_deny_github(): + parser_options = mrml.ParserOptions( + include_loader=mrml.http_loader( + mode=mrml.HttpIncludeLoaderOptionsMode.Deny, + list=set(["https://gist.githubusercontent.com"]), + ) + ) + try: + mrml.to_html( + """ + + + +""", + parser_options=parser_options, + ) + assert False + except Exception as err: + assert err