ewokssphinx 2.1.0__py3-none-any.whl

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.
@@ -0,0 +1,44 @@
1
+ from pathlib import Path
2
+
3
+ from sphinx.application import Sphinx
4
+ from sphinx.util.osutil import copyfile
5
+
6
+ from .ewoks_task_directive import EwoksTaskDirective
7
+
8
+
9
+ def setup(app: Sphinx):
10
+ app.add_directive("ewokstasks", EwoksTaskDirective)
11
+
12
+ app.add_config_value(
13
+ "ewokssphinx_task_cache_path",
14
+ default=None,
15
+ types=str,
16
+ rebuild="env",
17
+ description="JSON path to cache Ewoks task descriptions. "
18
+ "Task discovery overrides the cached descriptions. "
19
+ "Useful when task discovery needs to be done in different python environments.",
20
+ )
21
+
22
+ app.add_config_value(
23
+ "ewokssphinx_ignore_discovery_error",
24
+ default=False,
25
+ types=bool,
26
+ rebuild="env",
27
+ description="Ignore Ewoks task discovery errors. ",
28
+ )
29
+
30
+ app.add_css_file("ewokssphinx.css")
31
+ app.connect("build-finished", _copy_css)
32
+
33
+ return {
34
+ "version": "1.0",
35
+ "parallel_read_safe": False, # Reading source files
36
+ "parallel_write_safe": True, # Writing output files
37
+ }
38
+
39
+
40
+ def _copy_css(app, exception):
41
+ if app.builder.name != "html" or exception:
42
+ return
43
+ static_dir = Path(app.builder.outdir) / "_static"
44
+ copyfile(Path(__file__).parent / "ewokssphinx.css", static_dir / "ewokssphinx.css")
@@ -0,0 +1,45 @@
1
+ import logging
2
+
3
+ from docutils.parsers.rst import directives
4
+ from sphinx.util.docutils import SphinxDirective
5
+
6
+ from .ewoks_task_utils import cached_tasks
7
+ from .ewoks_task_utils import discover_tasks
8
+ from .sphinx_task_utils import task_nodes
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def _task_type_option(argument):
14
+ return directives.choice(argument, ("class", "method", "ppfmethod"))
15
+
16
+
17
+ class EwoksTaskDirective(SphinxDirective):
18
+ required_arguments = 1
19
+ option_spec = {
20
+ "task-type": _task_type_option,
21
+ "ignore-import-error": directives.flag,
22
+ }
23
+
24
+ def run(self):
25
+ module_pattern = self.arguments[0]
26
+ task_type = self.options.get("task-type")
27
+ ignore_import_error = "ignore-import-error" in self.options
28
+
29
+ try:
30
+ local_tasks = discover_tasks(module_pattern, task_type, ignore_import_error)
31
+ except Exception as ex:
32
+ if not self.config.ewokssphinx_ignore_discovery_error:
33
+ raise
34
+ logger.error(f"Task discovery '{module_pattern}' failed: {ex}")
35
+ local_tasks = []
36
+
37
+ tasks = cached_tasks(
38
+ local_tasks,
39
+ self.config.ewokssphinx_task_cache_path,
40
+ module_pattern,
41
+ task_type,
42
+ )
43
+
44
+ task_sections = task_nodes(self, tasks)
45
+ return task_sections
@@ -0,0 +1,144 @@
1
+ import importlib
2
+ import inspect
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from ewokscore.task_discovery import discover_tasks_from_modules
8
+ from pydantic import BaseModel
9
+ from pydantic_core import PydanticUndefined
10
+ from sphinx.util.typing import stringify_annotation
11
+
12
+ from .type_utils import TaskDescription
13
+
14
+
15
+ def discover_tasks(
16
+ module_pattern: str, task_type: str, ignore_import_error: bool
17
+ ) -> list[dict[str, Any]]:
18
+ tasks = []
19
+ for task in discover_tasks_from_modules(
20
+ module_pattern,
21
+ task_type=task_type,
22
+ raise_import_failure=not ignore_import_error,
23
+ ):
24
+ task_name = _get_task_name(task["task_identifier"], task["task_type"])
25
+
26
+ if task.get("input_model"):
27
+ inputs = _parse_pydantic_model(task["input_model"])
28
+ else:
29
+ inputs = _parse_parameter_names(
30
+ task.get("required_input_names", []),
31
+ task.get("optional_input_names", []),
32
+ )
33
+ if task.get("output_model"):
34
+ outputs = _parse_pydantic_model(task["output_model"])
35
+ else:
36
+ outputs = _parse_parameter_names(task.get("output_names", []), [])
37
+
38
+ serialized_task = {
39
+ "task_name": task_name,
40
+ "task_identifier": task["task_identifier"],
41
+ "task_type": task["task_type"],
42
+ "description": _parse_doc(task.get("description")),
43
+ "inputs": inputs,
44
+ "outputs": outputs,
45
+ }
46
+ _ = TaskDescription(**serialized_task)
47
+
48
+ tasks.append(serialized_task)
49
+ return tasks
50
+
51
+
52
+ def cached_tasks(
53
+ local_tasks: list[dict[str, Any]],
54
+ json_cache_path: str | Path | None,
55
+ module_pattern: str,
56
+ task_type: str,
57
+ ) -> list[dict[str, Any]]:
58
+ """Save tasks in cache or load task from cache when local tasks are empty."""
59
+ if not json_cache_path:
60
+ return local_tasks
61
+
62
+ json_cache_path = Path(json_cache_path)
63
+ if json_cache_path.exists():
64
+ cache = json.loads(json_cache_path.read_text(encoding="utf-8"))
65
+ else:
66
+ cache = {}
67
+
68
+ module_pattern_dict = cache.setdefault(module_pattern, {})
69
+ if local_tasks or task_type not in module_pattern_dict:
70
+ module_pattern_dict[task_type] = local_tasks
71
+
72
+ json_cache_path.parent.mkdir(parents=True, exist_ok=True)
73
+ json_cache_path.write_text(json.dumps(cache, indent=2), encoding="utf-8")
74
+
75
+ return module_pattern_dict[task_type]
76
+
77
+
78
+ def _get_task_name(identifier: str, task_type: str) -> str:
79
+ if task_type == "ppfmethod":
80
+ # ppfmethods are all named `run` so use the module name as task name.
81
+ return identifier.split(".")[-2]
82
+ return identifier.split(".")[-1]
83
+
84
+
85
+ def _parse_parameter_names(
86
+ required_names: list[str], optional_names: list[str]
87
+ ) -> list[dict[str, str | list[str]]]:
88
+ parameters = []
89
+ names = required_names + optional_names
90
+ is_required = [True] * len(required_names) + [False] * len(optional_names)
91
+ for name, required in zip(names, is_required):
92
+ parameter = {
93
+ "name": name,
94
+ "annotation": None,
95
+ "required": required,
96
+ "description": None,
97
+ "examples": [],
98
+ "default": None,
99
+ "has_default": False,
100
+ }
101
+ parameters.append(parameter)
102
+ return parameters
103
+
104
+
105
+ def _parse_pydantic_model(model: BaseModel | None) -> list[dict[str, str | list[str]]]:
106
+ parameters = []
107
+ if model is None:
108
+ return parameters
109
+
110
+ model_cls = _import_model(model)
111
+ for name, field_info in model_cls.model_fields.items():
112
+ parameters.append(_parse_pydantic_field(name, field_info))
113
+ return parameters
114
+
115
+
116
+ def _import_model(input_model_qual_name: str) -> type:
117
+ module_name, _, model_name = input_model_qual_name.rpartition(".")
118
+ mod = importlib.import_module(module_name)
119
+ return getattr(mod, model_name)
120
+
121
+
122
+ def _parse_doc(text) -> str:
123
+ return inspect.cleandoc(text) if text else ""
124
+
125
+
126
+ def _parse_pydantic_field(name, field_info) -> dict[str, str | list[str] | None]:
127
+ examples = getattr(field_info, "examples", None)
128
+ default = getattr(field_info, "default", None)
129
+ if default is PydanticUndefined:
130
+ default = None
131
+ has_default = False
132
+ else:
133
+ has_default = True
134
+ examples = [repr(example) for example in examples or []]
135
+
136
+ return {
137
+ "name": name,
138
+ "annotation": stringify_annotation(field_info.annotation),
139
+ "required": field_info.is_required(),
140
+ "description": _parse_doc(field_info.description),
141
+ "examples": examples,
142
+ "default": None if default is None else str(default),
143
+ "has_default": has_default,
144
+ }
@@ -0,0 +1,16 @@
1
+ .ewokssphinx-task .ewokssphinx-field-list {
2
+ padding-left: 0.5em;
3
+ }
4
+
5
+ .ewokssphinx-task .ewokssphinx-default {
6
+ background-color: transparent;
7
+ border: none;
8
+ }
9
+
10
+ .ewokssphinx-task .ewokssphinx-required {
11
+ color: var(--sd-color-danger, red)
12
+ }
13
+
14
+ .ewokssphinx-task .ewokssphinx-examples {
15
+ font-style: italic;
16
+ }
@@ -0,0 +1,192 @@
1
+ from typing import Any
2
+ from typing import Sequence
3
+
4
+ from docutils import nodes
5
+ from sphinx.util.docutils import SphinxDirective
6
+
7
+ from .type_utils import TaskDescription
8
+
9
+
10
+ def task_nodes(
11
+ directive: SphinxDirective,
12
+ tasks: Sequence[dict[str, Any]],
13
+ ) -> list[nodes.section]:
14
+ """
15
+ Convert a list of JSON Ewoks task descriptions into Sphinx/Docutils
16
+ document node trees.
17
+
18
+ Returns a list of <section> nodes.
19
+ """
20
+ return [_task_section(directive, task) for task in tasks]
21
+
22
+
23
+ def _task_section(directive: SphinxDirective, task: dict[str, Any]) -> nodes.section:
24
+ _ = TaskDescription(**task)
25
+
26
+ section = nodes.section(
27
+ "",
28
+ ids=[task["task_identifier"]],
29
+ classes=["ewokssphinx-task"],
30
+ )
31
+
32
+ # Title
33
+ section.append(nodes.title(text=task["task_name"]))
34
+
35
+ # Description
36
+ if task["description"]:
37
+ section.extend(_rst_to_nodes(directive, task["description"]))
38
+
39
+ # Identifier + task type
40
+ section.append(
41
+ nodes.field_list(
42
+ "",
43
+ _field_node("Identifier", nodes.literal(text=task["task_identifier"])),
44
+ _field_node("Task type", nodes.Text(task["task_type"])),
45
+ )
46
+ )
47
+
48
+ # Combine inputs + outputs in a container so that
49
+ # Sphinx does not attach the "simple" CSS class.
50
+ io_def = nodes.container("")
51
+ if task["inputs"]:
52
+ input_parameters = _parameter_nodes(
53
+ directive,
54
+ title="Inputs:",
55
+ css_classes=["field-odd"],
56
+ parameters=task["inputs"],
57
+ is_outputs=False,
58
+ )
59
+ io_def.append(input_parameters)
60
+
61
+ if task["outputs"]:
62
+ output_parameters = _parameter_nodes(
63
+ directive,
64
+ title="Outputs:",
65
+ css_classes=["field-even"],
66
+ parameters=task["outputs"],
67
+ is_outputs=True,
68
+ )
69
+ io_def.append(output_parameters)
70
+
71
+ section.append(
72
+ nodes.definition_list(
73
+ "",
74
+ io_def,
75
+ classes=["ewokssphinx-field-list"],
76
+ )
77
+ )
78
+
79
+ return section
80
+
81
+
82
+ def _field_node(name: str, body: nodes.Element) -> nodes.field:
83
+ """Construct a Sphinx field like: Identifier: my.module.Task"""
84
+ return nodes.field(
85
+ "",
86
+ nodes.field_name(text=name),
87
+ nodes.field_body("", body),
88
+ )
89
+
90
+
91
+ def _term_node(name: str, is_required: bool) -> nodes.term:
92
+ """
93
+ Create a term:
94
+
95
+ my_input
96
+ my_input*
97
+ """
98
+ if not is_required:
99
+ return nodes.term(text=name)
100
+
101
+ # Required input -> add "*"
102
+ return nodes.term(
103
+ "",
104
+ name,
105
+ nodes.strong("", "*", classes=["ewokssphinx-required"]),
106
+ )
107
+
108
+
109
+ def _parameter_nodes(
110
+ directive: SphinxDirective,
111
+ title: str,
112
+ css_classes: list[str],
113
+ parameters: dict[str, str | list[str] | None],
114
+ is_outputs: bool,
115
+ ) -> nodes.definition_list_item:
116
+
117
+ dl = nodes.definition_list()
118
+ for parameter in parameters:
119
+ dl.append(_parameter_node(directive, parameter, is_outputs))
120
+
121
+ return nodes.definition_list_item(
122
+ "",
123
+ nodes.term(text=title, classes=css_classes),
124
+ nodes.definition("", dl),
125
+ classes=["field-list"],
126
+ )
127
+
128
+
129
+ def _parameter_node(
130
+ directive: SphinxDirective, parameter: dict[str, Any], is_output: bool
131
+ ) -> nodes.definition_list_item:
132
+ """
133
+ Full parameter description structure:
134
+
135
+ name* : annotation = default
136
+ Description...
137
+
138
+ Examples:
139
+ 'foo'
140
+ 'bar'
141
+ """
142
+
143
+ # Build the term
144
+ #
145
+ # name* : annotation = default
146
+ #
147
+ is_required = parameter["required"] and not is_output
148
+ term = _term_node(parameter["name"], is_required)
149
+
150
+ if parameter["annotation"]:
151
+ term.extend([nodes.Text(" : "), nodes.literal(text=parameter["annotation"])])
152
+
153
+ has_default = (
154
+ parameter["has_default"] and not parameter["required"] and not is_output
155
+ )
156
+ if has_default:
157
+ term.append(
158
+ nodes.literal(
159
+ text=f"= {parameter['default']}",
160
+ classes=["ewokssphinx-default"],
161
+ )
162
+ )
163
+
164
+ # Description
165
+ definition = nodes.definition()
166
+ if parameter["description"]:
167
+ definition.extend(_rst_to_nodes(directive, parameter["description"]))
168
+
169
+ # Examples
170
+ examples = parameter.get("examples")
171
+ if examples:
172
+ definition.append(_examples_node(examples))
173
+
174
+ return nodes.definition_list_item("", term, definition)
175
+
176
+
177
+ def _examples_node(examples: list[Any]) -> nodes.container:
178
+ bl = nodes.bullet_list()
179
+ for example in examples:
180
+ bl.append(nodes.list_item("", nodes.Text(example)))
181
+
182
+ return nodes.container(
183
+ "",
184
+ nodes.Text("Examples:"),
185
+ bl,
186
+ classes=["ewokssphinx-examples"],
187
+ )
188
+
189
+
190
+ def _rst_to_nodes(directive: SphinxDirective, text: str) -> list[nodes.Node]:
191
+ # TODO: directives like ".. note::" still appear as plain text.
192
+ return directive.parse_text_to_nodes(text)
File without changes
@@ -0,0 +1,109 @@
1
+ import json
2
+
3
+ import pytest
4
+ from sphinx.testing.util import SphinxTestApp
5
+
6
+
7
+ @pytest.fixture(scope="session")
8
+ def app(tmp_path_factory):
9
+ srcdir = tmp_path_factory.mktemp("app")
10
+ with open(srcdir / "conf.py", "w") as conf:
11
+ conf.write('extensions = ["ewokssphinx"]')
12
+ app = SphinxTestApp(
13
+ "html", srcdir=srcdir, docutils_conf="[readers]\ndocinfo_xform: no"
14
+ )
15
+
16
+ return app
17
+
18
+
19
+ @pytest.fixture(scope="session")
20
+ def app_with_cache(tmp_path_factory):
21
+ srcdir = tmp_path_factory.mktemp("app_with_cache")
22
+ json_cache_path = srcdir / "discovered_ewoks_tasks.json"
23
+ with open(srcdir / "conf.py", "w") as conf:
24
+ conf.write('extensions = ["ewokssphinx"]\n')
25
+ conf.write(f'ewokssphinx_task_cache_path = "{json_cache_path.as_posix()}"\n')
26
+ conf.write("ewokssphinx_ignore_discovery_error = True\n")
27
+
28
+ task = {
29
+ "task_name": "MyNonExistingTask",
30
+ "task_identifier": "ewoksnoexisting.tasks.MyNonExistingTask",
31
+ "task_type": "class",
32
+ "description": "My non-existing task documentation",
33
+ "inputs": [
34
+ {
35
+ "annotation": None,
36
+ "default": None,
37
+ "description": None,
38
+ "examples": [],
39
+ "has_default": False,
40
+ "name": "a",
41
+ "required": True,
42
+ },
43
+ {
44
+ "annotation": None,
45
+ "default": None,
46
+ "description": None,
47
+ "examples": [],
48
+ "has_default": False,
49
+ "name": "b",
50
+ "required": True,
51
+ },
52
+ {
53
+ "annotation": None,
54
+ "default": None,
55
+ "description": None,
56
+ "examples": [],
57
+ "has_default": False,
58
+ "name": "c",
59
+ "required": True,
60
+ },
61
+ {
62
+ "annotation": None,
63
+ "default": None,
64
+ "description": None,
65
+ "examples": [],
66
+ "has_default": False,
67
+ "name": "d",
68
+ "required": False,
69
+ },
70
+ {
71
+ "annotation": None,
72
+ "default": None,
73
+ "description": None,
74
+ "examples": [],
75
+ "has_default": False,
76
+ "name": "e",
77
+ "required": False,
78
+ },
79
+ ],
80
+ "outputs": [
81
+ {
82
+ "annotation": None,
83
+ "default": None,
84
+ "description": None,
85
+ "examples": [],
86
+ "has_default": False,
87
+ "name": "error",
88
+ "required": True,
89
+ },
90
+ {
91
+ "annotation": None,
92
+ "default": None,
93
+ "description": None,
94
+ "examples": [],
95
+ "has_default": False,
96
+ "name": "result",
97
+ "required": True,
98
+ },
99
+ ],
100
+ }
101
+ cache = {"ewoksnoexisting.tasks.MyNonExistingTask": {"class": [task]}}
102
+ with open(json_cache_path, "w") as fp:
103
+ json.dump(cache, fp)
104
+
105
+ app = SphinxTestApp(
106
+ "html", srcdir=srcdir, docutils_conf="[readers]\ndocinfo_xform: no"
107
+ )
108
+
109
+ return app
@@ -0,0 +1,33 @@
1
+ from ewokscore import Task
2
+
3
+
4
+ class MyTask(
5
+ Task,
6
+ input_names=["a", "b", "c"],
7
+ optional_input_names=["d", "e"],
8
+ output_names=["result", "error"],
9
+ ):
10
+ """My task documentation"""
11
+
12
+ def run(self):
13
+ pass
14
+
15
+
16
+ def my_other_task(f, g, h=None):
17
+ pass
18
+
19
+
20
+ def run(i, j=None, k=None):
21
+ """Run task documentation"""
22
+
23
+ pass
24
+
25
+
26
+ class _HiddenTask(
27
+ Task,
28
+ input_names=["one"],
29
+ optional_input_names=["two", "three"],
30
+ output_names=["result", "error"],
31
+ ):
32
+ def run(self):
33
+ pass
@@ -0,0 +1,30 @@
1
+ from ewokscore import Task
2
+ from ewokscore.model import BaseInputModel
3
+ from ewokscore.model import BaseOutputModel
4
+ from pydantic import Field
5
+
6
+
7
+ class Inputs(BaseInputModel):
8
+ planet: str = "Earth"
9
+ latitude: int = Field(examples=[-90, 0, 90])
10
+ longitude: float = Field(
11
+ description="Longitude of the GPS point. **In degrees.**", examples=[-90, 0, 90]
12
+ )
13
+
14
+
15
+ class Outputs(BaseOutputModel):
16
+ location: str = Field(
17
+ ..., description="Name of the closest city or location to the given coordinates"
18
+ )
19
+ error: Exception | None
20
+
21
+
22
+ class FindLocation(
23
+ Task,
24
+ input_model=Inputs,
25
+ output_model=Outputs,
26
+ ):
27
+ """Finds a location given the GPS coordinates"""
28
+
29
+ def run(self):
30
+ pass
@@ -0,0 +1,26 @@
1
+ from sphinx.testing import restructuredtext
2
+
3
+ from .test_utils import assert_task_nodes
4
+
5
+
6
+ def test_cache(app_with_cache):
7
+ parsed_nodes = restructuredtext.parse(
8
+ app_with_cache,
9
+ """.. ewokstasks:: ewoksnoexisting.tasks.MyNonExistingTask
10
+ :task-type: class
11
+ """,
12
+ )
13
+ assert len(parsed_nodes) == 4
14
+ _assert_class_task_nodes(parsed_nodes)
15
+
16
+
17
+ def _assert_class_task_nodes(nodes):
18
+ assert_task_nodes(
19
+ nodes,
20
+ identifier="ewoksnoexisting.tasks.MyNonExistingTask",
21
+ doc="""My non-existing task documentation""",
22
+ task_type="class",
23
+ required_inputs=["a", "b", "c"],
24
+ optional_inputs=["d", "e"],
25
+ outputs=["error", "result"],
26
+ )
@@ -0,0 +1,117 @@
1
+ import pytest
2
+ from sphinx.testing import restructuredtext
3
+
4
+ from .test_utils import assert_task_nodes
5
+
6
+
7
+ def test_ewokstasks(app):
8
+ parsed_nodes = restructuredtext.parse(
9
+ app, ".. ewokstasks:: ewokssphinx.tests.dummy_tasks"
10
+ )
11
+
12
+ assert len(parsed_nodes) == 4
13
+ _assert_class_task_nodes(parsed_nodes[0])
14
+ _assert_ppfmethod_nodes(parsed_nodes[1])
15
+ _assert_method_nodes(parsed_nodes[2:])
16
+
17
+
18
+ def test_ewokstasks_class(app):
19
+ parsed_nodes = restructuredtext.parse(
20
+ app,
21
+ """.. ewokstasks:: ewokssphinx.tests.dummy_tasks
22
+ :task-type: class
23
+ """,
24
+ )
25
+
26
+ assert len(parsed_nodes) == 4
27
+ _assert_class_task_nodes(parsed_nodes)
28
+
29
+
30
+ def test_ewokstasks_method(app):
31
+ parsed_nodes = restructuredtext.parse(
32
+ app,
33
+ """.. ewokstasks:: ewokssphinx.tests.dummy_tasks
34
+ :task-type: method
35
+ """,
36
+ )
37
+
38
+ assert len(parsed_nodes) == 2
39
+ _assert_method_nodes(parsed_nodes)
40
+
41
+
42
+ def test_ewokstasks_ppfmethod(app):
43
+ parsed_nodes = restructuredtext.parse(
44
+ app,
45
+ """.. ewokstasks:: ewokssphinx.tests.dummy_tasks
46
+ :task-type: ppfmethod
47
+ """,
48
+ )
49
+
50
+ assert len(parsed_nodes) == 4
51
+ _assert_ppfmethod_nodes(parsed_nodes)
52
+
53
+
54
+ def test_ewokstasks_raised_import_error(app):
55
+ with pytest.raises(ImportError):
56
+ restructuredtext.parse(
57
+ app,
58
+ """.. ewokstasks:: not_existing
59
+ """,
60
+ )
61
+
62
+
63
+ def test_ewokstasks_ignore_import_error(app):
64
+ parsed_nodes = restructuredtext.parse(
65
+ app,
66
+ """.. ewokstasks:: not_existing
67
+ :ignore-import-error:
68
+ """,
69
+ )
70
+
71
+ assert len(parsed_nodes) == 0
72
+
73
+
74
+ def _assert_class_task_nodes(nodes):
75
+ assert_task_nodes(
76
+ nodes,
77
+ identifier="ewokssphinx.tests.dummy_tasks.MyTask",
78
+ doc="""My task documentation""",
79
+ task_type="class",
80
+ required_inputs=["a", "b", "c"],
81
+ optional_inputs=["d", "e"],
82
+ outputs=["error", "result"],
83
+ )
84
+
85
+
86
+ def _assert_method_nodes(nodes):
87
+ assert_task_nodes(
88
+ nodes[0],
89
+ identifier="ewokssphinx.tests.dummy_tasks.my_other_task",
90
+ doc=None,
91
+ task_type="method",
92
+ required_inputs=["f", "g"],
93
+ optional_inputs=["h"],
94
+ outputs=["return_value"],
95
+ )
96
+
97
+ assert_task_nodes(
98
+ nodes[1],
99
+ identifier="ewokssphinx.tests.dummy_tasks.run",
100
+ doc="""Run task documentation""",
101
+ task_type="method",
102
+ required_inputs=["i"],
103
+ optional_inputs=["j", "k"],
104
+ outputs=["return_value"],
105
+ )
106
+
107
+
108
+ def _assert_ppfmethod_nodes(nodes):
109
+ assert_task_nodes(
110
+ nodes,
111
+ identifier="ewokssphinx.tests.dummy_tasks.run",
112
+ doc="""Run task documentation""",
113
+ task_type="ppfmethod",
114
+ required_inputs=["i"],
115
+ optional_inputs=["j", "k"],
116
+ outputs=["return_value"],
117
+ )
@@ -0,0 +1,59 @@
1
+ from docutils import nodes
2
+ from sphinx.testing import restructuredtext
3
+
4
+ from .test_utils import assert_node
5
+ from .test_utils import assert_task_preamble
6
+
7
+
8
+ def test_ewokstasks_with_pydantic_input_model(app):
9
+ parsed_nodes = restructuredtext.parse(
10
+ app,
11
+ """.. ewokstasks:: ewokssphinx.tests.dummy_tasks_pydantic
12
+ :task-type: class
13
+ """,
14
+ )
15
+
16
+ assert len(parsed_nodes) == 4
17
+ assert_task_preamble(
18
+ parsed_nodes,
19
+ "ewokssphinx.tests.dummy_tasks_pydantic.FindLocation",
20
+ """Finds a location given the GPS coordinates""",
21
+ "class",
22
+ )
23
+ definition_list_node = parsed_nodes[-1]
24
+ container_node = definition_list_node[0]
25
+
26
+ input_list = container_node[0]
27
+ input_term, input_definition = input_list
28
+ assert_node(input_term, nodes.term, "Inputs:")
29
+ assert_node(input_definition, nodes.definition)
30
+
31
+ assert_node(input_definition[0][0][0], nodes.term, "planet : str= Earth")
32
+ assert_node(input_definition[0][1][0], nodes.term, "latitude* : int")
33
+ assert_node(input_definition[0][2][0], nodes.term, "longitude* : float")
34
+
35
+ longitude_definition = input_definition[0][2][1]
36
+ longitude_description, longitude_examples = longitude_definition
37
+ longitude_examples_title, longitude_examples = longitude_examples
38
+ assert_node(longitude_description, nodes.paragraph)
39
+ assert_node(longitude_description[0], nodes.Text, "Longitude of the GPS point. ")
40
+ assert_node(longitude_description[1], nodes.strong, "In degrees.")
41
+ assert_node(longitude_examples_title, nodes.Text, "Examples:")
42
+ for longitude_example, value in zip(longitude_examples, [-90, 0, 90]):
43
+ assert_node(longitude_example[0], nodes.Text, repr(value))
44
+
45
+ output_list = container_node[1]
46
+ output_term, output_definition = output_list
47
+ assert_node(output_term, nodes.term, "Outputs:")
48
+ assert_node(output_definition, nodes.definition)
49
+
50
+ assert_node(output_definition[0][0][0], nodes.term, "location : str")
51
+ assert_node(output_definition[0][1][0], nodes.term, "error : Exception | None")
52
+
53
+ location_definition = output_definition[0][0][1]
54
+ (location_definition,) = location_definition
55
+ assert_node(
56
+ location_definition[0],
57
+ nodes.Text,
58
+ "Name of the closest city or location to the given coordinates",
59
+ )
@@ -0,0 +1,71 @@
1
+ from typing import Type
2
+
3
+ from docutils import nodes
4
+ from docutils.nodes import Node
5
+
6
+ from ..ewoks_task_utils import _get_task_name
7
+
8
+
9
+ def assert_node(node, cls: Type[Node], text: str | None = None):
10
+ assert isinstance(node, cls)
11
+ if text is not None:
12
+ assert node.astext() == text
13
+
14
+
15
+ def assert_field_node(node, name: str, value: str):
16
+ assert isinstance(node, nodes.field)
17
+ assert_node(node[0], nodes.field_name, name)
18
+ assert_node(node[1], nodes.field_body, value)
19
+
20
+
21
+ def assert_simple_inputs(input_list, required_inputs, optional_inputs):
22
+ input_term, input_definition = input_list
23
+ assert_node(input_term, nodes.term, "Inputs:")
24
+ assert_node(input_definition, nodes.definition)
25
+
26
+ n_required = len(required_inputs)
27
+ for i in range(n_required):
28
+ assert_node(input_definition[0][i][0], nodes.term, f"{required_inputs[i]}*")
29
+
30
+ n_optional = len(optional_inputs)
31
+ for i in range(n_optional):
32
+ assert_node(
33
+ input_definition[0][n_required + i][0], nodes.term, optional_inputs[i]
34
+ )
35
+
36
+
37
+ def assert_simple_outputs(output_list, outputs):
38
+ output_term, output_definition = output_list
39
+ assert_node(output_term, nodes.term, "Outputs:")
40
+ assert_node(output_definition, nodes.definition)
41
+ for i, output_name in enumerate(outputs):
42
+ assert_node(output_definition[0][i][0], nodes.term, output_name)
43
+
44
+
45
+ def assert_task_nodes(
46
+ parsed_nodes, identifier, doc, task_type, required_inputs, optional_inputs, outputs
47
+ ):
48
+ assert_task_preamble(parsed_nodes, identifier, doc, task_type)
49
+ definition_list_node = parsed_nodes[-1]
50
+ container_node = definition_list_node[0]
51
+ input_list = container_node[0]
52
+ assert_simple_inputs(
53
+ input_list,
54
+ required_inputs=required_inputs,
55
+ optional_inputs=optional_inputs,
56
+ )
57
+ output_list = container_node[1]
58
+ assert_simple_outputs(output_list, outputs=outputs)
59
+
60
+
61
+ def assert_task_preamble(parsed_nodes, identifier, doc, task_type):
62
+ name = _get_task_name(identifier, task_type)
63
+ assert_node(parsed_nodes[0], nodes.title, name)
64
+ if doc is not None:
65
+ assert_node(parsed_nodes[1], nodes.paragraph, doc)
66
+ field_list_nodes = parsed_nodes[2]
67
+ else:
68
+ field_list_nodes = parsed_nodes[1]
69
+ assert_node(field_list_nodes, nodes.field_list)
70
+ assert_field_node(field_list_nodes[0], name="Identifier", value=identifier)
71
+ assert_field_node(field_list_nodes[1], name="Task type", value=task_type)
@@ -0,0 +1,20 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class TaskParameter(BaseModel, extra="forbid"):
5
+ name: str
6
+ annotation: str | None
7
+ required: bool
8
+ description: str | None
9
+ examples: list[str]
10
+ default: str | None
11
+ has_default: bool
12
+
13
+
14
+ class TaskDescription(BaseModel, extra="forbid"):
15
+ task_name: str
16
+ task_identifier: str
17
+ task_type: str
18
+ description: str
19
+ inputs: list[TaskParameter]
20
+ outputs: list[TaskParameter]
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: ewokssphinx
3
+ Version: 2.1.0
4
+ Summary: Ewoks Sphinx directives
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://github.com/ewoks-kit/ewokssphinx/
28
+ Project-URL: Documentation, https://github.com/ewoks-kit/ewokssphinx/blob/main/README.md
29
+ Project-URL: Repository, https://github.com/ewoks-kit/ewokssphinx/
30
+ Project-URL: Issues, https://github.com/ewoks-kit/ewokssphinx/issues
31
+ Project-URL: Changelog, https://github.com/ewoks-kit/ewokssphinx/blob/main/CHANGELOG.md
32
+ Keywords: ewoks,sphinx
33
+ Classifier: Intended Audience :: Science/Research
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Framework :: Sphinx :: Extension
37
+ Requires-Python: >=3.10
38
+ Description-Content-Type: text/markdown
39
+ License-File: LICENSE.md
40
+ Requires-Dist: sphinx
41
+ Requires-Dist: ewokscore>=3.1.0
42
+ Requires-Dist: pydantic
43
+ Provides-Extra: test
44
+ Requires-Dist: pytest>=7; extra == "test"
45
+ Provides-Extra: dev
46
+ Requires-Dist: ewokssphinx[test]; extra == "dev"
47
+ Requires-Dist: ruff; extra == "dev"
48
+ Provides-Extra: doc
49
+ Requires-Dist: ewokssphinx[test]; extra == "doc"
50
+ Requires-Dist: myst-parser; extra == "doc"
51
+ Requires-Dist: pydata_sphinx_theme; extra == "doc"
52
+ Requires-Dist: sphinx-design; extra == "doc"
53
+ Dynamic: license-file
54
+
55
+ # ewokssphinx
56
+
57
+
58
+ [![Pipeline](https://github.com/ewoks-kit/ewokssphinx/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/ewoks-kit/ewokssphinx/actions/workflows/test.yml)
59
+ [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
60
+ [![License](https://img.shields.io/github/license/ewoks-kit/ewokssphinx)](https://github.com/ewoks-kit/ewokssphinx/blob/main/LICENSE.md)
61
+ [![Coverage](https://codecov.io/gh/ewoks-kit/ewokssphinx/branch/main/graph/badge.svg)](https://codecov.io/gh/ewoks-kit/ewokssphinx)
62
+ [![Docs](https://readthedocs.org/projects/ewokssphinx/badge/?version=latest)](https://ewokssphinx.readthedocs.io/en/latest/?badge=latest)
63
+ [![PyPI](https://img.shields.io/pypi/v/ewokssphinx)](https://pypi.org/project/ewokssphinx/)
64
+
65
+ A set of Sphinx directives for Ewoks
66
+
67
+ ## Quick start
68
+
69
+ ```bash
70
+ pip install ewokssphinx
71
+ ```
72
+
73
+ Then, add `ewokssphinx` to the list of `extensions` in the Sphinx configuration file:
74
+
75
+ ```python
76
+ # conf.py
77
+
78
+ ...
79
+
80
+ extensions = [
81
+ ...,
82
+ "ewokssphinx"
83
+ ]
84
+ ```
85
+
86
+ ## Documentation
87
+
88
+ [Full documentation](https://ewokssphinx.readthedocs.io/en/latest) with [examples](https://ewokssphinx.readthedocs.io/en/latest/examples.html)
@@ -0,0 +1,19 @@
1
+ ewokssphinx/__init__.py,sha256=PBgd9wyQEh2NyHaB9fD9lyYH7Nlk_0pPfMqOPjimayU,1290
2
+ ewokssphinx/ewoks_task_directive.py,sha256=Fs1nAvCbgeC7N1LBCycSzBYhlxa9swDlf9M4R2W8Mxg,1330
3
+ ewokssphinx/ewoks_task_utils.py,sha256=xyKDU-LicWt-xKEw4W52i_BTiFk5k80UdfYAIcYwfcs,4656
4
+ ewokssphinx/ewokssphinx.css,sha256=_K6rcN2skZOrG535NmmbB6Mgo9KZmR_vn11il-Qj9Rc,321
5
+ ewokssphinx/sphinx_task_utils.py,sha256=_yCrxP-8gSXYcLUB4gxW9nRfLZnJVhTpA5t0bWQ356k,4948
6
+ ewokssphinx/type_utils.py,sha256=tZ5mjikkaf9SS_epEO1r8-hVu2jrMMNHKk1ri1pcSBw,440
7
+ ewokssphinx/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ ewokssphinx/tests/conftest.py,sha256=oFJKQ5X0WYsPbrPUkLA4zzs-urlDblF_7MG4BeqGK-c,3287
9
+ ewokssphinx/tests/dummy_tasks.py,sha256=AzOcfyVJXyiHiv9oRg6dqD8iHftkUXaK67ty3cID4SY,518
10
+ ewokssphinx/tests/dummy_tasks_pydantic.py,sha256=du6r2zpdApNr36IbrRQPyYUqqsyvkDj-sfOa4fVaZTM,728
11
+ ewokssphinx/tests/test_task_cache.py,sha256=pIGra8-BxBusjpmtupEZrLnfZgCt5GjRIM5tK0_YVlo,723
12
+ ewokssphinx/tests/test_task_directive.py,sha256=V06dfDW-1Dx0Jw5dIXn2SBmPtyP9xzdzKWJUoSQQvuM,2884
13
+ ewokssphinx/tests/test_task_directive_pydantic.py,sha256=Jie1yE0Nhdo2kLRzsBpEmyIzYGbyokh1MakvVTerxMM,2328
14
+ ewokssphinx/tests/test_utils.py,sha256=HzU5vr2ENZ5_2TJA8-eJ5yeLb-PNJ6avFdKo_fvyruI,2469
15
+ ewokssphinx-2.1.0.dist-info/licenses/LICENSE.md,sha256=jhnnemwVcGW_Dc_FbaaD12x2Vz9mIqjX_MtyKrjw3gQ,1102
16
+ ewokssphinx-2.1.0.dist-info/METADATA,sha256=aLlPLLMUYuSY5-vxARrSy7EDXQttd7-PVdgd0m08IJg,3777
17
+ ewokssphinx-2.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
18
+ ewokssphinx-2.1.0.dist-info/top_level.txt,sha256=uj9wRIAtZZM_KtQWHn9rhn5tfZraEGwqMuNeDeV9AWY,12
19
+ ewokssphinx-2.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ ewokssphinx