Skip to content

Commit

Permalink
feat(mrml-python): implement mj-include (#381)
Browse files Browse the repository at this point in the history
* feat(mrml-python): create local loader

Signed-off-by: Jérémie Drouet <jeremie.drouet@gmail.com>

* feat(mrml-python): create http loader

Signed-off-by: Jérémie Drouet <jeremie.drouet@gmail.com>

* chore(mrml-python): update editor config

Signed-off-by: Jérémie Drouet <jeremie.drouet@gmail.com>

---------

Signed-off-by: Jérémie Drouet <jeremie.drouet@gmail.com>
  • Loading branch information
jdrouet authored Feb 29, 2024
1 parent f45dca0 commit 907229e
Show file tree
Hide file tree
Showing 8 changed files with 228 additions and 2 deletions.
4 changes: 4 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 4 additions & 1 deletion packages/mrml-python/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
1 change: 1 addition & 0 deletions packages/mrml-python/resources/partials/hello-world.mjml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<mj-text>Hello World</mj-text>
7 changes: 7 additions & 0 deletions packages/mrml-python/resources/with-http-include.mjml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<mjml>
<mj-body>
<mj-include
path="https://gist.githubusercontent.com/jdrouet/b0ac80fa08a3e7262bd4c94fc8865a87/raw/ec8771f4804a6c38427ed2a9f5937e11ec2b8c27/hello-world.mjml"
/>
</mj-body>
</mjml>
5 changes: 5 additions & 0 deletions packages/mrml-python/resources/with-local-include.mjml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<mjml>
<mj-body>
<mj-include path="file:///hello-world.mjml" />
</mj-body>
</mjml>
8 changes: 8 additions & 0 deletions packages/mrml-python/resources/with-multi-include.mjml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<mjml>
<mj-body>
<mj-include path="file:///hello-world.mjml" />
<mj-include
path="https://gist.githubusercontent.com/jdrouet/b0ac80fa08a3e7262bd4c94fc8865a87/raw/ec8771f4804a6c38427ed2a9f5937e11ec2b8c27/hello-world.mjml"
/>
</mj-body>
</mjml>
77 changes: 76 additions & 1 deletion packages/mrml-python/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,11 +18,37 @@ pub struct NoopIncludeLoaderOptions;
#[derive(Clone, Debug, Default)]
pub struct MemoryIncludeLoaderOptions(HashMap<String, String>);

#[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<String>,
}

// #[pyclass]
#[derive(FromPyObject, Clone, Debug)]
pub enum ParserIncludeLoaderOptions {
Noop(NoopIncludeLoaderOptions),
Memory(MemoryIncludeLoaderOptions),
Local(LocalIncludeLoaderOptions),
Http(HttpIncludeLoaderOptions),
}

impl Default for ParserIncludeLoaderOptions {
Expand All @@ -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::<UreqFetcher>::new_allow(list))
}
HttpIncludeLoaderOptionsMode::Deny => {
Box::new(HttpIncludeLoader::<UreqFetcher>::new_deny(list))
}
},
}
}
}
Expand All @@ -44,6 +84,8 @@ impl IntoPy<PyObject> 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),
}
}
}
Expand All @@ -60,6 +102,34 @@ pub fn memory_loader(data: Option<HashMap<String, String>>) -> ParserIncludeLoad
ParserIncludeLoaderOptions::Memory(MemoryIncludeLoaderOptions(data.unwrap_or_default()))
}

#[pyfunction]
#[pyo3(name = "local_loader", signature = (data = None))]
pub fn local_loader(data: Option<String>) -> PyResult<ParserIncludeLoaderOptions> {
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::<pyo3::exceptions::PyException, String>(err.to_string()))?
.join(path),
None => std::env::current_dir()
.map_err(|err| PyErr::new::<pyo3::exceptions::PyException, String>(err.to_string()))?,
};
Ok(ParserIncludeLoaderOptions::Local(
LocalIncludeLoaderOptions(path),
))
}

#[pyfunction]
#[pyo3(name = "http_loader", signature = (mode = None, list = None))]
pub fn http_loader(
mode: Option<HttpIncludeLoaderOptionsMode>,
list: Option<HashSet<String>>,
) -> ParserIncludeLoaderOptions {
ParserIncludeLoaderOptions::Http(HttpIncludeLoaderOptions {
mode: mode.unwrap_or(HttpIncludeLoaderOptionsMode::Allow),
list: list.unwrap_or_default(),
})
}

#[pyclass]
#[derive(Clone, Debug, Default)]
pub struct ParserOptions {
Expand Down Expand Up @@ -147,10 +217,15 @@ fn to_html(
fn register(_py: Python<'_>, m: &PyModule) -> PyResult<()> {
m.add_class::<NoopIncludeLoaderOptions>()?;
m.add_class::<MemoryIncludeLoaderOptions>()?;
m.add_class::<LocalIncludeLoaderOptions>()?;
m.add_class::<HttpIncludeLoaderOptions>()?;
m.add_class::<HttpIncludeLoaderOptionsMode>()?;
m.add_class::<ParserOptions>()?;
m.add_class::<RenderOptions>()?;
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(())
}
123 changes: 123 additions & 0 deletions packages/mrml-python/tests/test_loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import mrml


def test_memory_loader():
parser_options = mrml.ParserOptions(
include_loader=mrml.memory_loader(
{
"hello-world.mjml": "<mj-text>Hello World!</mj-text>",
}
)
)
result = mrml.to_html(
'<mjml><mj-body><mj-include path="hello-world.mjml" /></mj-body></mjml>',
parser_options=parser_options,
)
assert result.startswith("<!doctype html>")


def test_local_loader_success():
parser_options = mrml.ParserOptions(
include_loader=mrml.local_loader("./resources/partials")
)
result = mrml.to_html(
'<mjml><mj-body><mj-include path="file:///hello-world.mjml" /></mj-body></mjml>',
parser_options=parser_options,
)
assert result.startswith("<!doctype html>")


def test_local_loader_missing():
parser_options = mrml.ParserOptions(
include_loader=mrml.local_loader("./resources/partials")
)
try:
mrml.to_html(
'<mjml><mj-body><mj-include path="file:///not-found.mjml" /></mj-body></mjml>',
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(
"""<mjml>
<mj-body>
<mj-include
path="https://gist.githubusercontent.com/jdrouet/b0ac80fa08a3e7262bd4c94fc8865a87/raw/ec8771f4804a6c38427ed2a9f5937e11ec2b8c27/hello-world.mjml"
/>
</mj-body>
</mjml>""",
parser_options=parser_options,
)
assert result.startswith("<!doctype html>")


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(
"""<mjml>
<mj-body>
<mj-include
path="https://gist.githubusercontent.com/jdrouet/b0ac80fa08a3e7262bd4c94fc8865a87/raw/ec8771f4804a6c38427ed2a9f5937e11ec2b8c27/hello-world.mjml"
/>
</mj-body>
</mjml>""",
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(
"""<mjml>
<mj-body>
<mj-include
path="https://gist.githubusercontent.com/jdrouet/b0ac80fa08a3e7262bd4c94fc8865a87/raw/ec8771f4804a6c38427ed2a9f5937e11ec2b8c27/hello-world.mjml"
/>
</mj-body>
</mjml>""",
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(
"""<mjml>
<mj-body>
<mj-include
path="https://gist.githubusercontent.com/jdrouet/b0ac80fa08a3e7262bd4c94fc8865a87/raw/ec8771f4804a6c38427ed2a9f5937e11ec2b8c27/hello-world.mjml"
/>
</mj-body>
</mjml>""",
parser_options=parser_options,
)
assert False
except Exception as err:
assert err

0 comments on commit 907229e

Please sign in to comment.