diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf9eb43..6115fc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,23 +31,23 @@ jobs: run: uv sync --all-extras --dev - name: Install emailer-lib - run: uv pip install -e "./emailer-lib" + run: uv pip install -e . # quarto docs build ---- - uses: quarto-dev/quarto-actions/setup@v2 - with: - tinytex: true + # with: + # tinytex: true - name: Build docs run: | - uv run quartodoc build --verbose && uv run quarto render + cd docs && uv run quartodoc build --verbose && uv run quarto render - name: Deploy uses: JamesIves/github-pages-deploy-action@v4 if: ${{ github.ref == 'refs/heads/main' }} with: force: false - folder: _site + folder: docs/_site clean-exclude: pr-preview - name: Deploy (preview) @@ -55,7 +55,7 @@ jobs: # if in a PR if: ${{ github.event_name == 'pull_request' }} with: - source-dir: _site + source-dir: docs/_site test-emailer-lib: runs-on: ubuntu-latest @@ -81,18 +81,18 @@ jobs: run: uv sync --all-extras --dev - name: Install emailer-lib - run: uv pip install -e "./emailer-lib" + run: uv pip install -e . - name: Install the project deps - run: uv pip install -e "./emailer-lib[dev]" + run: uv pip install -e .[dev] - name: Test emailer-lib run: | - uv run pytest emailer-lib/emailer_lib/tests/ --cov=emailer_lib --cov-report=xml --cov-report=term-missing + uv run pytest emailer_lib/tests/ --cov=emailer_lib --cov-report=xml --cov-report=term-missing - name: Upload coverage reports uses: codecov/codecov-action@v4 if: ${{ matrix.python-version == '3.12' }} with: file: emailer-lib/coverage.xml - flags: emailer-lib \ No newline at end of file + flags: emailer-lib diff --git a/.gitignore b/.gitignore index 5753ab2..709c5de 100644 --- a/.gitignore +++ b/.gitignore @@ -212,4 +212,4 @@ _site .DS_Store -.vscode \ No newline at end of file +.vscode diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c0c4950 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +build-docs: generate-mjml-tags + cd docs && uv run quartodoc build --verbose && quarto render + +preview: + cd docs && quarto preview + +test: + pytest emailer_lib/tests emailer_lib/mjml/tests --cov-report=xml + +test-update: + pytest emailer_lib/tests emailer_lib/mjml/tests --snapshot-update + +generate-mjml-tags: + python3 emailer_lib/mjml/scripts/generate_tags.py diff --git a/README.md b/README.md index 5c8c8e9..b026ae7 100644 --- a/README.md +++ b/README.md @@ -1 +1,84 @@ -# email-for-data-science \ No newline at end of file +# emailer-lib + + + + + + + +[![Documentation](https://img.shields.io/badge/docs-project_website-blue.svg)](https://posit-dev.github.io/email-for-data-science/reference/) + + + + +> ⚠️ **emailer-lib is currently in development, expect breaking changes.** + + +### What is [emailer-lib](https://posit-dev.github.io/email-for-data-science/reference/)? + +**emailer-lib** is a Python package for serializing, previewing, and sending email messages in a consistent, simple structure. It provides utilities to convert emails from different sources (Redmail, Yagmail, MJML, Quarto JSON) into a unified intermediate format, and send them via multiple backends (Gmail, SMTP, Mailgun, etc.). + +The package is designed for data science workflows and Quarto projects, making it easy to generate, preview, and deliver rich email content programmatically. + + + +## Example Usage + +```python +from emailer_lib import ( + quarto_json_to_intermediate_email, + IntermediateEmail, + send_intermediate_email_with_gmail, +) + +# Read a Quarto email JSON file +email_struct = quarto_json_to_intermediate_email("email.json") + +# Preview the email as HTML +email_struct.write_preview_email("preview.html") + +# Send the email via Gmail +send_intermediate_email_with_gmail("your_email@gmail.com", "your_password", email_struct) +``` + +## Features + +- **Unified email structure** for serialization and conversion +- **Convert** emails from Redmail, Yagmail, MJML, and Quarto JSON +- **Send** emails via Gmail, SMTP, Mailgun, and more +- **Preview** emails as HTML files +- **Support for attachments** (inline and external) +- **Simple API** for integration in data science and reporting workflows + +## Contributing +If you encounter a bug, have usage questions, or want to share ideas to make this package better, please feel free to file an [issue](https://github.com/posit-dev/email-for-data-science/issues). + + + + + + + +For more information, see the [docs](https://posit-dev.github.io/email-for-data-science/reference) or [open an issue](https://github.com/posit-dev/email-for-data-science/issues) with questions or suggestions! \ No newline at end of file diff --git a/cover.png b/cover.png deleted file mode 100644 index e1f5bc6..0000000 Binary files a/cover.png and /dev/null differ diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..0709e39 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,2 @@ +/.quarto/ +/reference/ diff --git a/.output_metadata.json b/docs/.output_metadata.json similarity index 100% rename from .output_metadata.json rename to docs/.output_metadata.json diff --git a/_quarto.yml b/docs/_quarto.yml similarity index 71% rename from _quarto.yml rename to docs/_quarto.yml index 28841ce..cfa6598 100644 --- a/_quarto.yml +++ b/docs/_quarto.yml @@ -70,13 +70,52 @@ quartodoc: - send_intermediate_email_with_mailgun - send_quarto_email_with_gmail - - title: Utilities desc: > Previews and more contents: - write_email_message_to_file + - title: MJML Authoring + desc: > + Write responsive emails with MJML + package: emailer_lib + contents: + - mjml.mjml + - mjml.head + - mjml.body + - mjml.mj_attributes + - mjml.mj_all + - mjml.mj_class + - mjml.breakpoint + - mjml.font + - mjml.html_attributes + - mjml.html_attribute + - mjml.preview + - mjml.style + - mjml.title + - mjml.accordion + - mjml.accordion_element + - mjml.accordion_text + - mjml.accordion_title + - mjml.button + - mjml.carousel + - mjml.carousel_image + - mjml.column + - mjml.divider + - mjml.group + - mjml.hero + - mjml.image + - mjml.navbar + - mjml.navbar_link + - mjml.raw + - mjml.section + - mjml.social + - mjml.social_element + - mjml.spacer + - mjml.table + - mjml.text + - mjml.wrapper format: html: diff --git a/assets/mjml-email-full.png b/docs/assets/mjml-email-full.png similarity index 100% rename from assets/mjml-email-full.png rename to docs/assets/mjml-email-full.png diff --git a/assets/whole-game-email-annotated.png b/docs/assets/whole-game-email-annotated.png similarity index 100% rename from assets/whole-game-email-annotated.png rename to docs/assets/whole-game-email-annotated.png diff --git a/assets/whole-game-email.png b/docs/assets/whole-game-email.png similarity index 100% rename from assets/whole-game-email.png rename to docs/assets/whole-game-email.png diff --git a/assets/whole-game-fancy.png b/docs/assets/whole-game-fancy.png similarity index 100% rename from assets/whole-game-fancy.png rename to docs/assets/whole-game-fancy.png diff --git a/assets/whole-game-quarto.png b/docs/assets/whole-game-quarto.png similarity index 100% rename from assets/whole-game-quarto.png rename to docs/assets/whole-game-quarto.png diff --git a/configuring-attachments.qmd b/docs/configuring-attachments.qmd similarity index 100% rename from configuring-attachments.qmd rename to docs/configuring-attachments.qmd diff --git a/configuring-subject.qmd b/docs/configuring-subject.qmd similarity index 100% rename from configuring-subject.qmd rename to docs/configuring-subject.qmd diff --git a/content-embedding.qmd b/docs/content-embedding.qmd similarity index 100% rename from content-embedding.qmd rename to docs/content-embedding.qmd diff --git a/content-layout.qmd b/docs/content-layout.qmd similarity index 100% rename from content-layout.qmd rename to docs/content-layout.qmd diff --git a/data_polars.py b/docs/data_polars.py similarity index 100% rename from data_polars.py rename to docs/data_polars.py diff --git a/index.qmd b/docs/index.qmd similarity index 100% rename from index.qmd rename to docs/index.qmd diff --git a/docs/objects.json b/docs/objects.json new file mode 100644 index 0000000..6165209 --- /dev/null +++ b/docs/objects.json @@ -0,0 +1 @@ +{"project": "emailer_lib", "version": "0.0.9999", "count": 106, "items": [{"name": "emailer_lib.IntermediateEmail.preview_send_email", "domain": "py", "role": "function", "priority": "1", "uri": "reference/emailer_lib.IntermediateEmail.preview_send_email.html#emailer_lib.IntermediateEmail.preview_send_email", "dispname": "-"}, {"name": "emailer_lib.structs.IntermediateEmail.preview_send_email", "domain": "py", "role": "function", "priority": "1", "uri": "reference/emailer_lib.IntermediateEmail.preview_send_email.html#emailer_lib.IntermediateEmail.preview_send_email", "dispname": "emailer_lib.IntermediateEmail.preview_send_email"}, {"name": "emailer_lib.IntermediateEmail.write_email_message", "domain": "py", "role": "function", "priority": "1", "uri": "reference/emailer_lib.IntermediateEmail.write_email_message.html#emailer_lib.IntermediateEmail.write_email_message", "dispname": "-"}, {"name": "emailer_lib.structs.IntermediateEmail.write_email_message", "domain": "py", "role": "function", "priority": "1", "uri": "reference/emailer_lib.IntermediateEmail.write_email_message.html#emailer_lib.IntermediateEmail.write_email_message", "dispname": "emailer_lib.IntermediateEmail.write_email_message"}, {"name": "emailer_lib.IntermediateEmail.write_preview_email", "domain": "py", "role": "function", "priority": "1", "uri": "reference/emailer_lib.IntermediateEmail.write_preview_email.html#emailer_lib.IntermediateEmail.write_preview_email", "dispname": "-"}, {"name": "emailer_lib.structs.IntermediateEmail.write_preview_email", "domain": "py", "role": "function", "priority": "1", "uri": "reference/emailer_lib.IntermediateEmail.write_preview_email.html#emailer_lib.IntermediateEmail.write_preview_email", "dispname": "emailer_lib.IntermediateEmail.write_preview_email"}, {"name": "emailer_lib.IntermediateEmail", "domain": "py", "role": "class", "priority": "1", "uri": "reference/IntermediateEmail.html#emailer_lib.IntermediateEmail", "dispname": "-"}, {"name": "emailer_lib.structs.IntermediateEmail", "domain": "py", "role": "class", "priority": "1", "uri": "reference/IntermediateEmail.html#emailer_lib.IntermediateEmail", "dispname": "emailer_lib.IntermediateEmail"}, {"name": "emailer_lib.IntermediateEmail.write_preview_email", "domain": "py", "role": "function", "priority": "1", "uri": "reference/IntermediateEmail.write_preview_email.html#emailer_lib.IntermediateEmail.write_preview_email", "dispname": "-"}, {"name": "emailer_lib.structs.IntermediateEmail.write_preview_email", "domain": "py", "role": "function", "priority": "1", "uri": "reference/IntermediateEmail.write_preview_email.html#emailer_lib.IntermediateEmail.write_preview_email", "dispname": "emailer_lib.IntermediateEmail.write_preview_email"}, {"name": "emailer_lib.IntermediateEmail.write_email_message", "domain": "py", "role": "function", "priority": "1", "uri": "reference/IntermediateEmail.write_email_message.html#emailer_lib.IntermediateEmail.write_email_message", "dispname": "-"}, {"name": "emailer_lib.structs.IntermediateEmail.write_email_message", "domain": "py", "role": "function", "priority": "1", "uri": "reference/IntermediateEmail.write_email_message.html#emailer_lib.IntermediateEmail.write_email_message", "dispname": "emailer_lib.IntermediateEmail.write_email_message"}, {"name": "emailer_lib.IntermediateEmail.preview_send_email", "domain": "py", "role": "function", "priority": "1", "uri": "reference/IntermediateEmail.preview_send_email.html#emailer_lib.IntermediateEmail.preview_send_email", "dispname": "-"}, {"name": "emailer_lib.structs.IntermediateEmail.preview_send_email", "domain": "py", "role": "function", "priority": "1", "uri": "reference/IntermediateEmail.preview_send_email.html#emailer_lib.IntermediateEmail.preview_send_email", "dispname": "emailer_lib.IntermediateEmail.preview_send_email"}, {"name": "emailer_lib.quarto_json_to_intermediate_email", "domain": "py", "role": "function", "priority": "1", "uri": "reference/quarto_json_to_intermediate_email.html#emailer_lib.quarto_json_to_intermediate_email", "dispname": "-"}, {"name": "emailer_lib.ingress.quarto_json_to_intermediate_email", "domain": "py", "role": "function", "priority": "1", "uri": "reference/quarto_json_to_intermediate_email.html#emailer_lib.quarto_json_to_intermediate_email", "dispname": "emailer_lib.quarto_json_to_intermediate_email"}, {"name": "emailer_lib.mjml_to_intermediate_email", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml_to_intermediate_email.html#emailer_lib.mjml_to_intermediate_email", "dispname": "-"}, {"name": "emailer_lib.ingress.mjml_to_intermediate_email", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml_to_intermediate_email.html#emailer_lib.mjml_to_intermediate_email", "dispname": "emailer_lib.mjml_to_intermediate_email"}, {"name": "emailer_lib.redmail_to_intermediate_email", "domain": "py", "role": "function", "priority": "1", "uri": "reference/redmail_to_intermediate_email.html#emailer_lib.redmail_to_intermediate_email", "dispname": "-"}, {"name": "emailer_lib.ingress.redmail_to_intermediate_email", "domain": "py", "role": "function", "priority": "1", "uri": "reference/redmail_to_intermediate_email.html#emailer_lib.redmail_to_intermediate_email", "dispname": "emailer_lib.redmail_to_intermediate_email"}, {"name": "emailer_lib.yagmail_to_intermediate_email", "domain": "py", "role": "function", "priority": "1", "uri": "reference/yagmail_to_intermediate_email.html#emailer_lib.yagmail_to_intermediate_email", "dispname": "-"}, {"name": "emailer_lib.ingress.yagmail_to_intermediate_email", "domain": "py", "role": "function", "priority": "1", "uri": "reference/yagmail_to_intermediate_email.html#emailer_lib.yagmail_to_intermediate_email", "dispname": "emailer_lib.yagmail_to_intermediate_email"}, {"name": "emailer_lib.send_intermediate_email_with_gmail", "domain": "py", "role": "function", "priority": "1", "uri": "reference/send_intermediate_email_with_gmail.html#emailer_lib.send_intermediate_email_with_gmail", "dispname": "-"}, {"name": "emailer_lib.egress.send_intermediate_email_with_gmail", "domain": "py", "role": "function", "priority": "1", "uri": "reference/send_intermediate_email_with_gmail.html#emailer_lib.send_intermediate_email_with_gmail", "dispname": "emailer_lib.send_intermediate_email_with_gmail"}, {"name": "emailer_lib.send_intermediate_email_with_smtp", "domain": "py", "role": "function", "priority": "1", "uri": "reference/send_intermediate_email_with_smtp.html#emailer_lib.send_intermediate_email_with_smtp", "dispname": "-"}, {"name": "emailer_lib.egress.send_intermediate_email_with_smtp", "domain": "py", "role": "function", "priority": "1", "uri": "reference/send_intermediate_email_with_smtp.html#emailer_lib.send_intermediate_email_with_smtp", "dispname": "emailer_lib.send_intermediate_email_with_smtp"}, {"name": "emailer_lib.send_intermediate_email_with_redmail", "domain": "py", "role": "function", "priority": "1", "uri": "reference/send_intermediate_email_with_redmail.html#emailer_lib.send_intermediate_email_with_redmail", "dispname": "-"}, {"name": "emailer_lib.egress.send_intermediate_email_with_redmail", "domain": "py", "role": "function", "priority": "1", "uri": "reference/send_intermediate_email_with_redmail.html#emailer_lib.send_intermediate_email_with_redmail", "dispname": "emailer_lib.send_intermediate_email_with_redmail"}, {"name": "emailer_lib.send_intermediate_email_with_yagmail", "domain": "py", "role": "function", "priority": "1", "uri": "reference/send_intermediate_email_with_yagmail.html#emailer_lib.send_intermediate_email_with_yagmail", "dispname": "-"}, {"name": "emailer_lib.egress.send_intermediate_email_with_yagmail", "domain": "py", "role": "function", "priority": "1", "uri": "reference/send_intermediate_email_with_yagmail.html#emailer_lib.send_intermediate_email_with_yagmail", "dispname": "emailer_lib.send_intermediate_email_with_yagmail"}, {"name": "emailer_lib.send_intermediate_email_with_mailgun", "domain": "py", "role": "function", "priority": "1", "uri": "reference/send_intermediate_email_with_mailgun.html#emailer_lib.send_intermediate_email_with_mailgun", "dispname": "-"}, {"name": "emailer_lib.egress.send_intermediate_email_with_mailgun", "domain": "py", "role": "function", "priority": "1", "uri": "reference/send_intermediate_email_with_mailgun.html#emailer_lib.send_intermediate_email_with_mailgun", "dispname": "emailer_lib.send_intermediate_email_with_mailgun"}, {"name": "emailer_lib.send_quarto_email_with_gmail", "domain": "py", "role": "function", "priority": "1", "uri": "reference/send_quarto_email_with_gmail.html#emailer_lib.send_quarto_email_with_gmail", "dispname": "-"}, {"name": "emailer_lib.egress.send_quarto_email_with_gmail", "domain": "py", "role": "function", "priority": "1", "uri": "reference/send_quarto_email_with_gmail.html#emailer_lib.send_quarto_email_with_gmail", "dispname": "emailer_lib.send_quarto_email_with_gmail"}, {"name": "emailer_lib.write_email_message_to_file", "domain": "py", "role": "function", "priority": "1", "uri": "reference/write_email_message_to_file.html#emailer_lib.write_email_message_to_file", "dispname": "-"}, {"name": "emailer_lib.utils.write_email_message_to_file", "domain": "py", "role": "function", "priority": "1", "uri": "reference/write_email_message_to_file.html#emailer_lib.write_email_message_to_file", "dispname": "emailer_lib.write_email_message_to_file"}, {"name": "emailer_lib.mjml.mjml", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.mjml.html#emailer_lib.mjml.mjml", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.mjml", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.mjml.html#emailer_lib.mjml.mjml", "dispname": "emailer_lib.mjml.mjml"}, {"name": "emailer_lib.mjml.head", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.head.html#emailer_lib.mjml.head", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.head", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.head.html#emailer_lib.mjml.head", "dispname": "emailer_lib.mjml.head"}, {"name": "emailer_lib.mjml.body", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.body.html#emailer_lib.mjml.body", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.body", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.body.html#emailer_lib.mjml.body", "dispname": "emailer_lib.mjml.body"}, {"name": "emailer_lib.mjml.mj_attributes", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.mj_attributes.html#emailer_lib.mjml.mj_attributes", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.mj_attributes", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.mj_attributes.html#emailer_lib.mjml.mj_attributes", "dispname": "emailer_lib.mjml.mj_attributes"}, {"name": "emailer_lib.mjml.mj_all", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.mj_all.html#emailer_lib.mjml.mj_all", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.mj_all", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.mj_all.html#emailer_lib.mjml.mj_all", "dispname": "emailer_lib.mjml.mj_all"}, {"name": "emailer_lib.mjml.mj_class", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.mj_class.html#emailer_lib.mjml.mj_class", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.mj_class", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.mj_class.html#emailer_lib.mjml.mj_class", "dispname": "emailer_lib.mjml.mj_class"}, {"name": "emailer_lib.mjml.breakpoint", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.breakpoint.html#emailer_lib.mjml.breakpoint", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.breakpoint", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.breakpoint.html#emailer_lib.mjml.breakpoint", "dispname": "emailer_lib.mjml.breakpoint"}, {"name": "emailer_lib.mjml.font", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.font.html#emailer_lib.mjml.font", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.font", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.font.html#emailer_lib.mjml.font", "dispname": "emailer_lib.mjml.font"}, {"name": "emailer_lib.mjml.html_attributes", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.html_attributes.html#emailer_lib.mjml.html_attributes", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.html_attributes", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.html_attributes.html#emailer_lib.mjml.html_attributes", "dispname": "emailer_lib.mjml.html_attributes"}, {"name": "emailer_lib.mjml.html_attribute", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.html_attribute.html#emailer_lib.mjml.html_attribute", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.html_attribute", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.html_attribute.html#emailer_lib.mjml.html_attribute", "dispname": "emailer_lib.mjml.html_attribute"}, {"name": "emailer_lib.mjml.preview", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.preview.html#emailer_lib.mjml.preview", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.preview", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.preview.html#emailer_lib.mjml.preview", "dispname": "emailer_lib.mjml.preview"}, {"name": "emailer_lib.mjml.style", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.style.html#emailer_lib.mjml.style", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.style", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.style.html#emailer_lib.mjml.style", "dispname": "emailer_lib.mjml.style"}, {"name": "emailer_lib.mjml.title", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.title.html#emailer_lib.mjml.title", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.title", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.title.html#emailer_lib.mjml.title", "dispname": "emailer_lib.mjml.title"}, {"name": "emailer_lib.mjml.accordion", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.accordion.html#emailer_lib.mjml.accordion", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.accordion", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.accordion.html#emailer_lib.mjml.accordion", "dispname": "emailer_lib.mjml.accordion"}, {"name": "emailer_lib.mjml.accordion_element", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.accordion_element.html#emailer_lib.mjml.accordion_element", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.accordion_element", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.accordion_element.html#emailer_lib.mjml.accordion_element", "dispname": "emailer_lib.mjml.accordion_element"}, {"name": "emailer_lib.mjml.accordion_text", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.accordion_text.html#emailer_lib.mjml.accordion_text", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.accordion_text", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.accordion_text.html#emailer_lib.mjml.accordion_text", "dispname": "emailer_lib.mjml.accordion_text"}, {"name": "emailer_lib.mjml.accordion_title", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.accordion_title.html#emailer_lib.mjml.accordion_title", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.accordion_title", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.accordion_title.html#emailer_lib.mjml.accordion_title", "dispname": "emailer_lib.mjml.accordion_title"}, {"name": "emailer_lib.mjml.button", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.button.html#emailer_lib.mjml.button", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.button", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.button.html#emailer_lib.mjml.button", "dispname": "emailer_lib.mjml.button"}, {"name": "emailer_lib.mjml.carousel", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.carousel.html#emailer_lib.mjml.carousel", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.carousel", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.carousel.html#emailer_lib.mjml.carousel", "dispname": "emailer_lib.mjml.carousel"}, {"name": "emailer_lib.mjml.carousel_image", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.carousel_image.html#emailer_lib.mjml.carousel_image", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.carousel_image", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.carousel_image.html#emailer_lib.mjml.carousel_image", "dispname": "emailer_lib.mjml.carousel_image"}, {"name": "emailer_lib.mjml.column", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.column.html#emailer_lib.mjml.column", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.column", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.column.html#emailer_lib.mjml.column", "dispname": "emailer_lib.mjml.column"}, {"name": "emailer_lib.mjml.divider", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.divider.html#emailer_lib.mjml.divider", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.divider", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.divider.html#emailer_lib.mjml.divider", "dispname": "emailer_lib.mjml.divider"}, {"name": "emailer_lib.mjml.group", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.group.html#emailer_lib.mjml.group", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.group", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.group.html#emailer_lib.mjml.group", "dispname": "emailer_lib.mjml.group"}, {"name": "emailer_lib.mjml.hero", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.hero.html#emailer_lib.mjml.hero", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.hero", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.hero.html#emailer_lib.mjml.hero", "dispname": "emailer_lib.mjml.hero"}, {"name": "emailer_lib.mjml.image", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.image.html#emailer_lib.mjml.image", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.image", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.image.html#emailer_lib.mjml.image", "dispname": "emailer_lib.mjml.image"}, {"name": "emailer_lib.mjml.navbar", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.navbar.html#emailer_lib.mjml.navbar", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.navbar", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.navbar.html#emailer_lib.mjml.navbar", "dispname": "emailer_lib.mjml.navbar"}, {"name": "emailer_lib.mjml.navbar_link", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.navbar_link.html#emailer_lib.mjml.navbar_link", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.navbar_link", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.navbar_link.html#emailer_lib.mjml.navbar_link", "dispname": "emailer_lib.mjml.navbar_link"}, {"name": "emailer_lib.mjml.raw", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.raw.html#emailer_lib.mjml.raw", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.raw", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.raw.html#emailer_lib.mjml.raw", "dispname": "emailer_lib.mjml.raw"}, {"name": "emailer_lib.mjml.section", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.section.html#emailer_lib.mjml.section", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.section", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.section.html#emailer_lib.mjml.section", "dispname": "emailer_lib.mjml.section"}, {"name": "emailer_lib.mjml.social", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.social.html#emailer_lib.mjml.social", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.social", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.social.html#emailer_lib.mjml.social", "dispname": "emailer_lib.mjml.social"}, {"name": "emailer_lib.mjml.social_element", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.social_element.html#emailer_lib.mjml.social_element", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.social_element", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.social_element.html#emailer_lib.mjml.social_element", "dispname": "emailer_lib.mjml.social_element"}, {"name": "emailer_lib.mjml.spacer", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.spacer.html#emailer_lib.mjml.spacer", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.spacer", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.spacer.html#emailer_lib.mjml.spacer", "dispname": "emailer_lib.mjml.spacer"}, {"name": "emailer_lib.mjml.table", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.table.html#emailer_lib.mjml.table", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.table", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.table.html#emailer_lib.mjml.table", "dispname": "emailer_lib.mjml.table"}, {"name": "emailer_lib.mjml.text", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.text.html#emailer_lib.mjml.text", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.text", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.text.html#emailer_lib.mjml.text", "dispname": "emailer_lib.mjml.text"}, {"name": "emailer_lib.mjml.wrapper", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.wrapper.html#emailer_lib.mjml.wrapper", "dispname": "-"}, {"name": "emailer_lib.mjml.tags.wrapper", "domain": "py", "role": "function", "priority": "1", "uri": "reference/mjml.wrapper.html#emailer_lib.mjml.wrapper", "dispname": "emailer_lib.mjml.wrapper"}]} \ No newline at end of file diff --git a/orchestrating-auth.qmd b/docs/orchestrating-auth.qmd similarity index 100% rename from orchestrating-auth.qmd rename to docs/orchestrating-auth.qmd diff --git a/orchestrating-tests.qmd b/docs/orchestrating-tests.qmd similarity index 100% rename from orchestrating-tests.qmd rename to docs/orchestrating-tests.qmd diff --git a/summary.qmd b/docs/summary.qmd similarity index 100% rename from summary.qmd rename to docs/summary.qmd diff --git a/emailer-lib/.gitignore b/emailer-lib/.gitignore deleted file mode 100644 index 68297d9..0000000 --- a/emailer-lib/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# generated emails -*.html \ No newline at end of file diff --git a/emailer-lib/Makefile b/emailer-lib/Makefile deleted file mode 100644 index 649ce8a..0000000 --- a/emailer-lib/Makefile +++ /dev/null @@ -1,5 +0,0 @@ -test: - pytest --cov-report=xml - -test-update: - pytest --snapshot-update \ No newline at end of file diff --git a/emailer-lib/README.md b/emailer-lib/README.md deleted file mode 100644 index b026ae7..0000000 --- a/emailer-lib/README.md +++ /dev/null @@ -1,84 +0,0 @@ -# emailer-lib - - - - - - - -[![Documentation](https://img.shields.io/badge/docs-project_website-blue.svg)](https://posit-dev.github.io/email-for-data-science/reference/) - - - - -> ⚠️ **emailer-lib is currently in development, expect breaking changes.** - - -### What is [emailer-lib](https://posit-dev.github.io/email-for-data-science/reference/)? - -**emailer-lib** is a Python package for serializing, previewing, and sending email messages in a consistent, simple structure. It provides utilities to convert emails from different sources (Redmail, Yagmail, MJML, Quarto JSON) into a unified intermediate format, and send them via multiple backends (Gmail, SMTP, Mailgun, etc.). - -The package is designed for data science workflows and Quarto projects, making it easy to generate, preview, and deliver rich email content programmatically. - - - -## Example Usage - -```python -from emailer_lib import ( - quarto_json_to_intermediate_email, - IntermediateEmail, - send_intermediate_email_with_gmail, -) - -# Read a Quarto email JSON file -email_struct = quarto_json_to_intermediate_email("email.json") - -# Preview the email as HTML -email_struct.write_preview_email("preview.html") - -# Send the email via Gmail -send_intermediate_email_with_gmail("your_email@gmail.com", "your_password", email_struct) -``` - -## Features - -- **Unified email structure** for serialization and conversion -- **Convert** emails from Redmail, Yagmail, MJML, and Quarto JSON -- **Send** emails via Gmail, SMTP, Mailgun, and more -- **Preview** emails as HTML files -- **Support for attachments** (inline and external) -- **Simple API** for integration in data science and reporting workflows - -## Contributing -If you encounter a bug, have usage questions, or want to share ideas to make this package better, please feel free to file an [issue](https://github.com/posit-dev/email-for-data-science/issues). - - - - - - - -For more information, see the [docs](https://posit-dev.github.io/email-for-data-science/reference) or [open an issue](https://github.com/posit-dev/email-for-data-science/issues) with questions or suggestions! \ No newline at end of file diff --git a/emailer-lib/pyproject.toml b/emailer-lib/pyproject.toml deleted file mode 100644 index d7e4092..0000000 --- a/emailer-lib/pyproject.toml +++ /dev/null @@ -1,40 +0,0 @@ -[build-system] -requires = ["setuptools>=45", "wheel", "setuptools_scm>=6.2"] -build-backend = "setuptools.build_meta" - -[tool.setuptools_scm] - -[tool.pytest.ini_options] -minversion = "6.0" -addopts = "-ra --cov=emailer_lib --cov-report=term-missing" -testpaths = ["emailer_lib/tests"] - -[project] -name = "emailer-lib" -version = "0.0.1" -description = "Email serialization and sending utilities" -authors = [{name = "Jules Walzer-Goldfeld"}] -readme = "README.md" - -requires-python = ">=3.9" - -dependencies = [ - "dotenv", - "mjml-python>=1.3.6", -] - -[project.optional-dependencies] -dev = [ - "aiosmtpd", - "pytest", - "pytest-cov", -] - -[tool.coverage.report] -exclude_also = [ - "if TYPE_CHECKING:" -] -include = ["emailer_lib/*"] -omit = [ - "emailer_lib/tests/*" -] \ No newline at end of file diff --git a/emailer-lib/emailer_lib/__init__.py b/emailer_lib/__init__.py similarity index 100% rename from emailer-lib/emailer_lib/__init__.py rename to emailer_lib/__init__.py diff --git a/emailer-lib/emailer_lib/egress.py b/emailer_lib/egress.py similarity index 100% rename from emailer-lib/emailer_lib/egress.py rename to emailer_lib/egress.py diff --git a/emailer-lib/emailer_lib/ingress.py b/emailer_lib/ingress.py similarity index 100% rename from emailer-lib/emailer_lib/ingress.py rename to emailer_lib/ingress.py diff --git a/emailer_lib/mjml/.gitignore b/emailer_lib/mjml/.gitignore new file mode 100644 index 0000000..9ce3ea5 --- /dev/null +++ b/emailer_lib/mjml/.gitignore @@ -0,0 +1,3 @@ +playground.qmd +*.html +sample_mjml.mjml diff --git a/emailer_lib/mjml/README.md b/emailer_lib/mjml/README.md new file mode 100644 index 0000000..16efad3 --- /dev/null +++ b/emailer_lib/mjml/README.md @@ -0,0 +1,198 @@ +# MJML Module + +A Python implementation of MJML tags for building responsive email templates programmatically. + +## Overview + +This module provides Python functions for creating MJML markup, the responsive email framework. Instead of writing raw MJML XML, you can use Python functions to build your email templates. + +## Features + +- **Automatic tag generation**: Tags are auto-generated from the official MJML specification +- **Leaf tag safety**: Ending tags (like `mj-text`, `mj-button`) only accept content, not MJML children +- **Flexible rendering**: Convert your Python MJML structures to valid MJML markup via [mjml2html](https://github.com/mgd020/mjml-python) + +## Installation + +This module is part of the `emailer_lib` package: + +```python +from emailer_lib import mjml as mj +``` + +## Quick Start + +```python +from emailer_lib.mjml import mjml, body, section, column, text + +# Build an MJML email structure +email = mjml( + body( + section( + column( + text(content="Hello, World!", color="#ff6600") + ) + ) + ) +) + +# Render to MJML markup +mjml_string = email.render() +``` + +## Tag Types + +### Container Tags + +These tags accept children (other MJML components) and optional content: + +- Structure: `mjml`, `head`, `body`, `include` +- Layout: `section`, `column`, `group`, `wrapper` +- Components: `accordion`, `carousel`, `hero`, `navbar`, `social` +- Configuration: `attributes`, `breakpoint`, `font`, `html_attributes`, `style`, `title` + +Example: +```python +section( + column( + text(content="First column") + ), + column( + text(content="Second column") + ), + background_color="#f0f0f0" +) +``` + +### Leaf/Ending Tags + +These tags accept text or HTML content but **not** MJML children: + +- `button` - Call-to-action buttons +- `text` - Text content with HTML support +- `table` - HTML tables +- `raw` - Raw HTML content +- `accordion_text`, `accordion_title` - Accordion content +- `navbar_link` - Navigation links +- `social_element` - Social media icons +- `carousel_image` - Carousel images + +Example: +```python +text( + content="Bold text and a link", + font_size="16px", + color="#333333" +) + +button( + content="Click Here", + href="https://example.com", + background_color="#007bff" +) +``` + +## Core Classes + +### `MJMLTag` + +The base class for all MJML elements. Can be instantiated directly or via helper functions. + +```python +from emailer_lib.mjml import MJMLTag + +tag = MJMLTag( + "mj-text", + content="Hello", + color="#ff6600" +) +``` + +### `TagAttrDict` + +A dictionary type for tag attributes. + +## Examples + +### Simple Email + +```python +from emailer_lib.mjml import mjml, head, body, section, column, text, title + +email = mjml( + head( + title(content="Welcome Email") + ), + body( + section( + column( + text(content="Welcome to our service!") + ) + ) + ) +) +``` + +### Multi-column Layout + +```python +from emailer_lib.mjml import body, section, column, text, image + +layout = body( + section( + column( + image(src="https://example.com/logo.png"), + text(content="Column 1") + ), + column( + text(content="Column 2") + ), + column( + text(content="Column 3") + ) + ) +) +``` + +### Using Attributes + +```python +from emailer_lib.mjml import section, column, text + +# Attributes as kwargs +section( + column( + text(content="Styled text", color="#ff0000", font_size="20px") + ), + background_color="#f5f5f5", + padding="20px" +) +``` + +## Rendering + +Use the `.render()` method to convert MJML structures to markup: + +```python +mjml_markup = email.render() +# Pass to MJML API or tool for HTML conversion +``` + +## API Reference + +For detailed documentation of all tags and their attributes, see the [API Reference](https://posit-dev.github.io/email-for-data-science/reference/). + +## Resources + +- [Official MJML Documentation](https://documentation.mjml.io/) +- [MJML GitHub Repository](https://github.com/mjmlio/mjml) + +## Development + +The tag functions in `tags.py` are auto-generated by `scripts/generate-tags.py`. To regenerate: + +```bash +make generate-mjml-tags +``` + +**Do not edit `tags.py` directly** - your changes will be overwritten. diff --git a/emailer_lib/mjml/__init__.py b/emailer_lib/mjml/__init__.py new file mode 100644 index 0000000..959139c --- /dev/null +++ b/emailer_lib/mjml/__init__.py @@ -0,0 +1,81 @@ +# MJMLTools package init +# Exposes MJMLTag, TagAttrDict, and all tag functions + +from ._core import MJMLTag, TagAttrDict +from .tags import ( + mjml, + head, + body, + mj_attributes, + mj_all, + mj_class, + breakpoint, + font, + html_attributes, + html_attribute, + preview, + style, + title, + accordion, + accordion_element, + button, + carousel, + carousel_image, + column, + divider, + group, + hero, + image, + navbar, + raw, + section, + social, + spacer, + table, + text, + wrapper, + accordion_text, + accordion_title, + navbar_link, + social_element, +) + +__all__ = ( + "MJMLTag", + "TagAttrDict", + "mjml", + "head", + "body", + "mj_attributes", + "mj_all", + "mj_class", + "breakpoint", + "font", + "html_attributes", + "html_attribute", + "preview", + "style", + "title", + "accordion", + "accordion_element", + "button", + "carousel", + "carousel_image", + "column", + "divider", + "group", + "hero", + "image", + "navbar", + "raw", + "section", + "social", + "spacer", + "table", + "text", + "wrapper", + "accordion_text", + "accordion_title", + "navbar_link", + "social_element", +) diff --git a/emailer_lib/mjml/_core.py b/emailer_lib/mjml/_core.py new file mode 100644 index 0000000..d2068f2 --- /dev/null +++ b/emailer_lib/mjml/_core.py @@ -0,0 +1,204 @@ +# MJML core classes adapted from py-htmltools + +## TODO: make sure Ending tags are rendered as needed +# https://documentation.mjml.io/#ending-tags + +from typing import Dict, Mapping, Optional, Sequence, Union +import warnings +from mjml import mjml2html + + +# Types for MJML +TagAttrValue = Union[str, float, bool, None] +TagAttrs = Union[Dict[str, TagAttrValue], "TagAttrDict"] +TagChild = Union["MJMLTag", str, float, None, Sequence["TagChild"]] + + +class TagAttrDict(Dict[str, str]): + """ + MJML attribute dictionary. All values are stored as strings. + """ + + def __init__( + self, *args: Mapping[str, TagAttrValue] + ) -> None: + super().__init__() + for mapping in args: + for k, v in mapping.items(): + if v is not None: + self[k] = str(v) + + def update(self, *args: Mapping[str, TagAttrValue]) -> None: + for mapping in args: + for k, v in mapping.items(): + if v is not None: + self[k] = str(v) + + +class MJMLTag: + """ + MJML tag class. + """ + + def __init__( + self, + tagName: str, + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, + _is_leaf: bool = False, + ) -> None: + self.tagName = tagName + self.attrs = TagAttrDict() + self.children = [] + self._is_leaf = _is_leaf + + # Runtime validation for leaf tags + if self._is_leaf: + # For leaf tags, treat the first positional argument as content if provided + if args: + if len(args) > 1: + raise TypeError( + f"<{tagName}> is a leaf tag and accepts only one positional argument for content." + ) + self.content = args[0] + else: + self.content = content + + # Validate content type + if self.content is not None and not isinstance(self.content, (str, int, float)): + raise TypeError( + f"<{tagName}> content must be a string, int, or float, " + f"got {type(self.content).__name__}" + ) + + # Validate attributes parameter type + if attributes is not None and not isinstance(attributes, (dict, TagAttrDict)): + raise TypeError( + f"attributes must be a dict or TagAttrDict, got {type(attributes).__name__}." + ) + + # Process attributes + if attributes is not None: + self.attrs.update(attributes) + else: + # For container tags + self.content = content + + # Validate attributes parameter type + if attributes is not None and not isinstance(attributes, (dict, TagAttrDict)): + raise TypeError( + f"attributes must be a dict or TagAttrDict, got {type(attributes).__name__}. " + f"If you meant to pass children, use positional arguments for container tags." + ) + + # Collect children (for non-leaf tags only) + for arg in args: + if ( + isinstance(arg, (str, float)) or arg is None or isinstance(arg, MJMLTag) + ): + self.children.append(arg) + elif isinstance(arg, Sequence) and not isinstance(arg, str): + self.children.extend(arg) + + # Process attributes + if attributes is not None: + self.attrs.update(attributes) + + # TODO: confirm if this is the case... I don't think it is + # # If content is provided, children should be empty + # if self.content is not None: + # self.children = [] + + def render_mjml(self, indent: int = 0, eol: str = "\n") -> str: + """ + Render MJMLTag and its children to MJML markup. + Ported from htmltools Tag rendering logic. + """ + + def _flatten(children): + for c in children: + if c is None: + continue + elif isinstance(c, MJMLTag): + yield c + elif isinstance(c, (str, float)): + yield c + + # Build attribute string + attr_str = "" + if self.attrs: + attr_str = " " + " ".join(f'{k}="{v}"' for k, v in self.attrs.items()) + + # Render children/content + inner = "" + if self.content is not None: + inner = str(self.content) + else: + child_strs = [] + for child in _flatten(self.children): + if isinstance(child, MJMLTag): + child_strs.append(child.render_mjml(indent + 2, eol)) + else: + child_strs.append(str(child)) + if child_strs: + inner = eol.join(child_strs) + + # Indentation + pad = " " * indent + if inner: + return f"{pad}<{self.tagName}{attr_str}>{eol}{inner}{eol}{pad}" + else: + return f"{pad}<{self.tagName}{attr_str}>" + + def _repr_html_(self): + return self.to_html() + + def __repr__(self) -> str: + return self.render_mjml() + + def to_html(self, **mjml2html_kwargs): + """ + Render MJMLTag to HTML using mjml2html. + + If this is not a top-level tag, it will be automatically wrapped + in ... with a warning. + + Parameters + ---------- + **mjml2html_kwargs + Additional keyword arguments to pass to mjml2html + + Returns + ------- + str + Result from mjml2html containing html content + """ + if self.tagName == "mjml": + # Already a complete MJML document + mjml_markup = self.render_mjml() + elif self.tagName == "mj-body": + # Wrap only in mjml tag + warnings.warn( + "to_html() called on tag. " + "Automatically wrapping in .... " + "For full control, create a complete MJML document with the mjml() tag.", + UserWarning, + stacklevel=2 + ) + wrapped = MJMLTag("mjml", self) + mjml_markup = wrapped.render_mjml() + else: + # Warn and wrap in mjml/mj-body + warnings.warn( + f"to_html() called on <{self.tagName}> tag. " + "Automatically wrapping in .... " + "For full control, create a complete MJML document with the mjml() tag.", + UserWarning, + stacklevel=2 + ) + # Wrap in mjml and mj-body + wrapped = MJMLTag("mjml", MJMLTag("mj-body", self)) + mjml_markup = wrapped.render_mjml() + + return mjml2html(mjml_markup, **mjml2html_kwargs) diff --git a/emailer_lib/mjml/scripts/generate_tags.py b/emailer_lib/mjml/scripts/generate_tags.py new file mode 100644 index 0000000..d84b7f0 --- /dev/null +++ b/emailer_lib/mjml/scripts/generate_tags.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 + +""" +Script to auto-generate tags.py with MJML tag functions. +Run this script to regenerate tags.py from the list of MJML tags. +""" + +from pathlib import Path + +# List of all MJML tags (from official docs) +MJML_TAGS = [ + # Root + "mjml", + "mj-head", + "mj-body", + # Head components + "mj-attributes", + "mj-all", # sub-attribute for mj-attributes + "mj-class", # sub-attribute for mj-attributes + "mj-breakpoint", + "mj-font", + "mj-html-attributes", + "mj-html-attribute", # sub-attribute for mj-html-attributes + "mj-preview", + "mj-style", + # Body components + "mj-accordion", + "mj-accordion-element", + "mj-carousel", + "mj-column", + "mj-divider", + "mj-group", + "mj-hero", + "mj-image", + "mj-navbar", + "mj-section", + "mj-social", + "mj-spacer", + "mj-wrapper", +] + +# Leaf/ending tags - accept content (text/HTML) but not MJML children +LEAF_TAGS = [ + "mj-accordion-text", + "mj-accordion-title", + "mj-button", + "mj-carousel-image", + "mj-navbar-link", + "mj-raw", + "mj-social-element", + "mj-table", + "mj-text", + "mj-title" +] + +# Tags that should keep the mj- prefix in the function name +KEEP_MJ_PREFIX = ["mj-attributes", "mj-all", "mj-class"] + + +def get_python_name(tag_name: str) -> str: + """Convert MJML tag name to Python function name.""" + if tag_name in KEEP_MJ_PREFIX: + # Keep mj- prefix, just replace hyphens + return tag_name.replace("-", "_") + else: + # Remove mj- prefix and replace hyphens + return tag_name.replace("-", "_").replace("mj_", "", 1) + + +def generate_tags_file(): + """Generate tags.py with all MJML tag functions.""" + + header = '''# AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY +# This file is auto-generated by scripts/generate-tags.py +# To regenerate, run: python scripts/generate-tags.py + +"""MJML tag functions for all official MJML tags.""" + +from typing import Optional, Union + +from ._core import MJMLTag, TagAttrs, TagAttrValue, TagChild + +''' + + functions = [] + + # Generate regular MJML tags (accept children and optional content) + for tag_name in MJML_TAGS: + py_name = get_python_name(tag_name) + + function_code = f''' +def {py_name}( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `<{tag_name}>` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `<{tag_name}>` + + Examples + -------- + With children: + ```{{python}} + from emailer_lib.mjml import {py_name}, text + child = text("Hello World") + + result = {py_name}(child) + ``` + + With attributes: + ```{{python}} + result = {py_name}(attributes={{"attr": "value"}}) + ``` + + With both: + ```{{python}} + result = {py_name}(child, attributes={{"background-color": "yellow"}}) + ``` + """ + return MJMLTag("{tag_name}", *args, attributes=attributes, content=content) +''' + functions.append(function_code) + + # Generate leaf tags (accept content but not MJML children) + for tag_name in LEAF_TAGS: + py_name = get_python_name(tag_name) + + function_code = f''' +def {py_name}( + content: Optional[str] = None, + attributes: Optional[TagAttrs] = None, +): + """ + Create an MJML `<{tag_name}>` tag. + + This is an ending tag that accepts text/HTML content but not MJML children. + + Parameters + ---------- + content + Text or HTML content for the tag + attributes + Optional dict of tag attributes + + Returns + ------- + MJMLTag + MJMLTag object representing `<{tag_name}>` + + Examples + -------- + With content: + ```{{python}} + from emailer_lib.mjml import {py_name} + + result = {py_name}("Hello") + ``` + + With attributes and content: + ```{{python}} + result = {py_name}("Hello", attributes={{"background-color": "red"}}) + ``` + """ + return MJMLTag("{tag_name}", content, attributes=attributes, _is_leaf=True) +''' + functions.append(function_code) + + # Combine all parts + output = header + "\n".join(functions) + + # Write to file - navigate from scripts/ to emailer_lib/mjml/tags.py + script_dir = Path(__file__).parent + tags_file = script_dir.parent / "tags.py" + + with open(tags_file, "w") as f: + f.write(output) + + print(f"Generated {tags_file} with {len(MJML_TAGS)} container tags and {len(LEAF_TAGS)} leaf tags") + + +if __name__ == "__main__": + generate_tags_file() diff --git a/emailer_lib/mjml/tags.py b/emailer_lib/mjml/tags.py new file mode 100644 index 0000000..1cdbf1c --- /dev/null +++ b/emailer_lib/mjml/tags.py @@ -0,0 +1,1514 @@ +# AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY +# This file is auto-generated by scripts/generate-tags.py +# To regenerate, run: python scripts/generate-tags.py + +"""MJML tag functions for all official MJML tags.""" + +from typing import Optional, Union + +from ._core import MJMLTag, TagAttrs, TagAttrValue, TagChild + + +def mjml( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With children: + ```{python} + from emailer_lib.mjml import mjml, text + child = text("Hello World") + + result = mjml(child) + ``` + + With attributes: + ```{python} + result = mjml(attributes={"attr": "value"}) + ``` + + With both: + ```{python} + result = mjml(child, attributes={"background-color": "yellow"}) + ``` + """ + return MJMLTag("mjml", *args, attributes=attributes, content=content) + + +def head( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With children: + ```{python} + from emailer_lib.mjml import head, text + child = text("Hello World") + + result = head(child) + ``` + + With attributes: + ```{python} + result = head(attributes={"attr": "value"}) + ``` + + With both: + ```{python} + result = head(child, attributes={"background-color": "yellow"}) + ``` + """ + return MJMLTag("mj-head", *args, attributes=attributes, content=content) + + +def body( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With children: + ```{python} + from emailer_lib.mjml import body, text + child = text("Hello World") + + result = body(child) + ``` + + With attributes: + ```{python} + result = body(attributes={"attr": "value"}) + ``` + + With both: + ```{python} + result = body(child, attributes={"background-color": "yellow"}) + ``` + """ + return MJMLTag("mj-body", *args, attributes=attributes, content=content) + + +def mj_attributes( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With children: + ```{python} + from emailer_lib.mjml import mj_attributes, text + child = text("Hello World") + + result = mj_attributes(child) + ``` + + With attributes: + ```{python} + result = mj_attributes(attributes={"attr": "value"}) + ``` + + With both: + ```{python} + result = mj_attributes(child, attributes={"background-color": "yellow"}) + ``` + """ + return MJMLTag("mj-attributes", *args, attributes=attributes, content=content) + + +def mj_all( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With children: + ```{python} + from emailer_lib.mjml import mj_all, text + child = text("Hello World") + + result = mj_all(child) + ``` + + With attributes: + ```{python} + result = mj_all(attributes={"attr": "value"}) + ``` + + With both: + ```{python} + result = mj_all(child, attributes={"background-color": "yellow"}) + ``` + """ + return MJMLTag("mj-all", *args, attributes=attributes, content=content) + + +def mj_class( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With children: + ```{python} + from emailer_lib.mjml import mj_class, text + child = text("Hello World") + + result = mj_class(child) + ``` + + With attributes: + ```{python} + result = mj_class(attributes={"attr": "value"}) + ``` + + With both: + ```{python} + result = mj_class(child, attributes={"background-color": "yellow"}) + ``` + """ + return MJMLTag("mj-class", *args, attributes=attributes, content=content) + + +def breakpoint( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With children: + ```{python} + from emailer_lib.mjml import breakpoint, text + child = text("Hello World") + + result = breakpoint(child) + ``` + + With attributes: + ```{python} + result = breakpoint(attributes={"attr": "value"}) + ``` + + With both: + ```{python} + result = breakpoint(child, attributes={"background-color": "yellow"}) + ``` + """ + return MJMLTag("mj-breakpoint", *args, attributes=attributes, content=content) + + +def font( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With children: + ```{python} + from emailer_lib.mjml import font, text + child = text("Hello World") + + result = font(child) + ``` + + With attributes: + ```{python} + result = font(attributes={"attr": "value"}) + ``` + + With both: + ```{python} + result = font(child, attributes={"background-color": "yellow"}) + ``` + """ + return MJMLTag("mj-font", *args, attributes=attributes, content=content) + + +def html_attributes( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With children: + ```{python} + from emailer_lib.mjml import html_attributes, text + child = text("Hello World") + + result = html_attributes(child) + ``` + + With attributes: + ```{python} + result = html_attributes(attributes={"attr": "value"}) + ``` + + With both: + ```{python} + result = html_attributes(child, attributes={"background-color": "yellow"}) + ``` + """ + return MJMLTag("mj-html-attributes", *args, attributes=attributes, content=content) + + +def html_attribute( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With children: + ```{python} + from emailer_lib.mjml import html_attribute, text + child = text("Hello World") + + result = html_attribute(child) + ``` + + With attributes: + ```{python} + result = html_attribute(attributes={"attr": "value"}) + ``` + + With both: + ```{python} + result = html_attribute(child, attributes={"background-color": "yellow"}) + ``` + """ + return MJMLTag("mj-html-attribute", *args, attributes=attributes, content=content) + + +def preview( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With children: + ```{python} + from emailer_lib.mjml import preview, text + child = text("Hello World") + + result = preview(child) + ``` + + With attributes: + ```{python} + result = preview(attributes={"attr": "value"}) + ``` + + With both: + ```{python} + result = preview(child, attributes={"background-color": "yellow"}) + ``` + """ + return MJMLTag("mj-preview", *args, attributes=attributes, content=content) + + +def style( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With children: + ```{python} + from emailer_lib.mjml import style, text + child = text("Hello World") + + result = style(child) + ``` + + With attributes: + ```{python} + result = style(attributes={"attr": "value"}) + ``` + + With both: + ```{python} + result = style(child, attributes={"background-color": "yellow"}) + ``` + """ + return MJMLTag("mj-style", *args, attributes=attributes, content=content) + + +def accordion( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With children: + ```{python} + from emailer_lib.mjml import accordion, text + child = text("Hello World") + + result = accordion(child) + ``` + + With attributes: + ```{python} + result = accordion(attributes={"attr": "value"}) + ``` + + With both: + ```{python} + result = accordion(child, attributes={"background-color": "yellow"}) + ``` + """ + return MJMLTag("mj-accordion", *args, attributes=attributes, content=content) + + +def accordion_element( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With children: + ```{python} + from emailer_lib.mjml import accordion_element, text + child = text("Hello World") + + result = accordion_element(child) + ``` + + With attributes: + ```{python} + result = accordion_element(attributes={"attr": "value"}) + ``` + + With both: + ```{python} + result = accordion_element(child, attributes={"background-color": "yellow"}) + ``` + """ + return MJMLTag("mj-accordion-element", *args, attributes=attributes, content=content) + + +def carousel( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With children: + ```{python} + from emailer_lib.mjml import carousel, text + child = text("Hello World") + + result = carousel(child) + ``` + + With attributes: + ```{python} + result = carousel(attributes={"attr": "value"}) + ``` + + With both: + ```{python} + result = carousel(child, attributes={"background-color": "yellow"}) + ``` + """ + return MJMLTag("mj-carousel", *args, attributes=attributes, content=content) + + +def column( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With children: + ```{python} + from emailer_lib.mjml import column, text + child = text("Hello World") + + result = column(child) + ``` + + With attributes: + ```{python} + result = column(attributes={"attr": "value"}) + ``` + + With both: + ```{python} + result = column(child, attributes={"background-color": "yellow"}) + ``` + """ + return MJMLTag("mj-column", *args, attributes=attributes, content=content) + + +def divider( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With children: + ```{python} + from emailer_lib.mjml import divider, text + child = text("Hello World") + + result = divider(child) + ``` + + With attributes: + ```{python} + result = divider(attributes={"attr": "value"}) + ``` + + With both: + ```{python} + result = divider(child, attributes={"background-color": "yellow"}) + ``` + """ + return MJMLTag("mj-divider", *args, attributes=attributes, content=content) + + +def group( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With children: + ```{python} + from emailer_lib.mjml import group, text + child = text("Hello World") + + result = group(child) + ``` + + With attributes: + ```{python} + result = group(attributes={"attr": "value"}) + ``` + + With both: + ```{python} + result = group(child, attributes={"background-color": "yellow"}) + ``` + """ + return MJMLTag("mj-group", *args, attributes=attributes, content=content) + + +def hero( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With children: + ```{python} + from emailer_lib.mjml import hero, text + child = text("Hello World") + + result = hero(child) + ``` + + With attributes: + ```{python} + result = hero(attributes={"attr": "value"}) + ``` + + With both: + ```{python} + result = hero(child, attributes={"background-color": "yellow"}) + ``` + """ + return MJMLTag("mj-hero", *args, attributes=attributes, content=content) + + +def image( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With children: + ```{python} + from emailer_lib.mjml import image, text + child = text("Hello World") + + result = image(child) + ``` + + With attributes: + ```{python} + result = image(attributes={"attr": "value"}) + ``` + + With both: + ```{python} + result = image(child, attributes={"background-color": "yellow"}) + ``` + """ + return MJMLTag("mj-image", *args, attributes=attributes, content=content) + + +def navbar( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With children: + ```{python} + from emailer_lib.mjml import navbar, text + child = text("Hello World") + + result = navbar(child) + ``` + + With attributes: + ```{python} + result = navbar(attributes={"attr": "value"}) + ``` + + With both: + ```{python} + result = navbar(child, attributes={"background-color": "yellow"}) + ``` + """ + return MJMLTag("mj-navbar", *args, attributes=attributes, content=content) + + +def section( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With children: + ```{python} + from emailer_lib.mjml import section, text + child = text("Hello World") + + result = section(child) + ``` + + With attributes: + ```{python} + result = section(attributes={"attr": "value"}) + ``` + + With both: + ```{python} + result = section(child, attributes={"background-color": "yellow"}) + ``` + """ + return MJMLTag("mj-section", *args, attributes=attributes, content=content) + + +def social( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With children: + ```{python} + from emailer_lib.mjml import social, text + child = text("Hello World") + + result = social(child) + ``` + + With attributes: + ```{python} + result = social(attributes={"attr": "value"}) + ``` + + With both: + ```{python} + result = social(child, attributes={"background-color": "yellow"}) + ``` + """ + return MJMLTag("mj-social", *args, attributes=attributes, content=content) + + +def spacer( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With children: + ```{python} + from emailer_lib.mjml import spacer, text + child = text("Hello World") + + result = spacer(child) + ``` + + With attributes: + ```{python} + result = spacer(attributes={"attr": "value"}) + ``` + + With both: + ```{python} + result = spacer(child, attributes={"background-color": "yellow"}) + ``` + """ + return MJMLTag("mj-spacer", *args, attributes=attributes, content=content) + + +def wrapper( + *args: TagChild, + attributes: Optional[TagAttrs] = None, + content: Optional[str] = None, +): + """ + Create an MJML `` tag. + + Parameters + ---------- + *args + Children (MJMLTag objects) + attributes + Optional dict of tag attributes + content + Optional text content for the tag + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With children: + ```{python} + from emailer_lib.mjml import wrapper, text + child = text("Hello World") + + result = wrapper(child) + ``` + + With attributes: + ```{python} + result = wrapper(attributes={"attr": "value"}) + ``` + + With both: + ```{python} + result = wrapper(child, attributes={"background-color": "yellow"}) + ``` + """ + return MJMLTag("mj-wrapper", *args, attributes=attributes, content=content) + + +def accordion_text( + content: Optional[str] = None, + attributes: Optional[TagAttrs] = None, +): + """ + Create an MJML `` tag. + + This is an ending tag that accepts text/HTML content but not MJML children. + + Parameters + ---------- + content + Text or HTML content for the tag + attributes + Optional dict of tag attributes + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With content: + ```{python} + from emailer_lib.mjml import accordion_text + + result = accordion_text("Hello") + ``` + + With attributes and content: + ```{python} + result = accordion_text("Hello", attributes={"background-color": "red"}) + ``` + """ + return MJMLTag("mj-accordion-text", content, attributes=attributes, _is_leaf=True) + + +def accordion_title( + content: Optional[str] = None, + attributes: Optional[TagAttrs] = None, +): + """ + Create an MJML `` tag. + + This is an ending tag that accepts text/HTML content but not MJML children. + + Parameters + ---------- + content + Text or HTML content for the tag + attributes + Optional dict of tag attributes + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With content: + ```{python} + from emailer_lib.mjml import accordion_title + + result = accordion_title("Hello") + ``` + + With attributes and content: + ```{python} + result = accordion_title("Hello", attributes={"background-color": "red"}) + ``` + """ + return MJMLTag("mj-accordion-title", content, attributes=attributes, _is_leaf=True) + + +def button( + content: Optional[str] = None, + attributes: Optional[TagAttrs] = None, +): + """ + Create an MJML `` tag. + + This is an ending tag that accepts text/HTML content but not MJML children. + + Parameters + ---------- + content + Text or HTML content for the tag + attributes + Optional dict of tag attributes + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With content: + ```{python} + from emailer_lib.mjml import button + + result = button("Hello") + ``` + + With attributes and content: + ```{python} + result = button("Hello", attributes={"background-color": "red"}) + ``` + """ + return MJMLTag("mj-button", content, attributes=attributes, _is_leaf=True) + + +def carousel_image( + content: Optional[str] = None, + attributes: Optional[TagAttrs] = None, +): + """ + Create an MJML `` tag. + + This is an ending tag that accepts text/HTML content but not MJML children. + + Parameters + ---------- + content + Text or HTML content for the tag + attributes + Optional dict of tag attributes + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With content: + ```{python} + from emailer_lib.mjml import carousel_image + + result = carousel_image("Hello") + ``` + + With attributes and content: + ```{python} + result = carousel_image("Hello", attributes={"background-color": "red"}) + ``` + """ + return MJMLTag("mj-carousel-image", content, attributes=attributes, _is_leaf=True) + + +def navbar_link( + content: Optional[str] = None, + attributes: Optional[TagAttrs] = None, +): + """ + Create an MJML `` tag. + + This is an ending tag that accepts text/HTML content but not MJML children. + + Parameters + ---------- + content + Text or HTML content for the tag + attributes + Optional dict of tag attributes + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With content: + ```{python} + from emailer_lib.mjml import navbar_link + + result = navbar_link("Hello") + ``` + + With attributes and content: + ```{python} + result = navbar_link("Hello", attributes={"background-color": "red"}) + ``` + """ + return MJMLTag("mj-navbar-link", content, attributes=attributes, _is_leaf=True) + + +def raw( + content: Optional[str] = None, + attributes: Optional[TagAttrs] = None, +): + """ + Create an MJML `` tag. + + This is an ending tag that accepts text/HTML content but not MJML children. + + Parameters + ---------- + content + Text or HTML content for the tag + attributes + Optional dict of tag attributes + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With content: + ```{python} + from emailer_lib.mjml import raw + + result = raw("Hello") + ``` + + With attributes and content: + ```{python} + result = raw("Hello", attributes={"background-color": "red"}) + ``` + """ + return MJMLTag("mj-raw", content, attributes=attributes, _is_leaf=True) + + +def social_element( + content: Optional[str] = None, + attributes: Optional[TagAttrs] = None, +): + """ + Create an MJML `` tag. + + This is an ending tag that accepts text/HTML content but not MJML children. + + Parameters + ---------- + content + Text or HTML content for the tag + attributes + Optional dict of tag attributes + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With content: + ```{python} + from emailer_lib.mjml import social_element + + result = social_element("Hello") + ``` + + With attributes and content: + ```{python} + result = social_element("Hello", attributes={"background-color": "red"}) + ``` + """ + return MJMLTag("mj-social-element", content, attributes=attributes, _is_leaf=True) + + +def table( + content: Optional[str] = None, + attributes: Optional[TagAttrs] = None, +): + """ + Create an MJML `` tag. + + This is an ending tag that accepts text/HTML content but not MJML children. + + Parameters + ---------- + content + Text or HTML content for the tag + attributes + Optional dict of tag attributes + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With content: + ```{python} + from emailer_lib.mjml import table + + result = table("Hello") + ``` + + With attributes and content: + ```{python} + result = table("Hello", attributes={"background-color": "red"}) + ``` + """ + return MJMLTag("mj-table", content, attributes=attributes, _is_leaf=True) + + +def text( + content: Optional[str] = None, + attributes: Optional[TagAttrs] = None, +): + """ + Create an MJML `` tag. + + This is an ending tag that accepts text/HTML content but not MJML children. + + Parameters + ---------- + content + Text or HTML content for the tag + attributes + Optional dict of tag attributes + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With content: + ```{python} + from emailer_lib.mjml import text + + result = text("Hello") + ``` + + With attributes and content: + ```{python} + result = text("Hello", attributes={"background-color": "red"}) + ``` + """ + return MJMLTag("mj-text", content, attributes=attributes, _is_leaf=True) + + +def title( + content: Optional[str] = None, + attributes: Optional[TagAttrs] = None, +): + """ + Create an MJML `` tag. + + This is an ending tag that accepts text/HTML content but not MJML children. + + Parameters + ---------- + content + Text or HTML content for the tag + attributes + Optional dict of tag attributes + + Returns + ------- + MJMLTag + MJMLTag object representing `` + + Examples + -------- + With content: + ```{python} + from emailer_lib.mjml import title + + result = title("Hello") + ``` + + With attributes and content: + ```{python} + result = title("Hello", attributes={"background-color": "red"}) + ``` + """ + return MJMLTag("mj-title", content, attributes=attributes, _is_leaf=True) diff --git a/emailer_lib/mjml/tests/test_core.py b/emailer_lib/mjml/tests/test_core.py new file mode 100644 index 0000000..935d955 --- /dev/null +++ b/emailer_lib/mjml/tests/test_core.py @@ -0,0 +1,173 @@ +import pytest +from emailer_lib.mjml._core import MJMLTag, TagAttrDict + + +def test_accepts_dict_arguments(): + attrs = TagAttrDict({"color": "red", "padding": "10px"}) + + assert attrs["color"] == "red" + assert attrs["padding"] == "10px" + + +def test_values_converted_to_strings(): + attrs = TagAttrDict({"width": 100, "visible": True, "opacity": 0.5}) + + assert attrs["width"] == "100" + assert attrs["visible"] == "True" + assert attrs["opacity"] == "0.5" + + +def test_update_method(): + attrs = TagAttrDict({"color": "red"}) + attrs.update({"font-size": "14px", "padding": "10px"}) + + assert attrs["color"] == "red" + assert attrs["font-size"] == "14px" + assert attrs["padding"] == "10px" + + +def test_tag_with_dict_attributes(): + attrs_dict = {"background-color": "#fff", "padding": "20px"} + tag = MJMLTag("mj-section", attributes=attrs_dict) + + assert tag.attrs["background-color"] == "#fff" + assert tag.attrs["padding"] == "20px" + + +def test_tag_filters_none_children(): + tag = MJMLTag("mj-column", MJMLTag("mj-text", content="Text"), None) + mjml_content = tag.render_mjml() + + # None should not appear in output + assert mjml_content.count("") == 1 + + +def test_render_empty_tag(): + tag = MJMLTag("mj-spacer") + mjml_content = tag.render_mjml() + assert mjml_content == "" + + +def test_render_with_attributes(): + tag = MJMLTag("mj-spacer", attributes={"height": "20px"}) + mjml_content = tag.render_mjml() + assert mjml_content == '' + + +def test_render_with_custom_indent(): + tag = MJMLTag("mj-text", content="Hello") + mjml_content = tag.render_mjml(indent=4) + assert mjml_content.startswith(" ") + + +def test_render_with_custom_eol(): + tag = MJMLTag("mj-text", content="Hello") + mjml_content = tag.render_mjml(eol="\r\n") + assert "\r\n" in mjml_content + + +def test_render_nested_tags(): + tag = MJMLTag( + "mj-section", MJMLTag("mj-column", MJMLTag("mj-text", content="Nested")) + ) + mjml_content = tag.render_mjml() + + assert "" in mjml_content + assert "" in mjml_content + assert "" in mjml_content + assert "Nested" in mjml_content + + +def test_render_with_string_and_tag_children(): + child_tag = MJMLTag("mj-text", content="Tagged") + tag = MJMLTag("mj-column", "Plain text", child_tag, "More text") + mjml_content = tag.render_mjml() + + assert "Plain text" in mjml_content + assert "" in mjml_content + assert "More text" in mjml_content + + +def test_repr_returns_mjml(): + tag = MJMLTag("mj-text", content="Hello") + + assert repr(tag) == tag.render_mjml() + + +def test_to_html_with_complete_mjml_document(): + tag = MJMLTag("mjml", MJMLTag("mj-body", MJMLTag("mj-section"))) + html_result = tag.to_html() + + assert "") == 3 + assert "Text 1" in mjml_content + assert "Text 2" in mjml_content + assert "Text 3" in mjml_content diff --git a/emailer_lib/mjml/tests/test_tags.py b/emailer_lib/mjml/tests/test_tags.py new file mode 100644 index 0000000..ea10c32 --- /dev/null +++ b/emailer_lib/mjml/tests/test_tags.py @@ -0,0 +1,267 @@ +import pytest +from emailer_lib.mjml import ( + MJMLTag, + mjml, + head, + body, + section, + column, + text, + button, + image, + raw, + accordion, + accordion_element, + accordion_text, + accordion_title, + navbar, + navbar_link, + social, + social_element, + carousel, + carousel_image, + table, + mj_attributes, + mj_all, + mj_class, +) + + +def test_container_tag_accepts_children(): + sec = section( + column( + text(content="Hello") + ) + ) + + assert isinstance(sec, MJMLTag) + assert sec.tagName == "mj-section" + assert len(sec.children) == 1 + assert sec.children[0].tagName == "mj-column" + + mjml_content = sec.render_mjml() + assert "" in mjml_content + assert "" in mjml_content + assert "" in mjml_content + assert "Hello" in mjml_content + assert "" in mjml_content + + +def test_container_tag_accepts_attributes(): + sec = section(attributes={"background-color": "#fff", "padding": "20px"}) + + assert sec.attrs["background-color"] == "#fff" + assert sec.attrs["padding"] == "20px" + + mjml_content = sec.render_mjml() + assert '' in mjml_content + + +def test_container_tag_accepts_children_and_attrs(): + sec = section( + column(text(content="Col 1")), + column(text(content="Col 2")), + attributes={"background-color": "#f0f0f0"} + ) + assert len(sec.children) == 2 + assert sec.attrs["background-color"] == "#f0f0f0" + + mjml_content = sec.render_mjml() + assert 'background-color="#f0f0f0"' in mjml_content + assert "Col 1" in mjml_content + assert "Col 2" in mjml_content + + +def test_leaf_tag_accepts_content(): + txt = text(content="Hello World") + + assert isinstance(txt, MJMLTag) + assert txt.tagName == "mj-text" + assert txt.content == "Hello World" + + mjml_content = txt.render_mjml() + assert mjml_content == "\nHello World\n" + + +def test_leaf_tag_accepts_attributes(): + txt = text(attributes={"color": "red", "font-size": "16px"}, content="Hello") + assert txt.content == "Hello" + assert txt.attrs["color"] == "red" + assert txt.attrs["font-size"] == "16px" + + mjml_content = txt.render_mjml() + assert 'color="red"' in mjml_content + assert 'font-size="16px"' in mjml_content + assert "Hello" in mjml_content + + +def test_leaf_tag_no_positional_children(): + # Leaf tags only have attributes and content parameters + # Passing a positional arg should fail + with pytest.raises(TypeError): + text(section(content="child_not_allowed")) + + +def test_button_is_leaf_tag(): + btn = button(attributes={"href": "https://example.com"}, content="Click Me") + assert btn.tagName == "mj-button" + assert btn.content == "Click Me" + assert btn.attrs["href"] == "https://example.com" + + mjml_content = btn.render_mjml() + assert 'href="https://example.com"' in mjml_content + assert "Click Me" in mjml_content + assert "Custom HTML") + assert r.tagName == "mj-raw" + assert r.content == "
Custom HTML
" + + mjml_content = r.render_mjml() + assert mjml_content == "\n
Custom HTML
\n
" + + +def test_table_tag(): + tbl = table(content="
Cell
") + assert tbl.tagName == "mj-table" + assert "" in tbl.content + + mjml_content = tbl.render_mjml() + assert "" in mjml_content + assert "
Cell
" in mjml_content + + +def test_mjml_full_document(): + doc = mjml( + head(), + body( + section( + column( + text(content="Hello World") + ) + ) + ) + ) + assert doc.tagName == "mjml" + assert len(doc.children) == 2 + assert doc.children[0].tagName == "mj-head" + assert doc.children[1].tagName == "mj-body" + + +def test_image_tag(): + img = image(attributes={"src": "https://example.com/image.jpg", "alt": "Test Image"}) + assert img.tagName == "mj-image" + assert img.attrs["src"] == "https://example.com/image.jpg" + assert img.attrs["alt"] == "Test Image" + + mjml_content = img.render_mjml() + assert 'src="https://example.com/image.jpg"' in mjml_content + assert 'alt="Test Image"' in mjml_content + assert "" in mjml_content + assert "" in mjml_content + assert '=45", "wheel", "setuptools_scm>=6.2"] +build-backend = "setuptools.build_meta" + +[tool.setuptools_scm] + +[tool.pytest.ini_options] +minversion = "6.0" +addopts = "-ra --cov=emailer_lib --cov-report=term-missing" +testpaths = ["emailer_lib/**/tests"] + [project] -name = "email-for-data-science" -version = "0.1.0" -description = "Add your description here" +name = "emailer-lib" +version = "0.0.1" +description = "Email serialization and sending utilities" +authors = [{name = "Jules Walzer-Goldfeld"}] readme = "README.md" + requires-python = ">=3.9" + dependencies = [ + "dotenv", + "mjml-python>=1.3.6", +] + +[project.optional-dependencies] +dev = [ + "aiosmtpd", + "pytest", + "quartodoc", + "pytest-cov", + "griffe", +] + +docs = [ "redmail>=0.6.0", "jupyter", "ipykernel>=6.29.5", - "dotenv", "nbformat", "nbclient", "great-tables>=0.18.0", @@ -17,14 +44,13 @@ dependencies = [ "css-inline>=0.17.0", "plotnine>=0.13.6", "pyarrow>=21.0.0", - "mjml-python>=1.3.6", - "quartodoc>=0.11.1", ] -[dependency-groups] -dev = [ - "quartodoc", - "pytest>=3", - "pytest-cov", - "griffe", +[tool.coverage.report] +exclude_also = [ + "if TYPE_CHECKING:" +] +include = ["emailer_lib/*"] +omit = [ + "emailer_lib/tests/*" ] diff --git a/reference/IntermediateEmail.preview_send_email.qmd b/reference/IntermediateEmail.preview_send_email.qmd deleted file mode 100644 index 6a4976d..0000000 --- a/reference/IntermediateEmail.preview_send_email.qmd +++ /dev/null @@ -1,22 +0,0 @@ -# IntermediateEmail.preview_send_email { #emailer_lib.IntermediateEmail.preview_send_email } - -```python -IntermediateEmail.preview_send_email() -``` - -Send a preview of the email to a test recipient. - -This method is intended for sending the email to a designated preview recipient -for testing purposes before sending to the full recipient list. - -## Returns {.doc-section .doc-section-returns} - -[]{.parameter-name} [:]{.parameter-annotation-sep} [None]{.parameter-annotation} - -: - -## Examples {.doc-section .doc-section-examples} - -```python -email.preview_send_email() -``` \ No newline at end of file diff --git a/reference/IntermediateEmail.qmd b/reference/IntermediateEmail.qmd deleted file mode 100644 index 5db5e37..0000000 --- a/reference/IntermediateEmail.qmd +++ /dev/null @@ -1,73 +0,0 @@ -# IntermediateEmail { #emailer_lib.IntermediateEmail } - -```python -IntermediateEmail( - html, - subject, - rsc_email_supress_report_attachment=None, - rsc_email_supress_scheduled=None, - external_attachments=None, - inline_attachments=None, - text=None, - recipients=None, -) -``` - -A serializable, previewable, sendable email object for data science workflows. - -The `IntermediateEmail` class provides a unified structure for representing email messages, -including HTML and plain text content, subject, inline or external attachments, and recipients. -It is designed to be generated from a variety of authoring tools and sent via multiple providers. - -## Parameters {.doc-section .doc-section-parameters} - -[**html**]{.parameter-name} [:]{.parameter-annotation-sep} [str]{.parameter-annotation} - -: The HTML content of the email. - -[**subject**]{.parameter-name} [:]{.parameter-annotation-sep} [str]{.parameter-annotation} - -: The subject line of the email. - -[**external_attachments**]{.parameter-name} [:]{.parameter-annotation-sep} [list\[str\] \| None]{.parameter-annotation} [ = ]{.parameter-default-sep} [None]{.parameter-default} - -: List of file paths for external attachments to include. - -[**inline_attachments**]{.parameter-name} [:]{.parameter-annotation-sep} [dict\[str, str\] \| None]{.parameter-annotation} [ = ]{.parameter-default-sep} [None]{.parameter-default} - -: Dictionary mapping filenames to base64-encoded strings for inline attachments. - -[**text**]{.parameter-name} [:]{.parameter-annotation-sep} [str \| None]{.parameter-annotation} [ = ]{.parameter-default-sep} [None]{.parameter-default} - -: Optional plain text version of the email. - -[**recipients**]{.parameter-name} [:]{.parameter-annotation-sep} [list\[str\] \| None]{.parameter-annotation} [ = ]{.parameter-default-sep} [None]{.parameter-default} - -: Optional list of recipient email addresses. - -[**rsc_email_supress_report_attachment**]{.parameter-name} [:]{.parameter-annotation-sep} [bool \| None]{.parameter-annotation} [ = ]{.parameter-default-sep} [None]{.parameter-default} - -: Whether to suppress report attachments (used in some workflows). - -[**rsc_email_supress_scheduled**]{.parameter-name} [:]{.parameter-annotation-sep} [bool \| None]{.parameter-annotation} [ = ]{.parameter-default-sep} [None]{.parameter-default} - -: Whether to suppress scheduled sending (used in some workflows). - -## Examples {.doc-section .doc-section-examples} - -```python -email = IntermediateEmail( - html="

Hello world

", - subject="Test Email", - recipients=["user@example.com"], -) -email.write_preview_email("preview.html") -``` - -## Methods - -| Name | Description | -| --- | --- | -| [preview_send_email](emailer_lib.IntermediateEmail.preview_send_email.qmd#emailer_lib.IntermediateEmail.preview_send_email) | Send a preview of the email to a test recipient. | -| [write_email_message](emailer_lib.IntermediateEmail.write_email_message.qmd#emailer_lib.IntermediateEmail.write_email_message) | Convert the IntermediateEmail to a Python EmailMessage. | -| [write_preview_email](emailer_lib.IntermediateEmail.write_preview_email.qmd#emailer_lib.IntermediateEmail.write_preview_email) | Write a preview HTML file with inline attachments embedded. | \ No newline at end of file diff --git a/reference/IntermediateEmail.write_email_message.qmd b/reference/IntermediateEmail.write_email_message.qmd deleted file mode 100644 index 63d4921..0000000 --- a/reference/IntermediateEmail.write_email_message.qmd +++ /dev/null @@ -1,22 +0,0 @@ -# IntermediateEmail.write_email_message { #emailer_lib.IntermediateEmail.write_email_message } - -```python -IntermediateEmail.write_email_message() -``` - -Convert the IntermediateEmail to a Python EmailMessage. - -This method creates a standard library EmailMessage object from the -IntermediateEmail, including HTML, plain text, recipients, and attachments. - -## Returns {.doc-section .doc-section-returns} - -[]{.parameter-name} [:]{.parameter-annotation-sep} [EmailMessage]{.parameter-annotation} - -: The constructed EmailMessage object. - -## Examples {.doc-section .doc-section-examples} - -```python -msg = email.write_email_message() -``` \ No newline at end of file diff --git a/reference/IntermediateEmail.write_preview_email.qmd b/reference/IntermediateEmail.write_preview_email.qmd deleted file mode 100644 index 21894f5..0000000 --- a/reference/IntermediateEmail.write_preview_email.qmd +++ /dev/null @@ -1,32 +0,0 @@ -# IntermediateEmail.write_preview_email { #emailer_lib.IntermediateEmail.write_preview_email } - -```python -IntermediateEmail.write_preview_email(out_file='preview_email.html') -``` - -Write a preview HTML file with inline attachments embedded. - -This method replaces image sources in the HTML with base64-encoded data from -inline attachments, allowing you to preview the email as it would appear to recipients. - -## Parameters {.doc-section .doc-section-parameters} - -[**out_file**]{.parameter-name} [:]{.parameter-annotation-sep} [str]{.parameter-annotation} [ = ]{.parameter-default-sep} [\'preview_email.html\']{.parameter-default} - -: The file path to write the preview HTML. Defaults to "preview_email.html". - -## Returns {.doc-section .doc-section-returns} - -[]{.parameter-name} [:]{.parameter-annotation-sep} [None]{.parameter-annotation} - -: - -## Examples {.doc-section .doc-section-examples} - -```python -email.write_preview_email("preview.html") -``` - -## Notes {.doc-section .doc-section-notes} - -Raises ValueError if external attachments are present, as preview does not support them. \ No newline at end of file diff --git a/reference/_styles-quartodoc.css b/reference/_styles-quartodoc.css deleted file mode 100644 index 51714ba..0000000 --- a/reference/_styles-quartodoc.css +++ /dev/null @@ -1,22 +0,0 @@ -/* -This file generated automatically by quartodoc version 0.11.1. -Modifications may be overwritten by quartodoc build. If you want to -customize styles, create a new .css file to avoid losing changes. -*/ - - -/* styles for parameter tables, etc.. ---- -*/ - -.doc-section dt code { - background: none; -} - -.doc-section dt { - /* background-color: lightyellow; */ - display: block; -} - -.doc-section dl dd { - margin-left: 3rem; -} diff --git a/reference/emailer_lib.IntermediateEmail.preview_send_email.qmd b/reference/emailer_lib.IntermediateEmail.preview_send_email.qmd deleted file mode 100644 index 7398b00..0000000 --- a/reference/emailer_lib.IntermediateEmail.preview_send_email.qmd +++ /dev/null @@ -1,22 +0,0 @@ -# preview_send_email { #emailer_lib.IntermediateEmail.preview_send_email } - -```python -IntermediateEmail.preview_send_email() -``` - -Send a preview of the email to a test recipient. - -This method is intended for sending the email to a designated preview recipient -for testing purposes before sending to the full recipient list. - -## Returns {.doc-section .doc-section-returns} - -[]{.parameter-name} [:]{.parameter-annotation-sep} [None]{.parameter-annotation} - -: - -## Examples {.doc-section .doc-section-examples} - -```python -email.preview_send_email() -``` \ No newline at end of file diff --git a/reference/emailer_lib.IntermediateEmail.write_email_message.qmd b/reference/emailer_lib.IntermediateEmail.write_email_message.qmd deleted file mode 100644 index e23a485..0000000 --- a/reference/emailer_lib.IntermediateEmail.write_email_message.qmd +++ /dev/null @@ -1,22 +0,0 @@ -# write_email_message { #emailer_lib.IntermediateEmail.write_email_message } - -```python -IntermediateEmail.write_email_message() -``` - -Convert the IntermediateEmail to a Python EmailMessage. - -This method creates a standard library EmailMessage object from the -IntermediateEmail, including HTML, plain text, recipients, and attachments. - -## Returns {.doc-section .doc-section-returns} - -[]{.parameter-name} [:]{.parameter-annotation-sep} [EmailMessage]{.parameter-annotation} - -: The constructed EmailMessage object. - -## Examples {.doc-section .doc-section-examples} - -```python -msg = email.write_email_message() -``` \ No newline at end of file diff --git a/reference/emailer_lib.IntermediateEmail.write_preview_email.qmd b/reference/emailer_lib.IntermediateEmail.write_preview_email.qmd deleted file mode 100644 index 2946061..0000000 --- a/reference/emailer_lib.IntermediateEmail.write_preview_email.qmd +++ /dev/null @@ -1,32 +0,0 @@ -# write_preview_email { #emailer_lib.IntermediateEmail.write_preview_email } - -```python -IntermediateEmail.write_preview_email(out_file='preview_email.html') -``` - -Write a preview HTML file with inline attachments embedded. - -This method replaces image sources in the HTML with base64-encoded data from -inline attachments, allowing you to preview the email as it would appear to recipients. - -## Parameters {.doc-section .doc-section-parameters} - -[**out_file**]{.parameter-name} [:]{.parameter-annotation-sep} [str]{.parameter-annotation} [ = ]{.parameter-default-sep} [\'preview_email.html\']{.parameter-default} - -: The file path to write the preview HTML. Defaults to "preview_email.html". - -## Returns {.doc-section .doc-section-returns} - -[]{.parameter-name} [:]{.parameter-annotation-sep} [None]{.parameter-annotation} - -: - -## Examples {.doc-section .doc-section-examples} - -```python -email.write_preview_email("preview.html") -``` - -## Notes {.doc-section .doc-section-notes} - -Raises ValueError if external attachments are present, as preview does not support them. \ No newline at end of file diff --git a/reference/index.qmd b/reference/index.qmd deleted file mode 100644 index 9d86d34..0000000 --- a/reference/index.qmd +++ /dev/null @@ -1,48 +0,0 @@ -# API Reference {.doc .doc-index} - -## The Email Object - -An email object that in a serializable, previewable format, optimized for emails with content generated by data scientists. - - -| | | -| --- | --- | -| [IntermediateEmail](IntermediateEmail.qmd#emailer_lib.IntermediateEmail) | A serializable, previewable, sendable email object for data science workflows. | -| [IntermediateEmail.write_preview_email](IntermediateEmail.write_preview_email.qmd#emailer_lib.IntermediateEmail.write_preview_email) | Write a preview HTML file with inline attachments embedded. | -| [IntermediateEmail.write_email_message](IntermediateEmail.write_email_message.qmd#emailer_lib.IntermediateEmail.write_email_message) | Convert the IntermediateEmail to a Python EmailMessage. | -| [IntermediateEmail.preview_send_email](IntermediateEmail.preview_send_email.qmd#emailer_lib.IntermediateEmail.preview_send_email) | Send a preview of the email to a test recipient. | - -## Uploading emails - -Converting emails to IntermediateEmails, at which point they can be previewed, tested, and sent. - - -| | | -| --- | --- | -| [quarto_json_to_intermediate_email](quarto_json_to_intermediate_email.qmd#emailer_lib.quarto_json_to_intermediate_email) | Convert a Quarto output metadata JSON file to an IntermediateEmail | -| [mjml_to_intermediate_email](mjml_to_intermediate_email.qmd#emailer_lib.mjml_to_intermediate_email) | Convert MJML markup to an IntermediateEmail | -| [redmail_to_intermediate_email](redmail_to_intermediate_email.qmd#emailer_lib.redmail_to_intermediate_email) | Convert a Redmail EmailMessage object to an IntermediateEmail | -| [yagmail_to_intermediate_email](yagmail_to_intermediate_email.qmd#emailer_lib.yagmail_to_intermediate_email) | Convert a Yagmail email object to an IntermediateEmail | - -## Sending - -Functions to sending emails with different providers. And a special handy one to bypass the intermediate object if you are sending a quarto email. - - -| | | -| --- | --- | -| [send_intermediate_email_with_gmail](send_intermediate_email_with_gmail.qmd#emailer_lib.send_intermediate_email_with_gmail) | Send an Intermediate Email object via Gmail. | -| [send_intermediate_email_with_smtp](send_intermediate_email_with_smtp.qmd#emailer_lib.send_intermediate_email_with_smtp) | Send an Intermediate Email object via SMTP. | -| [send_intermediate_email_with_redmail](send_intermediate_email_with_redmail.qmd#emailer_lib.send_intermediate_email_with_redmail) | Send an Intermediate Email object via Redmail. | -| [send_intermediate_email_with_yagmail](send_intermediate_email_with_yagmail.qmd#emailer_lib.send_intermediate_email_with_yagmail) | Send an Intermediate Email object via Yagmail. | -| [send_intermediate_email_with_mailgun](send_intermediate_email_with_mailgun.qmd#emailer_lib.send_intermediate_email_with_mailgun) | Send an Intermediate Email object via Mailgun. | -| [send_quarto_email_with_gmail](send_quarto_email_with_gmail.qmd#emailer_lib.send_quarto_email_with_gmail) | Send an email using Gmail with content from a Quarto metadata JSON file. | - -## Utilities - -Previews and more - - -| | | -| --- | --- | -| [write_email_message_to_file](write_email_message_to_file.qmd#emailer_lib.write_email_message_to_file) | Writes the HTML content of an email message to a file, inlining any images referenced by Content-ID (cid). | \ No newline at end of file diff --git a/reference/mjml_to_intermediate_email.qmd b/reference/mjml_to_intermediate_email.qmd deleted file mode 100644 index b828f86..0000000 --- a/reference/mjml_to_intermediate_email.qmd +++ /dev/null @@ -1,19 +0,0 @@ -# mjml_to_intermediate_email { #emailer_lib.mjml_to_intermediate_email } - -```python -mjml_to_intermediate_email(mjml_content) -``` - -Convert MJML markup to an IntermediateEmail - -## Parameters {.doc-section .doc-section-parameters} - -[**mjml_content**]{.parameter-name} [:]{.parameter-annotation-sep} [str]{.parameter-annotation} - -: MJML markup string - -## Returns {.doc-section .doc-section-returns} - -[]{.parameter-name} [:]{.parameter-annotation-sep} [An Intermediate Email object]{.parameter-annotation} - -: \ No newline at end of file diff --git a/reference/quarto_json_to_intermediate_email.qmd b/reference/quarto_json_to_intermediate_email.qmd deleted file mode 100644 index b3c8539..0000000 --- a/reference/quarto_json_to_intermediate_email.qmd +++ /dev/null @@ -1,13 +0,0 @@ -# quarto_json_to_intermediate_email { #emailer_lib.quarto_json_to_intermediate_email } - -```python -quarto_json_to_intermediate_email(path) -``` - -Convert a Quarto output metadata JSON file to an IntermediateEmail - -## Parameters {.doc-section .doc-section-parameters} - -[**path**]{.parameter-name} [:]{.parameter-annotation-sep} [str]{.parameter-annotation} - -: Path to the Quarto output metadata JSON file \ No newline at end of file diff --git a/reference/redmail_to_intermediate_email.qmd b/reference/redmail_to_intermediate_email.qmd deleted file mode 100644 index 7e03d00..0000000 --- a/reference/redmail_to_intermediate_email.qmd +++ /dev/null @@ -1,14 +0,0 @@ -# redmail_to_intermediate_email { #emailer_lib.redmail_to_intermediate_email } - -```python -redmail_to_intermediate_email(msg) -``` - -Convert a Redmail EmailMessage object to an IntermediateEmail - -## Params {.doc-section .doc-section-params} - -msg - The Redmail-generated EmailMessage object - -Converts the input EmailMessage to the intermediate email structure \ No newline at end of file diff --git a/reference/send_intermediate_email_with_gmail.qmd b/reference/send_intermediate_email_with_gmail.qmd deleted file mode 100644 index 509d95b..0000000 --- a/reference/send_intermediate_email_with_gmail.qmd +++ /dev/null @@ -1,39 +0,0 @@ -# send_intermediate_email_with_gmail { #emailer_lib.send_intermediate_email_with_gmail } - -```python -send_intermediate_email_with_gmail(username, password, i_email) -``` - -Send an Intermediate Email object via Gmail. - -## Parameters {.doc-section .doc-section-parameters} - -[**username**]{.parameter-name} [:]{.parameter-annotation-sep} [str]{.parameter-annotation} - -: Gmail account username for sending the email - -[**password**]{.parameter-name} [:]{.parameter-annotation-sep} [str]{.parameter-annotation} - -: Gmail app password - -[**i_email**]{.parameter-name} [:]{.parameter-annotation-sep} [IntermediateEmail]{.parameter-annotation} - -: IntermediateEmail object containing the email content and attachments - -## Returns {.doc-section .doc-section-returns} - -[]{.parameter-name} [:]{.parameter-annotation-sep} [None]{.parameter-annotation} - -: The function sends an email but doesn't return a value - -## Examples {.doc-section .doc-section-examples} - -```python -email = IntermediateEmail( - html="

Hello world

", - subject="Test Email", - recipients=["user@example.com"], -) - -send_intermediate_email_with_gmail("user@gmail.com", "password123", email) -``` \ No newline at end of file diff --git a/reference/send_intermediate_email_with_mailgun.qmd b/reference/send_intermediate_email_with_mailgun.qmd deleted file mode 100644 index fc7f9f5..0000000 --- a/reference/send_intermediate_email_with_mailgun.qmd +++ /dev/null @@ -1,23 +0,0 @@ -# send_intermediate_email_with_mailgun { #emailer_lib.send_intermediate_email_with_mailgun } - -```python -send_intermediate_email_with_mailgun(i_email) -``` - -Send an Intermediate Email object via Mailgun. - -## Parameters {.doc-section .doc-section-parameters} - -[**i_email**]{.parameter-name} [:]{.parameter-annotation-sep} [IntermediateEmail]{.parameter-annotation} - -: IntermediateEmail object containing the email content and attachments - -## Returns {.doc-section .doc-section-returns} - -[]{.parameter-name} [:]{.parameter-annotation-sep} [None]{.parameter-annotation} - -: - -## Notes {.doc-section .doc-section-notes} - -This function is a placeholder and has not been implemented yet. \ No newline at end of file diff --git a/reference/send_intermediate_email_with_redmail.qmd b/reference/send_intermediate_email_with_redmail.qmd deleted file mode 100644 index 32c0d50..0000000 --- a/reference/send_intermediate_email_with_redmail.qmd +++ /dev/null @@ -1,23 +0,0 @@ -# send_intermediate_email_with_redmail { #emailer_lib.send_intermediate_email_with_redmail } - -```python -send_intermediate_email_with_redmail(i_email) -``` - -Send an Intermediate Email object via Redmail. - -## Parameters {.doc-section .doc-section-parameters} - -[**i_email**]{.parameter-name} [:]{.parameter-annotation-sep} [IntermediateEmail]{.parameter-annotation} - -: IntermediateEmail object containing the email content and attachments - -## Returns {.doc-section .doc-section-returns} - -[]{.parameter-name} [:]{.parameter-annotation-sep} [None]{.parameter-annotation} - -: - -## Notes {.doc-section .doc-section-notes} - -This function is a placeholder and has not been implemented yet. \ No newline at end of file diff --git a/reference/send_intermediate_email_with_smtp.qmd b/reference/send_intermediate_email_with_smtp.qmd deleted file mode 100644 index b353990..0000000 --- a/reference/send_intermediate_email_with_smtp.qmd +++ /dev/null @@ -1,92 +0,0 @@ -# send_intermediate_email_with_smtp { #emailer_lib.send_intermediate_email_with_smtp } - -```python -send_intermediate_email_with_smtp( - smtp_host, - smtp_port, - username, - password, - i_email, - security=Literal['tls', 'ssl', 'smtp'], -) -``` - -Send an Intermediate Email object via SMTP. - -## Parameters {.doc-section .doc-section-parameters} - -[**smtp_host**]{.parameter-name} [:]{.parameter-annotation-sep} [str]{.parameter-annotation} - -: SMTP server hostname (e.g., "smtp.example.com") - -[**smtp_port**]{.parameter-name} [:]{.parameter-annotation-sep} [int]{.parameter-annotation} - -: SMTP server port (typically 587 for TLS, 465 for SSL, 25 for plain SMTP) - -[**username**]{.parameter-name} [:]{.parameter-annotation-sep} [str]{.parameter-annotation} - -: SMTP account username for authentication - -[**password**]{.parameter-name} [:]{.parameter-annotation-sep} [str]{.parameter-annotation} - -: SMTP account password - -[**i_email**]{.parameter-name} [:]{.parameter-annotation-sep} [IntermediateEmail]{.parameter-annotation} - -: IntermediateEmail object containing the email content and attachments - -[**security**]{.parameter-name} [:]{.parameter-annotation-sep} [str]{.parameter-annotation} [ = ]{.parameter-default-sep} [Literal\[\'tls\', \'ssl\', \'smtp\'\]]{.parameter-default} - -: Security protocol to use: "tls" (STARTTLS), "ssl" (SSL/TLS), or "smtp" (plain SMTP). Default is "tls". - -## Returns {.doc-section .doc-section-returns} - -[]{.parameter-name} [:]{.parameter-annotation-sep} [None]{.parameter-annotation} - -: The function sends an email but doesn't return a value - -## Raises {.doc-section .doc-section-raises} - -[:]{.parameter-annotation-sep} [ValueError]{.parameter-annotation} - -: If security parameter is not one of "tls", "ssl", or "smtp" - -## Examples {.doc-section .doc-section-examples} - -```python -email = IntermediateEmail( - html="

Hello world

", - subject="Test Email", - recipients=["user@example.com"], -) - -# TLS connection (port 587) - recommended -send_intermediate_email_with_smtp( - "smtp.example.com", - 587, - "user@example.com", - "password123", - email, - security="tls" -) - -# SSL connection (port 465) -send_intermediate_email_with_smtp( - "smtp.example.com", - 465, - "user@example.com", - "password123", - email, - security="ssl" -) - -# Plain SMTP (port 25) - insecure, for testing only -send_intermediate_email_with_smtp( - "127.0.0.1", - 8025, - "test@example.com", - "password", - email, - security="smtp" -) -``` \ No newline at end of file diff --git a/reference/send_intermediate_email_with_yagmail.qmd b/reference/send_intermediate_email_with_yagmail.qmd deleted file mode 100644 index 9cda074..0000000 --- a/reference/send_intermediate_email_with_yagmail.qmd +++ /dev/null @@ -1,23 +0,0 @@ -# send_intermediate_email_with_yagmail { #emailer_lib.send_intermediate_email_with_yagmail } - -```python -send_intermediate_email_with_yagmail(i_email) -``` - -Send an Intermediate Email object via Yagmail. - -## Parameters {.doc-section .doc-section-parameters} - -[**i_email**]{.parameter-name} [:]{.parameter-annotation-sep} [IntermediateEmail]{.parameter-annotation} - -: IntermediateEmail object containing the email content and attachments - -## Returns {.doc-section .doc-section-returns} - -[]{.parameter-name} [:]{.parameter-annotation-sep} [None]{.parameter-annotation} - -: - -## Notes {.doc-section .doc-section-notes} - -This function is a placeholder and has not been implemented yet. \ No newline at end of file diff --git a/reference/send_quarto_email_with_gmail.qmd b/reference/send_quarto_email_with_gmail.qmd deleted file mode 100644 index b152f26..0000000 --- a/reference/send_quarto_email_with_gmail.qmd +++ /dev/null @@ -1,42 +0,0 @@ -# send_quarto_email_with_gmail { #emailer_lib.send_quarto_email_with_gmail } - -```python -send_quarto_email_with_gmail(username, password, json_path, recipients) -``` - -Send an email using Gmail with content from a Quarto metadata JSON file. - -## Parameters {.doc-section .doc-section-parameters} - -[**username**]{.parameter-name} [:]{.parameter-annotation-sep} [str]{.parameter-annotation} - -: Gmail account username for sending the email - -[**password**]{.parameter-name} [:]{.parameter-annotation-sep} [str]{.parameter-annotation} - -: Gmail app password - -[**json_path**]{.parameter-name} [:]{.parameter-annotation-sep} [str]{.parameter-annotation} - -: Path to the Quarto-generated .output_metadata.json file - -[**recipients**]{.parameter-name} [:]{.parameter-annotation-sep} [list\[str\]]{.parameter-annotation} - -: List of email addresses to send the email to - -## Returns {.doc-section .doc-section-returns} - -[]{.parameter-name} [:]{.parameter-annotation-sep} [None]{.parameter-annotation} - -: The function sends an email but doesn't return a value - -## Examples {.doc-section .doc-section-examples} - -```python -send_quarto_email_with_gmail( - "user@gmail.com", - "password123", - "path/to/output_metadata.json", - ["recipient1@example.com", "recipient2@example.com"] -) -``` \ No newline at end of file diff --git a/reference/styles.css b/reference/styles.css deleted file mode 100644 index 9be2d3a..0000000 --- a/reference/styles.css +++ /dev/null @@ -1,4 +0,0 @@ -table.caption-top.table td a { - display: inline-block; - min-width: 18em; -} \ No newline at end of file diff --git a/reference/write_email_message_to_file.qmd b/reference/write_email_message_to_file.qmd deleted file mode 100644 index 6f1fd86..0000000 --- a/reference/write_email_message_to_file.qmd +++ /dev/null @@ -1,20 +0,0 @@ -# write_email_message_to_file { #emailer_lib.write_email_message_to_file } - -```python -write_email_message_to_file(msg, out_file='preview_email.html') -``` - -Writes the HTML content of an email message to a file, inlining any images referenced by Content-ID (cid). - -This function extracts all attachments referenced by Content-ID from the given EmailMessage, -replaces any `src="cid:..."` references in the HTML body with base64-encoded image data, -and writes the resulting HTML to the specified output file. - -Params: - msg - The email message object containing the HTML body and attachments. - out_file - The path to the output HTML file. - -Returns: - None \ No newline at end of file diff --git a/reference/yagmail_to_intermediate_email.qmd b/reference/yagmail_to_intermediate_email.qmd deleted file mode 100644 index a8f9f7f..0000000 --- a/reference/yagmail_to_intermediate_email.qmd +++ /dev/null @@ -1,13 +0,0 @@ -# yagmail_to_intermediate_email { #emailer_lib.yagmail_to_intermediate_email } - -```python -yagmail_to_intermediate_email() -``` - -Convert a Yagmail email object to an IntermediateEmail - -## Params {.doc-section .doc-section-params} - -(none) - -Not yet implemented \ No newline at end of file diff --git a/uv.lock b/uv.lock index ca91185..944a3b8 100644 --- a/uv.lock +++ b/uv.lock @@ -9,6 +9,19 @@ resolution-markers = [ "python_full_version < '3.10'", ] +[[package]] +name = "aiosmtpd" +version = "1.4.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "atpublic" }, + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/ca/b2b7cc880403ef24be77383edaadfcf0098f5d7b9ddbf3e2c17ef0a6af0d/aiosmtpd-1.4.6.tar.gz", hash = "sha256:5a811826e1a5a06c25ebc3e6c4a704613eb9a1bcf6b78428fbe865f4f6c9a4b8", size = 152775, upload-time = "2024-05-18T11:37:50.029Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/39/d401756df60a8344848477d54fdf4ce0f50531f6149f3b8eaae9c06ae3dc/aiosmtpd-1.4.6-py3-none-any.whl", hash = "sha256:72c99179ba5aa9ae0abbda6994668239b64a5ce054471955fe75f581d2592475", size = 154263, upload-time = "2024-05-18T11:37:47.877Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -124,6 +137,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/49/d10027df9fce941cb8184e78a02857af36360d33e1721df81c5ed2179a1a/async_lru-2.0.5-py3-none-any.whl", hash = "sha256:ab95404d8d2605310d345932697371a5f40def0487c03d6d0ad9138de52c9943", size = 6069, upload-time = "2025-03-16T17:25:35.422Z" }, ] +[[package]] +name = "atpublic" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/78/a7c9b6d6581353204a7a099567783dd3352405b1662988892b9e67039c6c/atpublic-6.0.2.tar.gz", hash = "sha256:f90dcd17627ac21d5ce69e070d6ab89fb21736eb3277e8b693cc8484e1c7088c", size = 17708, upload-time = "2025-09-24T18:30:13.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/da/8916af0a074d24354d685fe4178a52d3fafd07b62e6f81124fdeac15594d/atpublic-6.0.2-py3-none-any.whl", hash = "sha256:156cfd3854e580ebfa596094a018fe15e4f3fa5bade74b39c3dabb54f12d6565", size = 6423, upload-time = "2025-09-24T18:30:15.214Z" }, +] + [[package]] name = "attrs" version = "25.4.0" @@ -1020,16 +1042,27 @@ wheels = [ ] [[package]] -name = "email-for-data-science" -version = "0.1.0" -source = { virtual = "." } +name = "emailer-lib" +version = "0.0.1" +source = { editable = "." } dependencies = [ - { name = "css-inline" }, { name = "dotenv" }, + { name = "mjml-python" }, +] + +[package.optional-dependencies] +dev = [ + { name = "aiosmtpd" }, + { name = "griffe" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "quartodoc" }, +] +docs = [ + { name = "css-inline" }, { name = "great-tables" }, { name = "ipykernel" }, { name = "jupyter" }, - { name = "mjml-python" }, { name = "nbclient" }, { name = "nbformat" }, { name = "pandas" }, @@ -1037,43 +1070,31 @@ dependencies = [ { name = "plotnine", version = "0.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "polars" }, { name = "pyarrow" }, - { name = "quartodoc" }, { name = "redmail" }, ] -[package.dev-dependencies] -dev = [ - { name = "griffe" }, - { name = "pytest" }, - { name = "pytest-cov" }, - { name = "quartodoc" }, -] - [package.metadata] requires-dist = [ - { name = "css-inline", specifier = ">=0.17.0" }, + { name = "aiosmtpd", marker = "extra == 'dev'" }, + { name = "css-inline", marker = "extra == 'docs'", specifier = ">=0.17.0" }, { name = "dotenv" }, - { name = "great-tables", specifier = ">=0.18.0" }, - { name = "ipykernel", specifier = ">=6.29.5" }, - { name = "jupyter" }, + { name = "great-tables", marker = "extra == 'docs'", specifier = ">=0.18.0" }, + { name = "griffe", marker = "extra == 'dev'" }, + { name = "ipykernel", marker = "extra == 'docs'", specifier = ">=6.29.5" }, + { name = "jupyter", marker = "extra == 'docs'" }, { name = "mjml-python", specifier = ">=1.3.6" }, - { name = "nbclient" }, - { name = "nbformat" }, - { name = "pandas", specifier = ">=2.3.3" }, - { name = "plotnine", specifier = ">=0.13.6" }, - { name = "polars", specifier = ">=1.34.0" }, - { name = "pyarrow", specifier = ">=21.0.0" }, - { name = "quartodoc", specifier = ">=0.11.1" }, - { name = "redmail", specifier = ">=0.6.0" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "griffe" }, - { name = "pytest", specifier = ">=3" }, - { name = "pytest-cov" }, - { name = "quartodoc" }, -] + { name = "nbclient", marker = "extra == 'docs'" }, + { name = "nbformat", marker = "extra == 'docs'" }, + { name = "pandas", marker = "extra == 'docs'", specifier = ">=2.3.3" }, + { name = "plotnine", marker = "extra == 'docs'", specifier = ">=0.13.6" }, + { name = "polars", marker = "extra == 'docs'", specifier = ">=1.34.0" }, + { name = "pyarrow", marker = "extra == 'docs'", specifier = ">=21.0.0" }, + { name = "pytest", marker = "extra == 'dev'" }, + { name = "pytest-cov", marker = "extra == 'dev'" }, + { name = "quartodoc", marker = "extra == 'dev'" }, + { name = "redmail", marker = "extra == 'docs'", specifier = ">=0.6.0" }, +] +provides-extras = ["dev", "docs"] [[package]] name = "exceptiongroup"