ewokssphinx 2.2.0__tar.gz → 2.2.1__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 (26) hide show
  1. {ewokssphinx-2.2.0/src/ewokssphinx.egg-info → ewokssphinx-2.2.1}/PKG-INFO +1 -1
  2. {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/pyproject.toml +1 -1
  3. {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/ewoks_task_utils.py +75 -19
  4. {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/sphinx_task_utils.py +7 -33
  5. {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/tests/conftest.py +1 -0
  6. {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/tests/dummy_tasks_nested.py +8 -1
  7. {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/tests/test_task_cache.py +1 -1
  8. {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/tests/test_task_directive.py +1 -1
  9. {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/tests/test_task_directive_pydantic.py +6 -5
  10. ewokssphinx-2.2.1/src/ewokssphinx/tests/test_utils.py +46 -0
  11. {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/type_utils.py +1 -0
  12. {ewokssphinx-2.2.0 → ewokssphinx-2.2.1/src/ewokssphinx.egg-info}/PKG-INFO +1 -1
  13. {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx.egg-info/SOURCES.txt +2 -1
  14. {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/LICENSE.md +0 -0
  15. {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/README.md +0 -0
  16. {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/setup.cfg +0 -0
  17. {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/__init__.py +0 -0
  18. {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/ewoks_task_directive.py +0 -0
  19. {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/ewokssphinx.css +0 -0
  20. {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/tests/__init__.py +0 -0
  21. {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/tests/dummy_tasks.py +0 -0
  22. {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/tests/dummy_tasks_pydantic.py +0 -0
  23. /ewokssphinx-2.2.0/src/ewokssphinx/tests/test_utils.py → /ewokssphinx-2.2.1/src/ewokssphinx/tests/utils.py +0 -0
  24. {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx.egg-info/dependency_links.txt +0 -0
  25. {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx.egg-info/requires.txt +0 -0
  26. {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ewokssphinx
3
- Version: 2.2.0
3
+ Version: 2.2.1
4
4
  Summary: Ewoks Sphinx directives
5
5
  Author-email: ESRF <dau-pydev@esrf.fr>
6
6
  License: # MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ewokssphinx"
7
- version = "2.2.0"
7
+ version = "2.2.1"
8
8
  keywords = ['ewoks', 'sphinx']
9
9
  authors = [{ name = "ESRF", email = "dau-pydev@esrf.fr" }]
10
10
  description = "Ewoks Sphinx directives"
@@ -1,11 +1,19 @@
1
1
  import importlib
2
2
  import inspect
3
3
  import json
4
+ from functools import reduce
4
5
  from pathlib import Path
6
+ from typing import Annotated
5
7
  from typing import Any
8
+ from typing import Iterator
9
+ from typing import Type
10
+ from typing import get_args
11
+ from typing import get_origin
6
12
 
7
13
  from ewokscore.task_discovery import discover_tasks_from_modules
14
+ from ewoksutils.import_utils import qualname
8
15
  from pydantic import BaseModel
16
+ from pydantic.fields import FieldInfo
9
17
  from pydantic_core import PydanticUndefined
10
18
  from sphinx.util.typing import stringify_annotation
11
19
 
@@ -24,18 +32,26 @@ def discover_tasks(
24
32
  ):
25
33
  task_name = _get_task_name(task["task_identifier"], task["task_type"])
26
34
 
27
- if task.get("input_model"):
28
- inputs = parse_pydantic_model(task["input_model"])
35
+ input_model_name = task.get("input_model")
36
+ if input_model_name is not None:
37
+ input_model = _import_model(input_model_name)
38
+ inputs = _parse_pydantic_model(input_model)
29
39
  else:
40
+ input_model = None
30
41
  inputs = _parse_parameter_names(
31
42
  task.get("required_input_names", []),
32
43
  task.get("optional_input_names", []),
33
44
  )
34
- if task.get("output_model"):
35
- outputs = parse_pydantic_model(task["output_model"])
45
+
46
+ output_model_name = task.get("output_model")
47
+ if output_model_name:
48
+ output_model = _import_model(output_model_name)
49
+ outputs = _parse_pydantic_model(output_model)
36
50
  else:
51
+ output_model = None
37
52
  outputs = _parse_parameter_names(task.get("output_names", []), [])
38
53
 
54
+ submodels = _extract_submodels(input_model, output_model)
39
55
  serialized_task = {
40
56
  "task_name": task_name,
41
57
  "task_identifier": task["task_identifier"],
@@ -43,6 +59,7 @@ def discover_tasks(
43
59
  "description": _parse_doc(task.get("description")),
44
60
  "inputs": inputs,
45
61
  "outputs": outputs,
62
+ "submodels": {k: _parse_pydantic_model(v) for k, v in submodels.items()},
46
63
  }
47
64
  _ = TaskDescription(**serialized_task)
48
65
 
@@ -103,30 +120,27 @@ def _parse_parameter_names(
103
120
  return parameters
104
121
 
105
122
 
106
- def parse_pydantic_model(model: str | None) -> list[ParameterDescription]:
107
- parameters = []
108
- if model is None:
109
- return parameters
110
-
111
- model_cls = _import_model(model)
112
- if not issubclass(model_cls, BaseModel):
113
- raise ValueError("Not a pydantic model")
114
- for name, field_info in model_cls.model_fields.items():
115
- parameters.append(_parse_pydantic_field(name, field_info))
116
- return parameters
123
+ def _parse_pydantic_model(model: Type[BaseModel]) -> list[ParameterDescription]:
124
+ return [
125
+ _parse_pydantic_field(name, field_info)
126
+ for name, field_info in model.model_fields.items()
127
+ ]
117
128
 
118
129
 
119
- def _import_model(input_model_qual_name: str) -> type:
120
- module_name, _, model_name = input_model_qual_name.rpartition(".")
130
+ def _import_model(model_qual_name: str) -> Type[BaseModel]:
131
+ module_name, _, model_name = model_qual_name.rpartition(".")
121
132
  mod = importlib.import_module(module_name)
122
- return getattr(mod, model_name)
133
+ model = getattr(mod, model_name)
134
+ if not issubclass(model, BaseModel):
135
+ raise ValueError("Not a pydantic model")
136
+ return model
123
137
 
124
138
 
125
139
  def _parse_doc(text) -> str:
126
140
  return inspect.cleandoc(text) if text else ""
127
141
 
128
142
 
129
- def _parse_pydantic_field(name, field_info) -> ParameterDescription:
143
+ def _parse_pydantic_field(name: str, field_info: FieldInfo) -> ParameterDescription:
130
144
  examples = getattr(field_info, "examples", None)
131
145
  default = getattr(field_info, "default", None)
132
146
  if default is PydanticUndefined:
@@ -145,3 +159,45 @@ def _parse_pydantic_field(name, field_info) -> ParameterDescription:
145
159
  "default": None if default is None else str(default),
146
160
  "has_default": has_default,
147
161
  }
162
+
163
+
164
+ def _extract_submodels(*raw_annotations: Any) -> dict[str, Type[BaseModel]]:
165
+ annotations = tuple(a for a in raw_annotations if a is not None)
166
+
167
+ if not annotations:
168
+ return {}
169
+
170
+ # Iterate over the union to have depth-first ordering
171
+ union_of_annotations = reduce(lambda a, b: a | b, annotations)
172
+
173
+ return {
174
+ qualname(model_cls): model_cls
175
+ for model_cls in _iter_models(union_of_annotations, set())
176
+ # Remove original models to get only submodels
177
+ if model_cls not in annotations
178
+ }
179
+
180
+
181
+ def _iter_models(annotation: Any, seen: set[int]) -> Iterator[Type[BaseModel]]:
182
+ annotation_id = id(annotation)
183
+ if annotation_id in seen:
184
+ return
185
+ seen.add(annotation_id)
186
+
187
+ if isinstance(annotation, type) and issubclass(annotation, BaseModel):
188
+ yield annotation
189
+
190
+ # Recurse into model fields
191
+ for field in annotation.model_fields.values():
192
+ yield from _iter_models(field.annotation, seen)
193
+
194
+ origin = get_origin(annotation)
195
+ # Handle Annotated[T, ...]
196
+ if origin is Annotated:
197
+ base_type, *_ = get_args(annotation)
198
+ yield from _iter_models(base_type, seen)
199
+ return
200
+
201
+ # Recurse into generic arguments (Union, list, dict, etc.)
202
+ for arg in get_args(annotation):
203
+ yield from _iter_models(arg, seen)
@@ -4,7 +4,6 @@ from typing import Sequence
4
4
  from docutils import nodes
5
5
  from sphinx.util.docutils import SphinxDirective
6
6
 
7
- from .ewoks_task_utils import parse_pydantic_model
8
7
  from .type_utils import ParameterDescription
9
8
  from .type_utils import TaskDescription
10
9
 
@@ -193,43 +192,18 @@ def _rst_to_nodes(directive: SphinxDirective, text: str) -> list[nodes.Node]:
193
192
  return directive.parse_text_to_nodes(text)
194
193
 
195
194
 
196
- def _get_annotations(tasks: Sequence[dict[str, Any]]):
197
- annotations: set[str] = set()
198
-
199
- for task_dict in tasks:
200
- task = TaskDescription(**task_dict)
201
- parameters = task.inputs + task.outputs
202
- for parameter in parameters:
203
- annotation = parameter.annotation
204
- if annotation is None:
205
- continue
206
- if "|" in annotation:
207
- for a in annotation.split("|"):
208
- annotations.add(a.strip())
209
- else:
210
- annotations.add(annotation)
211
-
212
- # Enforce consistent ordering
213
- return sorted(annotations)
214
-
215
-
216
195
  def additional_model_nodes(
217
196
  directive: SphinxDirective, tasks: Sequence[dict[str, Any]]
218
197
  ) -> nodes.section | None:
219
- annotations = _get_annotations(tasks)
220
-
221
198
  model_sections = []
222
- for annotation in annotations:
223
- try:
224
- model = parse_pydantic_model(annotation)
225
- except (ValueError, ModuleNotFoundError):
226
- # Not a Pydantic model
227
- continue
228
- section = nodes.section("", ids=[annotation])
229
- section.append(nodes.title("", annotation.split(".")[-1]))
199
+ for task in tasks:
200
+ for model_name, model_fields in task["submodels"].items():
201
+ section = nodes.section("", ids=[model_name])
202
+ section.append(nodes.title("", model_name.split(".")[-1]))
203
+
204
+ section.append(_field_list(directive, {"fields": model_fields}))
205
+ model_sections.append(section)
230
206
 
231
- section.append(_field_list(directive, {"fields": model}))
232
- model_sections.append(section)
233
207
  if not model_sections:
234
208
  return None
235
209
 
@@ -97,6 +97,7 @@ def app_with_cache(tmp_path_factory):
97
97
  "required": True,
98
98
  },
99
99
  ],
100
+ "submodels": dict(),
100
101
  }
101
102
  cache = {"ewoksnoexisting.tasks.MyNonExistingTask": {"class": [task]}}
102
103
  with open(json_cache_path, "w") as fp:
@@ -1,3 +1,4 @@
1
+ from typing import Annotated
1
2
  from typing import Literal
2
3
 
3
4
  from ewokscore import Task
@@ -7,8 +8,14 @@ from pydantic import BaseModel
7
8
  from pydantic import Field
8
9
 
9
10
 
11
+ class Planet(BaseModel):
12
+ name: str
13
+ radius: Annotated[float, Field(gt=0)]
14
+ gaseous: bool
15
+
16
+
10
17
  class Coordinates(BaseModel):
11
- planet: str = "Earth"
18
+ planet: Planet
12
19
  latitude: int = Field(examples=[-90, 0, 90])
13
20
  longitude: float = Field(
14
21
  description="Longitude of the GPS point. **In degrees.**", examples=[-90, 0, 90]
@@ -1,6 +1,6 @@
1
1
  from sphinx.testing import restructuredtext
2
2
 
3
- from .test_utils import assert_task_nodes
3
+ from .utils import assert_task_nodes
4
4
 
5
5
 
6
6
  def test_cache(app_with_cache):
@@ -1,7 +1,7 @@
1
1
  import pytest
2
2
  from sphinx.testing import restructuredtext
3
3
 
4
- from .test_utils import assert_task_nodes
4
+ from .utils import assert_task_nodes
5
5
 
6
6
 
7
7
  def test_ewokstasks(app):
@@ -1,9 +1,9 @@
1
1
  from docutils import nodes
2
2
  from sphinx.testing import restructuredtext
3
3
 
4
- from .test_utils import assert_node
5
- from .test_utils import assert_section
6
- from .test_utils import assert_task_preamble
4
+ from .utils import assert_node
5
+ from .utils import assert_section
6
+ from .utils import assert_task_preamble
7
7
 
8
8
 
9
9
  def test_ewokstasks_with_pydantic_input_model(app):
@@ -72,7 +72,8 @@ def test_ewokstasks_with_nested_pydantic_model(app):
72
72
  task_section, model_section = parsed_nodes
73
73
  assert_section(task_section, "ComputeTimeToGo")
74
74
 
75
- assert len(model_section) == 3
75
+ assert len(model_section) == 4
76
76
  assert_section(model_section, "Additional models")
77
77
  assert_section(model_section[1], "Coordinates")
78
- assert_section(model_section[2], "Location")
78
+ assert_section(model_section[2], "Planet")
79
+ assert_section(model_section[3], "Location")
@@ -0,0 +1,46 @@
1
+ from pydantic import BaseModel
2
+
3
+ from ewokssphinx.ewoks_task_utils import _extract_submodels
4
+
5
+
6
+ class Type1(BaseModel):
7
+ pass
8
+
9
+
10
+ class SubType1(BaseModel):
11
+ pass
12
+
13
+
14
+ class SubSubType(BaseModel):
15
+ pass
16
+
17
+
18
+ class SubType2(BaseModel):
19
+ var: dict[str, SubSubType]
20
+
21
+
22
+ class Type2(BaseModel):
23
+ var: tuple[SubType2 | SubType1]
24
+
25
+
26
+ Annotation = Type1 | list[SubType2] | dict[str, list[int | Type2]]
27
+
28
+
29
+ def test_extract_type():
30
+
31
+ assert list(_extract_submodels(Annotation).keys()) == [
32
+ "ewokssphinx.tests.test_utils.Type1",
33
+ "ewokssphinx.tests.test_utils.SubType2",
34
+ "ewokssphinx.tests.test_utils.SubSubType",
35
+ "ewokssphinx.tests.test_utils.Type2",
36
+ "ewokssphinx.tests.test_utils.SubType1",
37
+ ]
38
+
39
+
40
+ def test_extract_type_inputs():
41
+ from ewokssphinx.tests.dummy_tasks_nested import Inputs
42
+
43
+ assert list(_extract_submodels(Inputs).keys()) == [
44
+ "ewokssphinx.tests.dummy_tasks_nested.Coordinates",
45
+ "ewokssphinx.tests.dummy_tasks_nested.Planet",
46
+ ]
@@ -18,6 +18,7 @@ class TaskDescription(BaseModel, extra="forbid"):
18
18
  description: str
19
19
  inputs: list[TaskParameter]
20
20
  outputs: list[TaskParameter]
21
+ submodels: dict[str, list[TaskParameter]]
21
22
 
22
23
 
23
24
  ParameterDescription = dict[str, str | list[str] | bool | None]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ewokssphinx
3
- Version: 2.2.0
3
+ Version: 2.2.1
4
4
  Summary: Ewoks Sphinx directives
5
5
  Author-email: ESRF <dau-pydev@esrf.fr>
6
6
  License: # MIT License
@@ -20,4 +20,5 @@ src/ewokssphinx/tests/dummy_tasks_pydantic.py
20
20
  src/ewokssphinx/tests/test_task_cache.py
21
21
  src/ewokssphinx/tests/test_task_directive.py
22
22
  src/ewokssphinx/tests/test_task_directive_pydantic.py
23
- src/ewokssphinx/tests/test_utils.py
23
+ src/ewokssphinx/tests/test_utils.py
24
+ src/ewokssphinx/tests/utils.py
File without changes
File without changes
File without changes