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.
- {ewokssphinx-2.2.0/src/ewokssphinx.egg-info → ewokssphinx-2.2.1}/PKG-INFO +1 -1
- {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/pyproject.toml +1 -1
- {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/ewoks_task_utils.py +75 -19
- {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/sphinx_task_utils.py +7 -33
- {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/tests/conftest.py +1 -0
- {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/tests/dummy_tasks_nested.py +8 -1
- {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/tests/test_task_cache.py +1 -1
- {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/tests/test_task_directive.py +1 -1
- {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/tests/test_task_directive_pydantic.py +6 -5
- ewokssphinx-2.2.1/src/ewokssphinx/tests/test_utils.py +46 -0
- {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/type_utils.py +1 -0
- {ewokssphinx-2.2.0 → ewokssphinx-2.2.1/src/ewokssphinx.egg-info}/PKG-INFO +1 -1
- {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx.egg-info/SOURCES.txt +2 -1
- {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/LICENSE.md +0 -0
- {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/README.md +0 -0
- {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/setup.cfg +0 -0
- {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/__init__.py +0 -0
- {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/ewoks_task_directive.py +0 -0
- {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/ewokssphinx.css +0 -0
- {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/tests/__init__.py +0 -0
- {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/tests/dummy_tasks.py +0 -0
- {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/tests/dummy_tasks_pydantic.py +0 -0
- /ewokssphinx-2.2.0/src/ewokssphinx/tests/test_utils.py → /ewokssphinx-2.2.1/src/ewokssphinx/tests/utils.py +0 -0
- {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx.egg-info/dependency_links.txt +0 -0
- {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx.egg-info/requires.txt +0 -0
- {ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx.egg-info/top_level.txt +0 -0
|
@@ -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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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(
|
|
120
|
-
module_name, _, model_name =
|
|
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
|
-
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
|
@@ -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:
|
|
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]
|
{ewokssphinx-2.2.0 → ewokssphinx-2.2.1}/src/ewokssphinx/tests/test_task_directive_pydantic.py
RENAMED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
from docutils import nodes
|
|
2
2
|
from sphinx.testing import restructuredtext
|
|
3
3
|
|
|
4
|
-
from .
|
|
5
|
-
from .
|
|
6
|
-
from .
|
|
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) ==
|
|
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], "
|
|
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
|
+
]
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|