ewoksdrac 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. ewoksdrac-0.1.0/LICENSE.md +20 -0
  2. ewoksdrac-0.1.0/PKG-INFO +64 -0
  3. ewoksdrac-0.1.0/README.md +7 -0
  4. ewoksdrac-0.1.0/pyproject.toml +85 -0
  5. ewoksdrac-0.1.0/setup.cfg +4 -0
  6. ewoksdrac-0.1.0/src/ewoksdrac/__init__.py +0 -0
  7. ewoksdrac-0.1.0/src/ewoksdrac/tasks/__init__.py +0 -0
  8. ewoksdrac-0.1.0/src/ewoksdrac/tasks/base_tasks/__init__.py +0 -0
  9. ewoksdrac-0.1.0/src/ewoksdrac/tasks/base_tasks/drac_investigation_task.py +15 -0
  10. ewoksdrac-0.1.0/src/ewoksdrac/tasks/base_tasks/drac_investigation_task_inputs.py +10 -0
  11. ewoksdrac-0.1.0/src/ewoksdrac/tasks/base_tasks/drac_task.py +74 -0
  12. ewoksdrac-0.1.0/src/ewoksdrac/tasks/base_tasks/drac_task_inputs.py +15 -0
  13. ewoksdrac-0.1.0/src/ewoksdrac/tasks/base_tasks/drac_task_outputs.py +20 -0
  14. ewoksdrac-0.1.0/src/ewoksdrac/tasks/mail/__init__.py +0 -0
  15. ewoksdrac-0.1.0/src/ewoksdrac/tasks/mail/render.py +32 -0
  16. ewoksdrac-0.1.0/src/ewoksdrac/tasks/mail/send.py +58 -0
  17. ewoksdrac-0.1.0/src/ewoksdrac/tasks/mail/send_inputs.py +43 -0
  18. ewoksdrac-0.1.0/src/ewoksdrac/tasks/mail/smtp.py +35 -0
  19. ewoksdrac-0.1.0/src/ewoksdrac/tasks/mail/templates/base.html.j2 +17 -0
  20. ewoksdrac-0.1.0/src/ewoksdrac/tasks/mail/templates/mx_report.html.j2 +7 -0
  21. ewoksdrac-0.1.0/src/ewoksdrac/tasks/report/__init__.py +0 -0
  22. ewoksdrac-0.1.0/src/ewoksdrac/tasks/report/mx.py +55 -0
  23. ewoksdrac-0.1.0/src/ewoksdrac/tasks/report/mx_inputs.py +5 -0
  24. ewoksdrac-0.1.0/src/ewoksdrac/tasks/report/mx_outputs.py +17 -0
  25. ewoksdrac-0.1.0/src/ewoksdrac/tests/__init__.py +0 -0
  26. ewoksdrac-0.1.0/src/ewoksdrac/tests/conftest.py +9 -0
  27. ewoksdrac-0.1.0/src/ewoksdrac/tests/test_mx_report.py +100 -0
  28. ewoksdrac-0.1.0/src/ewoksdrac/tests/test_send_email.py +192 -0
  29. ewoksdrac-0.1.0/src/ewoksdrac/tests/test_smtp.py +65 -0
  30. ewoksdrac-0.1.0/src/ewoksdrac/tests/utils.py +26 -0
  31. ewoksdrac-0.1.0/src/ewoksdrac.egg-info/PKG-INFO +64 -0
  32. ewoksdrac-0.1.0/src/ewoksdrac.egg-info/SOURCES.txt +34 -0
  33. ewoksdrac-0.1.0/src/ewoksdrac.egg-info/dependency_links.txt +1 -0
  34. ewoksdrac-0.1.0/src/ewoksdrac.egg-info/entry_points.txt +3 -0
  35. ewoksdrac-0.1.0/src/ewoksdrac.egg-info/requires.txt +20 -0
  36. ewoksdrac-0.1.0/src/ewoksdrac.egg-info/top_level.txt +1 -0
@@ -0,0 +1,20 @@
1
+ # MIT License
2
+
3
+ **Copyright (c) 2026 European Synchrotron Radiation Facility**
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: ewoksdrac
3
+ Version: 0.1.0
4
+ Summary: Ewoks tasks for the Data Portal
5
+ Author-email: ESRF <dau-pydev@esrf.fr>
6
+ License: # MIT License
7
+
8
+ **Copyright (c) 2026 European Synchrotron Radiation Facility**
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
11
+ this software and associated documentation files (the "Software"), to deal in
12
+ the Software without restriction, including without limitation the rights to
13
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
14
+ the Software, and to permit persons to whom the Software is furnished to do so,
15
+ subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
22
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
23
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
24
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
25
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
26
+
27
+ Project-URL: Homepage, https://gitlab.esrf.fr/workflow/ewoksapps/ewoksdrac/
28
+ Project-URL: Documentation, https://ewoksdrac.readthedocs.io/
29
+ Project-URL: Repository, https://gitlab.esrf.fr/workflow/ewoksapps/ewoksdrac/
30
+ Project-URL: Issues, https://gitlab.esrf.fr/workflow/ewoksapps/ewoksdrac/issues
31
+ Project-URL: Changelog, https://gitlab.esrf.fr/workflow/ewoksapps/ewoksdrac/-/blob/main/CHANGELOG.md
32
+ Keywords: ewoks
33
+ Classifier: Intended Audience :: Science/Research
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Requires-Python: >=3.11
37
+ Description-Content-Type: text/markdown
38
+ License-File: LICENSE.md
39
+ Requires-Dist: ewokscore
40
+ Requires-Dist: requests
41
+ Requires-Dist: pydantic
42
+ Requires-Dist: jinja2
43
+ Requires-Dist: minedrac>=1.1.0
44
+ Provides-Extra: test
45
+ Requires-Dist: pytest>=7; extra == "test"
46
+ Provides-Extra: dev
47
+ Requires-Dist: ewoksdrac[test]; extra == "dev"
48
+ Requires-Dist: ruff; extra == "dev"
49
+ Provides-Extra: doc
50
+ Requires-Dist: ewoksdrac[test]; extra == "doc"
51
+ Requires-Dist: sphinx>=4.5; extra == "doc"
52
+ Requires-Dist: sphinx-autodoc-typehints>=1.16; extra == "doc"
53
+ Requires-Dist: pydata-sphinx-theme; extra == "doc"
54
+ Requires-Dist: sphinx-copybutton; extra == "doc"
55
+ Requires-Dist: ewokssphinx>=2.0; extra == "doc"
56
+ Dynamic: license-file
57
+
58
+ # ewoksdrac
59
+
60
+ Ewoks tasks for the Data Portal
61
+
62
+ ## Documentation
63
+
64
+ https://ewoksdrac.readthedocs.io/
@@ -0,0 +1,7 @@
1
+ # ewoksdrac
2
+
3
+ Ewoks tasks for the Data Portal
4
+
5
+ ## Documentation
6
+
7
+ https://ewoksdrac.readthedocs.io/
@@ -0,0 +1,85 @@
1
+ [build-system]
2
+ requires = [
3
+ "setuptools>=61",
4
+ ]
5
+ build-backend = "setuptools.build_meta"
6
+
7
+ [project]
8
+ name = "ewoksdrac"
9
+ version = "0.1.0"
10
+ keywords = [
11
+ "ewoks"
12
+ ]
13
+
14
+ authors = [{name = "ESRF", email = "dau-pydev@esrf.fr"}]
15
+ description = "Ewoks tasks for the Data Portal"
16
+ readme = {file = "README.md", content-type = "text/markdown"}
17
+ license = {file = "LICENSE.md"}
18
+ classifiers = [
19
+ "Intended Audience :: Science/Research",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ ]
23
+ requires-python = ">=3.11"
24
+ dependencies = [
25
+ "ewokscore",
26
+ "requests",
27
+ "pydantic",
28
+ "jinja2",
29
+ "minedrac >=1.1.0",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://gitlab.esrf.fr/workflow/ewoksapps/ewoksdrac/"
34
+ Documentation = "https://ewoksdrac.readthedocs.io/"
35
+ Repository = "https://gitlab.esrf.fr/workflow/ewoksapps/ewoksdrac/"
36
+ Issues = "https://gitlab.esrf.fr/workflow/ewoksapps/ewoksdrac/issues"
37
+ Changelog = "https://gitlab.esrf.fr/workflow/ewoksapps/ewoksdrac/-/blob/main/CHANGELOG.md"
38
+
39
+ [project.optional-dependencies]
40
+ test = [
41
+ "pytest >=7",
42
+ ]
43
+ dev = [
44
+ "ewoksdrac[test]",
45
+ "ruff",
46
+ ]
47
+ doc = [
48
+ "ewoksdrac[test]",
49
+ "sphinx >=4.5",
50
+ "sphinx-autodoc-typehints >=1.16",
51
+ "pydata-sphinx-theme",
52
+ "sphinx-copybutton",
53
+ "ewokssphinx >=2.0"
54
+ ]
55
+
56
+ [tool.setuptools]
57
+ package-dir = {""= "src"}
58
+
59
+ [tool.setuptools.packages.find]
60
+ where = ["src"]
61
+ namespaces = false
62
+
63
+ [tool.setuptools.package-data]
64
+ "ewoksdrac.tests.data"= ["*/*"]
65
+ "ewoksdrac.tasks.mail"= ["templates/*.j2"]
66
+
67
+ [project.entry-points."ewoks.tasks.class"]
68
+ "ewoksdrac.tasks.report.*" = "ewoksdrac"
69
+ "ewoksdrac.tasks.mail.*" = "ewoksdrac"
70
+
71
+
72
+ [tool.coverage.run]
73
+ omit = [
74
+ "*/tests/*"
75
+ ]
76
+ [tool.ruff.lint]
77
+ select = ["E", "F", "I", "S"]
78
+ ignore = ["E203", "E501", "E701"]
79
+
80
+ [tool.ruff.lint.per-file-ignores]
81
+ # Ignore `S101` (assert used violations) in all test files
82
+ "*/test_*.py" = ["S101"]
83
+
84
+ [tool.ruff.lint.isort]
85
+ force-single-line = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
File without changes
@@ -0,0 +1,15 @@
1
+ from .drac_investigation_task_inputs import DracInvestigationTaskInputs
2
+ from .drac_task import DracTask
3
+
4
+
5
+ class DracInvestigationTask(
6
+ DracTask,
7
+ input_model=DracInvestigationTaskInputs,
8
+ ):
9
+ """
10
+ A task conceived to be run for a whole investigation (experiment session).
11
+ """
12
+
13
+ @property
14
+ def investigation_id(self) -> int:
15
+ return self.inputs.investigation_id
@@ -0,0 +1,10 @@
1
+ from pydantic import Field
2
+
3
+ from .drac_task_inputs import DracTaskInputs
4
+
5
+
6
+ class DracInvestigationTaskInputs(DracTaskInputs):
7
+ investigation_id: int = Field(
8
+ description="The id of the investigation (experiment session) this task operates on",
9
+ examples=[12345],
10
+ )
@@ -0,0 +1,74 @@
1
+ import logging
2
+
3
+ import requests
4
+ from ewokscore import Task
5
+
6
+ from .drac_task_inputs import DracTaskInputs
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class DracTask(Task, input_model=DracTaskInputs):
12
+ """
13
+ Base class for a task launched from the Data Portal. It owns the run()
14
+ lifecycle and the status/log callback shared by every DRAC task.
15
+ """
16
+
17
+ @property
18
+ def session_id(self) -> str:
19
+ return self.inputs.session_id
20
+
21
+ @property
22
+ def callback(self) -> str | None:
23
+ if self.inputs.callback is None:
24
+ return None
25
+ return str(self.inputs.callback)
26
+
27
+ def pass_on_session(self) -> None:
28
+ """Output the session_id and callback, for a task whose output model
29
+ extends DracTaskOutputs."""
30
+ self.outputs.session_id = self.session_id
31
+ self.outputs.callback = self.callback
32
+
33
+ def process(self) -> None:
34
+ """The main processing logic."""
35
+ raise NotImplementedError
36
+
37
+ def finalize(self) -> None:
38
+ """Called after process() succeeds, e.g. to upload results to the catalogue."""
39
+
40
+ def run(self) -> None:
41
+ try:
42
+ self.set_status("STARTED")
43
+ self.set_status("RUNNING")
44
+ self.process()
45
+ self.finalize()
46
+ except Exception as ex:
47
+ self.log({"logs": {"error": str(ex)}})
48
+ self.set_status("TERMINATED", raise_on_error=True)
49
+ raise
50
+ else:
51
+ self.set_status("FINISHED", raise_on_error=True)
52
+
53
+ def set_status(self, status: str, raise_on_error: bool = False) -> None:
54
+ self.log({"status": status}, raise_on_error=raise_on_error)
55
+
56
+ def log(self, payload: dict, raise_on_error: bool = False) -> None:
57
+ """
58
+ Sends a log payload to the callback URL if provided.
59
+
60
+ Args:
61
+ payload (dict): The JSON payload to send.
62
+ raise_on_error (bool): Raise when the callback request fails instead of only logging it.
63
+ """
64
+ logger.debug(payload)
65
+ if not self.callback:
66
+ return
67
+
68
+ try:
69
+ response = requests.put(self.callback, json=payload, timeout=30)
70
+ response.raise_for_status()
71
+ except Exception as ex:
72
+ if raise_on_error:
73
+ raise
74
+ logger.error(f"Logging request failed: {ex}")
@@ -0,0 +1,15 @@
1
+ from ewokscore.model import BaseInputModel
2
+ from pydantic import Field
3
+ from pydantic import HttpUrl
4
+
5
+
6
+ class DracTaskInputs(BaseInputModel):
7
+ session_id: str = Field(
8
+ description="A unique identifier for the ICAT session. It is used as a token to identify the user",
9
+ examples=["xfthscs-234234sdfsd-fsfsd21"],
10
+ )
11
+ callback: HttpUrl | None = Field(
12
+ default=None,
13
+ description="A web endpoint to be called upon task completion or status updates. Usually a call to icat+",
14
+ examples=["https://icatplus.esrf.fr/callback"],
15
+ )
@@ -0,0 +1,20 @@
1
+ from ewokscore.model import BaseOutputModel
2
+ from pydantic import Field
3
+
4
+
5
+ class DracTaskOutputs(BaseOutputModel):
6
+ """Outputs of a DRAC task followed by another DRAC task in the workflow.
7
+
8
+ The Data Portal only gives the session_id and callback to the first task of a
9
+ workflow, so this task passes them on through the workflow links.
10
+ """
11
+
12
+ session_id: str = Field(
13
+ description="The session_id input, passed on to the next task of the workflow",
14
+ examples=["xfthscs-234234sdfsd-fsfsd21"],
15
+ )
16
+ callback: str | None = Field(
17
+ default=None,
18
+ description="The callback input, passed on to the next task of the workflow",
19
+ examples=["https://icatplus.esrf.fr/callback"],
20
+ )
File without changes
@@ -0,0 +1,32 @@
1
+ import os
2
+ from typing import Any
3
+
4
+ from jinja2 import PackageLoader
5
+ from jinja2 import StrictUndefined
6
+ from jinja2 import select_autoescape
7
+ from jinja2.sandbox import SandboxedEnvironment
8
+
9
+ _ENVIRONMENT = SandboxedEnvironment(
10
+ loader=PackageLoader("ewoksdrac.tasks.mail", "templates"),
11
+ undefined=StrictUndefined,
12
+ autoescape=select_autoescape(["html.j2"]),
13
+ )
14
+
15
+
16
+ def render_email(
17
+ subject: str, template: str, context: dict[str, Any]
18
+ ) -> tuple[str, str]:
19
+ """Render the subject string and the packaged HTML body template with the context.
20
+
21
+ The body template is ``templates/<template>.html.j2``. A variable missing from
22
+ the context raises instead of rendering as an empty string.
23
+ """
24
+ variables = {
25
+ "data_portal_url": os.environ.get("DATA_PORTAL_URL", "https://data.esrf.fr"),
26
+ "contact_email": os.environ.get("CONTACT_EMAIL", "dataportalrequests@esrf.fr"),
27
+ "signature": os.environ.get("EMAIL_SIGNATURE", "The ESRF Data Portal"),
28
+ **context,
29
+ }
30
+ rendered_subject = _ENVIRONMENT.from_string(subject).render(variables)
31
+ body = _ENVIRONMENT.get_template(f"{template}.html.j2").render(variables)
32
+ return rendered_subject, body
@@ -0,0 +1,58 @@
1
+ from pathlib import Path
2
+
3
+ from minedrac.data.session import UserLookupError
4
+ from minedrac.data.session import get_user_email
5
+
6
+ from ..base_tasks.drac_task import DracTask
7
+ from .render import render_email
8
+ from .send_inputs import SendEmailInputs
9
+ from .smtp import send_email
10
+
11
+
12
+ class SendEmail(
13
+ DracTask,
14
+ input_model=SendEmailInputs,
15
+ ):
16
+ """Send an email, optionally with attachments, to users of the ICAT session"""
17
+
18
+ def process(self) -> None:
19
+ try:
20
+ self._send()
21
+ finally:
22
+ if self.inputs.delete_attachments:
23
+ _delete_files(self.inputs.attachments)
24
+
25
+ def _send(self) -> None:
26
+ try:
27
+ email = get_user_email(self.session_id)
28
+ except UserLookupError as ex:
29
+ raise RuntimeError(
30
+ f"Could not find the user of this session to email: {ex}"
31
+ ) from ex
32
+ if not email:
33
+ raise RuntimeError("No email address found for the user of this session")
34
+
35
+ subject, body = render_email(
36
+ self.inputs.subject, self.inputs.template, self.inputs.context
37
+ )
38
+ attachments = self.inputs.attachments
39
+ self.log({"logs": {"msg": "About to send the email"}})
40
+ # TODO send through the email REST API once it is available
41
+ send_email([email], subject, body, attachments)
42
+ self.log(
43
+ {
44
+ "logs": {
45
+ "msg": "email sent",
46
+ "attachments": str([f.name for f in attachments]),
47
+ }
48
+ }
49
+ )
50
+
51
+
52
+ def _delete_files(files: list[Path]) -> None:
53
+ for file in files:
54
+ file.unlink(missing_ok=True)
55
+ try:
56
+ file.parent.rmdir()
57
+ except OSError:
58
+ pass
@@ -0,0 +1,43 @@
1
+ from pathlib import Path
2
+ from typing import Any
3
+
4
+ from pydantic import Field
5
+ from pydantic import field_validator
6
+
7
+ from ..base_tasks.drac_task_inputs import DracTaskInputs
8
+
9
+
10
+ class SendEmailInputs(DracTaskInputs):
11
+ subject: str = Field(
12
+ description="Subject of the email, a Jinja2 template rendered with the context",
13
+ examples=["MX report of investigation {{ investigation_id }}"],
14
+ )
15
+ template: str = Field(
16
+ description="Name of the packaged Jinja2 template of the HTML body, without the '.html.j2' extension",
17
+ examples=["mx_report"],
18
+ )
19
+ context: dict[str, Any] = Field(
20
+ default_factory=dict,
21
+ description="Variables available to the subject and body templates, usually the context output of the previous task",
22
+ examples=[{"investigation_id": 12345, "generated_at": "2026-10-14 10:00:00"}],
23
+ )
24
+ attachments: list[Path] = Field(
25
+ default_factory=list,
26
+ description="Paths of the files to attach to the email. A single path is also accepted",
27
+ examples=[
28
+ ["/data/visitor/mx1234/reports/mx_report_12345.csv"],
29
+ "/data/visitor/mx1234/reports/mx_report_12345.csv",
30
+ ],
31
+ )
32
+ delete_attachments: bool = Field(
33
+ default=False,
34
+ description="Delete the attachments, and their directory when it is left empty, once the email is sent or failed. For temporary files such as generated reports",
35
+ examples=[True],
36
+ )
37
+
38
+ @field_validator("attachments", mode="before")
39
+ @classmethod
40
+ def single_attachment(cls, value: Any) -> Any:
41
+ if isinstance(value, (str, Path)):
42
+ return [value]
43
+ return value
@@ -0,0 +1,35 @@
1
+ import mimetypes
2
+ import os
3
+ import smtplib
4
+ from collections.abc import Sequence
5
+ from email.message import EmailMessage
6
+ from pathlib import Path
7
+
8
+ SMTP_TIMEOUT = 30 # seconds
9
+
10
+
11
+ def send_email(
12
+ recipients: Sequence[str],
13
+ subject: str,
14
+ html_body: str,
15
+ attachments: Sequence[str | Path] = (),
16
+ ) -> None:
17
+ """Send an HTML email through the SMTP server configured by the environment variables."""
18
+ message = EmailMessage()
19
+ message["Subject"] = subject
20
+ message["From"] = os.environ.get("SMTP_FROM", "dataportal@esrf.fr")
21
+ message["To"] = ", ".join(recipients)
22
+ message.set_content(html_body, subtype="html")
23
+
24
+ for attachment in attachments:
25
+ path = Path(attachment)
26
+ mime_type, _ = mimetypes.guess_type(path)
27
+ maintype, subtype = (mime_type or "application/octet-stream").split("/", 1)
28
+ message.add_attachment(
29
+ path.read_bytes(), maintype=maintype, subtype=subtype, filename=path.name
30
+ )
31
+
32
+ host = os.environ.get("SMTP_HOST", "localhost")
33
+ port = int(os.environ.get("SMTP_PORT", "25"))
34
+ with smtplib.SMTP(host, port, timeout=SMTP_TIMEOUT) as smtp:
35
+ smtp.send_message(message)
@@ -0,0 +1,17 @@
1
+ <html>
2
+ <head>
3
+ <meta charset="utf-8" />
4
+ <style>
5
+ .text-footer {
6
+ font-size: 0.8em;
7
+ color: #666;
8
+ }
9
+ </style>
10
+ </head>
11
+ <body>
12
+ <p>Hello,</p>
13
+ {% block content %}{% endblock %}
14
+ <p>Best regards,<br />{{ signature }}</p>
15
+ <p class="text-footer"><i>This email was automatically generated by the Data Portal. If you have any questions, please contact <a href="mailto:{{ contact_email }}">{{ contact_email }}</a></i></p>
16
+ </body>
17
+ </html>
@@ -0,0 +1,7 @@
1
+ {% extends "base.html.j2" %}
2
+ {% block content %}
3
+ {% set investigation_url = data_portal_url ~ "/investigation/" ~ investigation_id ~ "/datasets" %}
4
+ <p>Please find attached the MX report of your experiment session.</p>
5
+ <p>Generated on: {{ generated_at }}</p>
6
+ <p>The data can also be accessed online at: <a href="{{ investigation_url }}">{{ investigation_url }}</a></p>
7
+ {% endblock %}
File without changes
@@ -0,0 +1,55 @@
1
+ import datetime
2
+ import tempfile
3
+ from pathlib import Path
4
+
5
+ from minedrac.report.mx import generate_mx_report
6
+
7
+ from ..base_tasks.drac_investigation_task import DracInvestigationTask
8
+ from .mx_inputs import MXReportInputs
9
+ from .mx_outputs import MXReportOutputs
10
+
11
+
12
+ class MXReport(
13
+ DracInvestigationTask,
14
+ input_model=MXReportInputs,
15
+ output_model=MXReportOutputs,
16
+ ):
17
+ """Generate the MX report for an experiment session and output its file path"""
18
+
19
+ def process(self) -> None:
20
+ self.log(
21
+ {
22
+ "logs": {
23
+ "msg": "About to generate the report",
24
+ "investigation_id": str(self.investigation_id),
25
+ }
26
+ }
27
+ )
28
+ # The report must outlive the task to be emailed: SendEmail deletes it
29
+ # when its delete_attachments input is set
30
+ output_dir = tempfile.mkdtemp(prefix="mx_report_")
31
+ csv_path = Path(output_dir) / f"mx_report_{self.investigation_id}.csv"
32
+ report_files = generate_mx_report(
33
+ token=self.session_id,
34
+ investigation_ids=str(self.investigation_id),
35
+ csv_file=str(csv_path),
36
+ )
37
+
38
+ if not report_files:
39
+ raise RuntimeError(
40
+ f"No report was generated for investigation {self.investigation_id}"
41
+ )
42
+ self.outputs.report_file = Path(report_files[0])
43
+ self.outputs.context = {
44
+ "investigation_id": self.investigation_id,
45
+ "generated_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
46
+ }
47
+ self.pass_on_session()
48
+ self.log(
49
+ {
50
+ "logs": {
51
+ "msg": "report generated",
52
+ "report_file": Path(report_files[0]).name,
53
+ }
54
+ }
55
+ )
@@ -0,0 +1,5 @@
1
+ from ..base_tasks.drac_investigation_task_inputs import DracInvestigationTaskInputs
2
+
3
+
4
+ class MXReportInputs(DracInvestigationTaskInputs):
5
+ pass
@@ -0,0 +1,17 @@
1
+ from pathlib import Path
2
+ from typing import Any
3
+
4
+ from pydantic import Field
5
+
6
+ from ..base_tasks.drac_task_outputs import DracTaskOutputs
7
+
8
+
9
+ class MXReportOutputs(DracTaskOutputs):
10
+ report_file: Path = Field(
11
+ description="Path of the generated report",
12
+ examples=["/tmp/mx_report_abcd1234/mx_report_12345.csv"], # noqa: S108
13
+ )
14
+ context: dict[str, Any] = Field(
15
+ description="Information about the report, for the email templates",
16
+ examples=[{"investigation_id": 12345, "generated_at": "2026-10-14 10:00:00"}],
17
+ )
File without changes
@@ -0,0 +1,9 @@
1
+ from unittest.mock import patch
2
+
3
+ import pytest
4
+
5
+
6
+ @pytest.fixture
7
+ def callback():
8
+ with patch("ewoksdrac.tasks.base_tasks.drac_task.requests.put") as put:
9
+ yield put
@@ -0,0 +1,100 @@
1
+ from pathlib import Path
2
+ from unittest.mock import patch
3
+
4
+ import pytest
5
+
6
+ from ewoksdrac.tasks.report.mx import MXReport
7
+ from ewoksdrac.tests.utils import fail_on_status
8
+ from ewoksdrac.tests.utils import messages
9
+ from ewoksdrac.tests.utils import statuses
10
+
11
+
12
+ @pytest.fixture
13
+ def minedrac():
14
+ with patch("ewoksdrac.tasks.report.mx.generate_mx_report") as generate:
15
+ generate.side_effect = lambda **kwargs: [kwargs["csv_file"]]
16
+ yield generate
17
+
18
+
19
+ @pytest.fixture
20
+ def mkdtemp(tmp_path):
21
+ with patch("ewoksdrac.tasks.report.mx.tempfile.mkdtemp") as mkdtemp:
22
+ mkdtemp.return_value = str(tmp_path)
23
+ yield mkdtemp
24
+
25
+
26
+ @pytest.fixture
27
+ def inputs(mkdtemp):
28
+ return {
29
+ "investigation_id": "12345",
30
+ "session_id": "token",
31
+ "callback": "https://icatplus.esrf.fr/callback",
32
+ }
33
+
34
+
35
+ def test_report(callback, minedrac, inputs, tmp_path):
36
+ task = MXReport(inputs=inputs)
37
+ task.execute()
38
+
39
+ kwargs = minedrac.call_args.kwargs
40
+ assert kwargs["token"] == inputs["session_id"]
41
+ assert kwargs["investigation_ids"] == "12345"
42
+ assert kwargs["csv_file"] == str(tmp_path / "mx_report_12345.csv")
43
+ outputs = task.get_output_values()
44
+ assert outputs["report_file"] == Path(kwargs["csv_file"])
45
+ assert outputs["context"]["investigation_id"] == 12345
46
+ assert "generated_at" in outputs["context"]
47
+ assert outputs["session_id"] == "token"
48
+ assert outputs["callback"] == "https://icatplus.esrf.fr/callback"
49
+ assert statuses(callback) == ["STARTED", "RUNNING", "FINISHED"]
50
+ assert messages(callback) == [
51
+ "About to generate the report",
52
+ "report generated",
53
+ ]
54
+
55
+
56
+ def test_report_nothing_generated(callback, minedrac, inputs):
57
+ minedrac.side_effect = None
58
+ minedrac.return_value = []
59
+ with pytest.raises(Exception, match="No report was generated"):
60
+ MXReport(inputs=inputs).execute()
61
+ assert statuses(callback) == ["STARTED", "RUNNING", "TERMINATED"]
62
+
63
+
64
+ def test_report_minedrac_fails(callback, minedrac, inputs):
65
+ minedrac.side_effect = ValueError("ICAT unreachable")
66
+ task = MXReport(inputs=inputs)
67
+ with pytest.raises(Exception, match="ICAT unreachable"):
68
+ task.execute()
69
+ assert statuses(callback) == ["STARTED", "RUNNING", "TERMINATED"]
70
+
71
+
72
+ @pytest.mark.parametrize("investigation_id", [12345, "12345"])
73
+ def test_report_investigation_id_int_or_str(
74
+ callback, minedrac, inputs, investigation_id
75
+ ):
76
+ MXReport(inputs={**inputs, "investigation_id": investigation_id}).execute()
77
+ assert minedrac.call_args.kwargs["investigation_ids"] == "12345"
78
+
79
+
80
+ def test_report_invalid_callback(inputs):
81
+ task = MXReport(inputs={**inputs, "callback": "not a url"})
82
+ with pytest.raises(Exception, match="callback"):
83
+ task.execute()
84
+
85
+
86
+ def test_report_finished_callback_fails(callback, minedrac, inputs):
87
+ callback.side_effect = fail_on_status("FINISHED")
88
+ task = MXReport(inputs=inputs)
89
+ with pytest.raises(Exception, match="portal unreachable"):
90
+ task.execute()
91
+ assert statuses(callback) == ["STARTED", "RUNNING", "FINISHED"]
92
+
93
+
94
+ def test_report_terminated_callback_fails(callback, minedrac, inputs):
95
+ minedrac.side_effect = ValueError("ICAT unreachable")
96
+ callback.side_effect = fail_on_status("TERMINATED")
97
+ task = MXReport(inputs=inputs)
98
+ with pytest.raises(Exception, match="portal unreachable"):
99
+ task.execute()
100
+ assert statuses(callback) == ["STARTED", "RUNNING", "TERMINATED"]
@@ -0,0 +1,192 @@
1
+ from pathlib import Path
2
+ from unittest.mock import patch
3
+
4
+ import pytest
5
+ from minedrac.data.session import UserLookupError
6
+
7
+ from ewoksdrac.tasks.mail.send import SendEmail
8
+ from ewoksdrac.tests.utils import messages
9
+ from ewoksdrac.tests.utils import statuses
10
+
11
+ INPUTS = {
12
+ "session_id": "token",
13
+ "callback": "https://icatplus.esrf.fr/callback",
14
+ "subject": "MX report of investigation {{ investigation_id }}",
15
+ "template": "mx_report",
16
+ "context": {"investigation_id": 12345, "generated_at": "2026-10-14 10:00:00"},
17
+ }
18
+
19
+
20
+ @pytest.fixture
21
+ def user_email():
22
+ with patch("ewoksdrac.tasks.mail.send.get_user_email") as get:
23
+ get.return_value = "alice@esrf.fr"
24
+ yield get
25
+
26
+
27
+ @pytest.fixture
28
+ def send_email():
29
+ with patch("ewoksdrac.tasks.mail.send.send_email") as send:
30
+ yield send
31
+
32
+
33
+ def test_send_email(callback, user_email, send_email):
34
+ SendEmail(inputs=INPUTS).execute()
35
+
36
+ user_email.assert_called_once_with("token")
37
+ send_email.assert_called_once()
38
+ recipients, subject, body, attachments = send_email.call_args.args
39
+ assert recipients == ["alice@esrf.fr"]
40
+ assert subject == "MX report of investigation 12345"
41
+ assert attachments == []
42
+ assert "<p>Hello,</p>" in body
43
+ assert "<p>Best regards,<br />The ESRF Data Portal</p>" in body
44
+ assert "Generated on: 2026-10-14 10:00:00" in body
45
+ assert '<a href="https://data.esrf.fr/investigation/12345/datasets">' in body
46
+ assert (
47
+ '<i>This email was automatically generated by the Data Portal. If you have any questions, please contact <a href="mailto:dataportalrequests@esrf.fr">dataportalrequests@esrf.fr</a></i>'
48
+ in body
49
+ )
50
+ assert statuses(callback) == ["STARTED", "RUNNING", "FINISHED"]
51
+ assert messages(callback) == ["About to send the email", "email sent"]
52
+
53
+
54
+ REPORT = "/data/visitor/mx1234/reports/mx_report_12345.csv"
55
+ IMAGE = "/data/visitor/mx1234/reports/plot.png"
56
+
57
+
58
+ @pytest.mark.parametrize(
59
+ "attachments, expected",
60
+ [(REPORT, [REPORT]), ([REPORT], [REPORT]), ([REPORT, IMAGE], [REPORT, IMAGE])],
61
+ )
62
+ def test_send_email_with_attachments(
63
+ callback, user_email, send_email, attachments, expected
64
+ ):
65
+ SendEmail(inputs={**INPUTS, "attachments": attachments}).execute()
66
+
67
+ assert send_email.call_args.args[3] == [Path(f) for f in expected]
68
+
69
+
70
+ def _report(tmp_path):
71
+ report_dir = tmp_path / "mx_report_abcd"
72
+ report_dir.mkdir()
73
+ report = report_dir / "mx_report_12345.csv"
74
+ report.write_text("a,b\n1,2\n")
75
+ return report
76
+
77
+
78
+ def test_send_email_keeps_attachments(callback, user_email, send_email, tmp_path):
79
+ report = _report(tmp_path)
80
+ SendEmail(inputs={**INPUTS, "attachments": str(report)}).execute()
81
+
82
+ assert report.exists()
83
+
84
+
85
+ def test_send_email_deletes_attachments(callback, user_email, send_email, tmp_path):
86
+ report = _report(tmp_path)
87
+ inputs = {**INPUTS, "attachments": str(report), "delete_attachments": True}
88
+ SendEmail(inputs=inputs).execute()
89
+
90
+ assert send_email.call_args.args[3] == [report]
91
+ assert not report.parent.exists()
92
+
93
+
94
+ def test_send_email_deletes_attachments_on_failure(
95
+ callback, user_email, send_email, tmp_path
96
+ ):
97
+ report = _report(tmp_path)
98
+ send_email.side_effect = RuntimeError("SMTP unreachable")
99
+ inputs = {**INPUTS, "attachments": str(report), "delete_attachments": True}
100
+ with pytest.raises(Exception, match="SMTP unreachable"):
101
+ SendEmail(inputs=inputs).execute()
102
+
103
+ assert not report.parent.exists()
104
+
105
+
106
+ def test_send_email_keeps_non_empty_directory(
107
+ callback, user_email, send_email, tmp_path
108
+ ):
109
+ report = _report(tmp_path)
110
+ other = report.parent / "other.txt"
111
+ other.write_text("keep me")
112
+ inputs = {**INPUTS, "attachments": str(report), "delete_attachments": True}
113
+ SendEmail(inputs=inputs).execute()
114
+
115
+ assert not report.exists()
116
+ assert other.exists()
117
+
118
+
119
+ @pytest.mark.parametrize("email", [None, ""])
120
+ def test_send_email_no_user_email(callback, user_email, send_email, email):
121
+ user_email.return_value = email
122
+ with pytest.raises(Exception, match="No email address"):
123
+ SendEmail(inputs=INPUTS).execute()
124
+ send_email.assert_not_called()
125
+ assert statuses(callback) == ["STARTED", "RUNNING", "TERMINATED"]
126
+
127
+
128
+ def test_send_email_user_lookup_fails(callback, user_email, send_email):
129
+ user_email.side_effect = UserLookupError("Could not get session info: expired")
130
+ with pytest.raises(Exception, match="Could not find the user.*expired"):
131
+ SendEmail(inputs=INPUTS).execute()
132
+ send_email.assert_not_called()
133
+ assert statuses(callback) == ["STARTED", "RUNNING", "TERMINATED"]
134
+
135
+
136
+ def test_send_email_fails(callback, user_email, send_email):
137
+ send_email.side_effect = RuntimeError("SMTP unreachable")
138
+ with pytest.raises(Exception, match="SMTP unreachable"):
139
+ SendEmail(inputs=INPUTS).execute()
140
+ assert statuses(callback) == ["STARTED", "RUNNING", "TERMINATED"]
141
+
142
+
143
+ def test_send_email_data_portal_url(callback, user_email, send_email, monkeypatch):
144
+ monkeypatch.setenv("DATA_PORTAL_URL", "https://data.example.org")
145
+ SendEmail(inputs=INPUTS).execute()
146
+ assert (
147
+ "https://data.example.org/investigation/12345/datasets"
148
+ in send_email.call_args.args[2]
149
+ )
150
+
151
+
152
+ def test_send_email_contact_email(callback, user_email, send_email, monkeypatch):
153
+ monkeypatch.setenv("CONTACT_EMAIL", "help@example.org")
154
+ SendEmail(inputs=INPUTS).execute()
155
+ assert 'href="mailto:help@example.org"' in send_email.call_args.args[2]
156
+
157
+
158
+ def test_send_email_signature(callback, user_email, send_email, monkeypatch):
159
+ monkeypatch.setenv("EMAIL_SIGNATURE", "The Data Portal of the facility")
160
+ SendEmail(inputs=INPUTS).execute()
161
+ assert (
162
+ "<p>Best regards,<br />The Data Portal of the facility</p>"
163
+ in send_email.call_args.args[2]
164
+ )
165
+
166
+
167
+ def test_send_email_escapes_context(callback, user_email, send_email):
168
+ context = {**INPUTS["context"], "generated_at": "<script>"}
169
+ SendEmail(inputs={**INPUTS, "context": context}).execute()
170
+ body = send_email.call_args.args[2]
171
+ assert "<script>" not in body
172
+ assert "&lt;script&gt;" in body
173
+
174
+
175
+ def test_send_email_missing_context_variable(callback, user_email, send_email):
176
+ with pytest.raises(Exception, match="generated_at"):
177
+ SendEmail(inputs={**INPUTS, "context": {"investigation_id": 12345}}).execute()
178
+ send_email.assert_not_called()
179
+ assert statuses(callback) == ["STARTED", "RUNNING", "TERMINATED"]
180
+
181
+
182
+ def test_send_email_unknown_template(callback, user_email, send_email):
183
+ with pytest.raises(Exception, match="unknown"):
184
+ SendEmail(inputs={**INPUTS, "template": "unknown"}).execute()
185
+ send_email.assert_not_called()
186
+
187
+
188
+ @pytest.mark.parametrize("missing", ["subject", "template"])
189
+ def test_send_email_missing_input(missing):
190
+ inputs = {k: v for k, v in INPUTS.items() if k != missing}
191
+ with pytest.raises(Exception, match=missing):
192
+ SendEmail(inputs=inputs).execute()
@@ -0,0 +1,65 @@
1
+ from unittest.mock import patch
2
+
3
+ import pytest
4
+
5
+ from ewoksdrac.tasks.mail.smtp import send_email
6
+
7
+
8
+ @pytest.fixture
9
+ def smtp():
10
+ with patch("ewoksdrac.tasks.mail.smtp.smtplib.SMTP") as smtp:
11
+ yield smtp
12
+
13
+
14
+ def sent_message(smtp):
15
+ return smtp.return_value.__enter__.return_value.send_message.call_args.args[0]
16
+
17
+
18
+ def test_send_email(smtp, monkeypatch):
19
+ monkeypatch.setenv("SMTP_HOST", "smtp.esrf.fr")
20
+ monkeypatch.setenv("SMTP_PORT", "587")
21
+ monkeypatch.setenv("SMTP_FROM", "drac@esrf.fr")
22
+ send_email(["alice@esrf.fr", "bob@esrf.fr"], "MX report", "<p>Hello</p>")
23
+
24
+ smtp.assert_called_once_with("smtp.esrf.fr", 587, timeout=30)
25
+ message = sent_message(smtp)
26
+ assert message["Subject"] == "MX report"
27
+ assert message["From"] == "drac@esrf.fr"
28
+ assert message["To"] == "alice@esrf.fr, bob@esrf.fr"
29
+ assert message.get_content_type() == "text/html"
30
+ assert message.get_content().strip() == "<p>Hello</p>"
31
+
32
+
33
+ def test_send_email_defaults(smtp, monkeypatch):
34
+ for name in ("SMTP_HOST", "SMTP_PORT", "SMTP_FROM"):
35
+ monkeypatch.delenv(name, raising=False)
36
+ send_email(["alice@esrf.fr"], "MX report", "<p>Hello</p>")
37
+
38
+ smtp.assert_called_once_with("localhost", 25, timeout=30)
39
+ assert sent_message(smtp)["From"] == "dataportal@esrf.fr"
40
+
41
+
42
+ def test_send_email_with_attachments(smtp, tmp_path):
43
+ report = tmp_path / "mx_report_12345.csv"
44
+ report.write_text("a,b\n1,2\n")
45
+ image = tmp_path / "plot.png"
46
+ image.write_bytes(b"\x89PNG")
47
+ send_email(
48
+ ["alice@esrf.fr"], "MX report", "<p>Hello</p>", [str(report), str(image)]
49
+ )
50
+
51
+ attachments = list(sent_message(smtp).iter_attachments())
52
+ assert [a.get_filename() for a in attachments] == [
53
+ "mx_report_12345.csv",
54
+ "plot.png",
55
+ ]
56
+ assert [a.get_content_type() for a in attachments] == ["text/csv", "image/png"]
57
+ assert attachments[0].get_content() == "a,b\n1,2\n"
58
+
59
+
60
+ def test_send_email_missing_attachment(smtp, tmp_path):
61
+ with pytest.raises(FileNotFoundError):
62
+ send_email(
63
+ ["alice@esrf.fr"], "MX report", "<p>Hello</p>", [str(tmp_path / "nope")]
64
+ )
65
+ smtp.assert_not_called()
@@ -0,0 +1,26 @@
1
+ from unittest.mock import MagicMock
2
+
3
+
4
+ def statuses(callback):
5
+ return [
6
+ c.kwargs["json"]["status"]
7
+ for c in callback.call_args_list
8
+ if "status" in c.kwargs["json"]
9
+ ]
10
+
11
+
12
+ def messages(callback):
13
+ return [
14
+ c.kwargs["json"]["logs"]["msg"]
15
+ for c in callback.call_args_list
16
+ if "msg" in c.kwargs["json"].get("logs", {})
17
+ ]
18
+
19
+
20
+ def fail_on_status(status):
21
+ def put(url, json, timeout):
22
+ if json.get("status") == status:
23
+ raise ConnectionError("portal unreachable")
24
+ return MagicMock()
25
+
26
+ return put
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: ewoksdrac
3
+ Version: 0.1.0
4
+ Summary: Ewoks tasks for the Data Portal
5
+ Author-email: ESRF <dau-pydev@esrf.fr>
6
+ License: # MIT License
7
+
8
+ **Copyright (c) 2026 European Synchrotron Radiation Facility**
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
11
+ this software and associated documentation files (the "Software"), to deal in
12
+ the Software without restriction, including without limitation the rights to
13
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
14
+ the Software, and to permit persons to whom the Software is furnished to do so,
15
+ subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
22
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
23
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
24
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
25
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
26
+
27
+ Project-URL: Homepage, https://gitlab.esrf.fr/workflow/ewoksapps/ewoksdrac/
28
+ Project-URL: Documentation, https://ewoksdrac.readthedocs.io/
29
+ Project-URL: Repository, https://gitlab.esrf.fr/workflow/ewoksapps/ewoksdrac/
30
+ Project-URL: Issues, https://gitlab.esrf.fr/workflow/ewoksapps/ewoksdrac/issues
31
+ Project-URL: Changelog, https://gitlab.esrf.fr/workflow/ewoksapps/ewoksdrac/-/blob/main/CHANGELOG.md
32
+ Keywords: ewoks
33
+ Classifier: Intended Audience :: Science/Research
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Requires-Python: >=3.11
37
+ Description-Content-Type: text/markdown
38
+ License-File: LICENSE.md
39
+ Requires-Dist: ewokscore
40
+ Requires-Dist: requests
41
+ Requires-Dist: pydantic
42
+ Requires-Dist: jinja2
43
+ Requires-Dist: minedrac>=1.1.0
44
+ Provides-Extra: test
45
+ Requires-Dist: pytest>=7; extra == "test"
46
+ Provides-Extra: dev
47
+ Requires-Dist: ewoksdrac[test]; extra == "dev"
48
+ Requires-Dist: ruff; extra == "dev"
49
+ Provides-Extra: doc
50
+ Requires-Dist: ewoksdrac[test]; extra == "doc"
51
+ Requires-Dist: sphinx>=4.5; extra == "doc"
52
+ Requires-Dist: sphinx-autodoc-typehints>=1.16; extra == "doc"
53
+ Requires-Dist: pydata-sphinx-theme; extra == "doc"
54
+ Requires-Dist: sphinx-copybutton; extra == "doc"
55
+ Requires-Dist: ewokssphinx>=2.0; extra == "doc"
56
+ Dynamic: license-file
57
+
58
+ # ewoksdrac
59
+
60
+ Ewoks tasks for the Data Portal
61
+
62
+ ## Documentation
63
+
64
+ https://ewoksdrac.readthedocs.io/
@@ -0,0 +1,34 @@
1
+ LICENSE.md
2
+ README.md
3
+ pyproject.toml
4
+ src/ewoksdrac/__init__.py
5
+ src/ewoksdrac.egg-info/PKG-INFO
6
+ src/ewoksdrac.egg-info/SOURCES.txt
7
+ src/ewoksdrac.egg-info/dependency_links.txt
8
+ src/ewoksdrac.egg-info/entry_points.txt
9
+ src/ewoksdrac.egg-info/requires.txt
10
+ src/ewoksdrac.egg-info/top_level.txt
11
+ src/ewoksdrac/tasks/__init__.py
12
+ src/ewoksdrac/tasks/base_tasks/__init__.py
13
+ src/ewoksdrac/tasks/base_tasks/drac_investigation_task.py
14
+ src/ewoksdrac/tasks/base_tasks/drac_investigation_task_inputs.py
15
+ src/ewoksdrac/tasks/base_tasks/drac_task.py
16
+ src/ewoksdrac/tasks/base_tasks/drac_task_inputs.py
17
+ src/ewoksdrac/tasks/base_tasks/drac_task_outputs.py
18
+ src/ewoksdrac/tasks/mail/__init__.py
19
+ src/ewoksdrac/tasks/mail/render.py
20
+ src/ewoksdrac/tasks/mail/send.py
21
+ src/ewoksdrac/tasks/mail/send_inputs.py
22
+ src/ewoksdrac/tasks/mail/smtp.py
23
+ src/ewoksdrac/tasks/mail/templates/base.html.j2
24
+ src/ewoksdrac/tasks/mail/templates/mx_report.html.j2
25
+ src/ewoksdrac/tasks/report/__init__.py
26
+ src/ewoksdrac/tasks/report/mx.py
27
+ src/ewoksdrac/tasks/report/mx_inputs.py
28
+ src/ewoksdrac/tasks/report/mx_outputs.py
29
+ src/ewoksdrac/tests/__init__.py
30
+ src/ewoksdrac/tests/conftest.py
31
+ src/ewoksdrac/tests/test_mx_report.py
32
+ src/ewoksdrac/tests/test_send_email.py
33
+ src/ewoksdrac/tests/test_smtp.py
34
+ src/ewoksdrac/tests/utils.py
@@ -0,0 +1,3 @@
1
+ [ewoks.tasks.class]
2
+ ewoksdrac.tasks.mail.* = ewoksdrac
3
+ ewoksdrac.tasks.report.* = ewoksdrac
@@ -0,0 +1,20 @@
1
+ ewokscore
2
+ requests
3
+ pydantic
4
+ jinja2
5
+ minedrac>=1.1.0
6
+
7
+ [dev]
8
+ ewoksdrac[test]
9
+ ruff
10
+
11
+ [doc]
12
+ ewoksdrac[test]
13
+ sphinx>=4.5
14
+ sphinx-autodoc-typehints>=1.16
15
+ pydata-sphinx-theme
16
+ sphinx-copybutton
17
+ ewokssphinx>=2.0
18
+
19
+ [test]
20
+ pytest>=7
@@ -0,0 +1 @@
1
+ ewoksdrac