datahub-yaml-source 0.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.
- datahub_yaml_source/__init__.py +11 -0
- datahub_yaml_source/builders/__init__.py +0 -0
- datahub_yaml_source/builders/application.py +68 -0
- datahub_yaml_source/builders/assertion.py +254 -0
- datahub_yaml_source/builders/chart.py +41 -0
- datahub_yaml_source/builders/common.py +489 -0
- datahub_yaml_source/builders/container.py +145 -0
- datahub_yaml_source/builders/dashboard.py +45 -0
- datahub_yaml_source/builders/data_flow_job.py +95 -0
- datahub_yaml_source/builders/data_process_instance.py +64 -0
- datahub_yaml_source/builders/data_product.py +31 -0
- datahub_yaml_source/builders/dataset.py +147 -0
- datahub_yaml_source/builders/document.py +77 -0
- datahub_yaml_source/builders/domain.py +73 -0
- datahub_yaml_source/builders/glossary.py +115 -0
- datahub_yaml_source/builders/incident.py +74 -0
- datahub_yaml_source/builders/ml.py +271 -0
- datahub_yaml_source/builders/platform.py +21 -0
- datahub_yaml_source/builders/query.py +49 -0
- datahub_yaml_source/builders/raw_aspect.py +236 -0
- datahub_yaml_source/builders/semantic.py +150 -0
- datahub_yaml_source/builders/software.py +253 -0
- datahub_yaml_source/builders/structured_property.py +54 -0
- datahub_yaml_source/builders/tag.py +22 -0
- datahub_yaml_source/loader.py +404 -0
- datahub_yaml_source/models.py +1812 -0
- datahub_yaml_source/urns.py +315 -0
- datahub_yaml_source/yaml_source.py +605 -0
- datahub_yaml_source/yaml_source_config.py +200 -0
- datahub_yaml_source/yaml_source_report.py +81 -0
- datahub_yaml_source-0.1.0.dist-info/METADATA +124 -0
- datahub_yaml_source-0.1.0.dist-info/RECORD +36 -0
- datahub_yaml_source-0.1.0.dist-info/WHEEL +5 -0
- datahub_yaml_source-0.1.0.dist-info/entry_points.txt +2 -0
- datahub_yaml_source-0.1.0.dist-info/licenses/LICENSE +190 -0
- datahub_yaml_source-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError
|
|
2
|
+
from importlib.metadata import version as _version
|
|
3
|
+
|
|
4
|
+
from datahub_yaml_source.yaml_source import YamlSource
|
|
5
|
+
|
|
6
|
+
__all__ = ["YamlSource"]
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
__version__ = _version("datahub-yaml-source")
|
|
10
|
+
except PackageNotFoundError: # running from a source tree with no install
|
|
11
|
+
__version__ = "0.0.0"
|
|
File without changes
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from collections.abc import Iterable
|
|
2
|
+
|
|
3
|
+
from datahub.ingestion.api.workunit import MetadataWorkUnit
|
|
4
|
+
from datahub.metadata.schema_classes import (
|
|
5
|
+
ApplicationLineageClass,
|
|
6
|
+
ApplicationPropertiesClass,
|
|
7
|
+
EdgeClass,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
from datahub_yaml_source.builders.common import common_aspect_mcps, mcp_workunit
|
|
11
|
+
from datahub_yaml_source.models import ApplicationDoc, ApplicationLineageEdgeDoc
|
|
12
|
+
from datahub_yaml_source.urns import ReferenceIndex, api_urn, application_urn, dataset_urn
|
|
13
|
+
from datahub_yaml_source.yaml_source_report import YamlSourceReport
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _application_lineage_edges(
|
|
17
|
+
edges: list[ApplicationLineageEdgeDoc] | None, direction: str, context: str
|
|
18
|
+
) -> list[EdgeClass] | None:
|
|
19
|
+
if not edges:
|
|
20
|
+
return None
|
|
21
|
+
result = []
|
|
22
|
+
for i, edge in enumerate(edges):
|
|
23
|
+
if bool(edge.api) == bool(edge.dataset):
|
|
24
|
+
raise ValueError(
|
|
25
|
+
f"{context} applicationLineage.{direction}[{i}] must set "
|
|
26
|
+
f"exactly one of 'api' or 'dataset'"
|
|
27
|
+
)
|
|
28
|
+
if edge.api:
|
|
29
|
+
destination_urn = api_urn(edge.api)
|
|
30
|
+
else:
|
|
31
|
+
assert edge.dataset is not None # guaranteed by the exactly-one check above
|
|
32
|
+
destination_urn = dataset_urn(edge.dataset)
|
|
33
|
+
result.append(EdgeClass(destinationUrn=destination_urn))
|
|
34
|
+
return result
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_application(
|
|
38
|
+
doc: ApplicationDoc, index: ReferenceIndex, report: YamlSourceReport
|
|
39
|
+
) -> Iterable[MetadataWorkUnit]:
|
|
40
|
+
context = f"APPLICATION '{doc.id}'"
|
|
41
|
+
entity_urn = application_urn(doc.id)
|
|
42
|
+
|
|
43
|
+
# Resolve (and validate) applicationLineage before yielding anything --
|
|
44
|
+
# `get_workunits_internal` streams work units straight to the sink as they're
|
|
45
|
+
# yielded, so raising after an earlier yield would leave a partially-emitted
|
|
46
|
+
# entity instead of cleanly skipping the whole malformed document (see
|
|
47
|
+
# `build_document`'s equivalent native/external check for the same reason).
|
|
48
|
+
input_edges = output_edges = None
|
|
49
|
+
if doc.applicationLineage:
|
|
50
|
+
input_edges = _application_lineage_edges(
|
|
51
|
+
doc.applicationLineage.consumes, "consumes", context
|
|
52
|
+
)
|
|
53
|
+
output_edges = _application_lineage_edges(
|
|
54
|
+
doc.applicationLineage.produces, "produces", context
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
yield mcp_workunit(
|
|
58
|
+
entity_urn,
|
|
59
|
+
ApplicationPropertiesClass(name=doc.name, description=doc.description),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if input_edges or output_edges:
|
|
63
|
+
yield mcp_workunit(
|
|
64
|
+
entity_urn,
|
|
65
|
+
ApplicationLineageClass(inputEdges=input_edges, outputEdges=output_edges),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
yield from common_aspect_mcps(entity_urn, doc, index, report, context)
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections.abc import Iterable
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from datahub.emitter.mce_builder import make_schema_field_urn
|
|
6
|
+
from datahub.ingestion.api.workunit import MetadataWorkUnit
|
|
7
|
+
from datahub.metadata.schema_classes import (
|
|
8
|
+
AssertionActionClass,
|
|
9
|
+
AssertionActionsClass,
|
|
10
|
+
AssertionInfoClass,
|
|
11
|
+
AssertionNoteClass,
|
|
12
|
+
AssertionSourceClass,
|
|
13
|
+
AssertionStdParameterClass,
|
|
14
|
+
AssertionStdParametersClass,
|
|
15
|
+
AuditStampClass,
|
|
16
|
+
CustomAssertionInfoClass,
|
|
17
|
+
FieldAssertionInfoClass,
|
|
18
|
+
FieldMetricAssertionClass,
|
|
19
|
+
FieldValuesAssertionClass,
|
|
20
|
+
FieldValuesFailThresholdClass,
|
|
21
|
+
FreshnessAssertionInfoClass,
|
|
22
|
+
FreshnessAssertionScheduleClass,
|
|
23
|
+
FreshnessCronScheduleClass,
|
|
24
|
+
OtherSchemaClass,
|
|
25
|
+
RowCountChangeClass,
|
|
26
|
+
RowCountTotalClass,
|
|
27
|
+
SchemaAssertionInfoClass,
|
|
28
|
+
SchemaFieldClass,
|
|
29
|
+
SchemaFieldSpecClass,
|
|
30
|
+
SchemaMetadataClass,
|
|
31
|
+
SqlAssertionInfoClass,
|
|
32
|
+
VolumeAssertionInfoClass,
|
|
33
|
+
)
|
|
34
|
+
from datahub.metadata.urns import AssertionUrn
|
|
35
|
+
|
|
36
|
+
from datahub_yaml_source.builders.common import (
|
|
37
|
+
DEFAULT_ACTOR_URN,
|
|
38
|
+
common_aspect_mcps,
|
|
39
|
+
mcp_workunit,
|
|
40
|
+
schema_field_data_type,
|
|
41
|
+
stringify_custom_properties,
|
|
42
|
+
)
|
|
43
|
+
from datahub_yaml_source.models import AssertionActionsDoc, AssertionDoc
|
|
44
|
+
from datahub_yaml_source.urns import ReferenceIndex
|
|
45
|
+
from datahub_yaml_source.yaml_source_report import YamlSourceReport
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _build_freshness_assertion(doc: AssertionDoc) -> FreshnessAssertionInfoClass:
|
|
49
|
+
a = doc.assertion
|
|
50
|
+
schedule = FreshnessAssertionScheduleClass(
|
|
51
|
+
type=a.scheduleType or "CRON",
|
|
52
|
+
cron=FreshnessCronScheduleClass(cron=a.cron or "", timezone=a.timezone or "UTC"),
|
|
53
|
+
)
|
|
54
|
+
return FreshnessAssertionInfoClass(
|
|
55
|
+
type=a.freshnessType or "DATASET_CHANGE", entity=a.entityUrn, schedule=schedule
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _build_sql_assertion(doc: AssertionDoc) -> SqlAssertionInfoClass:
|
|
60
|
+
a = doc.assertion
|
|
61
|
+
return SqlAssertionInfoClass(
|
|
62
|
+
type=a.sqlType or "METRIC",
|
|
63
|
+
entity=a.entityUrn,
|
|
64
|
+
statement=a.statement or "",
|
|
65
|
+
operator=a.operator or "GREATER_THAN",
|
|
66
|
+
parameters=AssertionStdParametersClass(
|
|
67
|
+
value=AssertionStdParameterClass(value=str(a.value), type="NUMBER")
|
|
68
|
+
),
|
|
69
|
+
changeType=a.changeType,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _build_field_assertion(doc: AssertionDoc) -> FieldAssertionInfoClass:
|
|
74
|
+
a = doc.assertion
|
|
75
|
+
field_spec = SchemaFieldSpecClass(
|
|
76
|
+
path=a.fieldPath or "",
|
|
77
|
+
type=a.dataType or "STRING",
|
|
78
|
+
nativeType=a.nativeDataType or "",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
field_values_assertion = None
|
|
82
|
+
field_metric_assertion = None
|
|
83
|
+
if a.fieldType == "FIELD_METRIC":
|
|
84
|
+
field_metric_assertion = FieldMetricAssertionClass(
|
|
85
|
+
field=field_spec,
|
|
86
|
+
metric=a.metric or "UNIQUE_PERCENTAGE",
|
|
87
|
+
operator=a.metricOperator or "EQUAL_TO",
|
|
88
|
+
parameters=(
|
|
89
|
+
AssertionStdParametersClass(
|
|
90
|
+
value=AssertionStdParameterClass(value=str(a.metricValue), type="NUMBER")
|
|
91
|
+
)
|
|
92
|
+
if a.metricValue is not None
|
|
93
|
+
else None
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
field_values_assertion = FieldValuesAssertionClass(
|
|
98
|
+
field=field_spec,
|
|
99
|
+
operator=a.operator or "NOT_NULL",
|
|
100
|
+
failThreshold=FieldValuesFailThresholdClass(type="COUNT", value=0),
|
|
101
|
+
excludeNulls=a.excludeNulls,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return FieldAssertionInfoClass(
|
|
105
|
+
type=a.fieldType or "FIELD_VALUES",
|
|
106
|
+
entity=a.entityUrn,
|
|
107
|
+
fieldValuesAssertion=field_values_assertion,
|
|
108
|
+
fieldMetricAssertion=field_metric_assertion,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
_DATASET_URN_PLATFORM_RE = re.compile(r"^urn:li:dataset:\((?P<platform>urn:li:dataPlatform:[^,]+),")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _platform_urn_from_dataset_urn(entity_urn: str) -> str:
|
|
116
|
+
match = _DATASET_URN_PLATFORM_RE.match(entity_urn)
|
|
117
|
+
return match.group("platform") if match else "urn:li:dataPlatform:unknown"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _build_volume_assertion(doc: AssertionDoc) -> VolumeAssertionInfoClass:
|
|
121
|
+
a = doc.assertion
|
|
122
|
+
if a.value is None:
|
|
123
|
+
raise ValueError(f"VOLUME assertion '{doc.id}' is missing a 'value' to compare against.")
|
|
124
|
+
# `RowCountTotalClass.parameters` / `RowCountChangeClass.parameters` are
|
|
125
|
+
# required in the Avro schema despite the generated stub typing them
|
|
126
|
+
# Optional -- passing None parses fine but fails MCP validation at emit
|
|
127
|
+
# time (same trap as `DeprecationClass.note`, see build_deprecation_aspect()).
|
|
128
|
+
parameters = AssertionStdParametersClass(
|
|
129
|
+
value=AssertionStdParameterClass(value=str(a.value), type="NUMBER")
|
|
130
|
+
)
|
|
131
|
+
if a.volumeType == "ROW_COUNT_CHANGE":
|
|
132
|
+
return VolumeAssertionInfoClass(
|
|
133
|
+
type="ROW_COUNT_CHANGE",
|
|
134
|
+
entity=a.entityUrn,
|
|
135
|
+
rowCountChange=RowCountChangeClass(
|
|
136
|
+
type=a.changeType or "ABSOLUTE",
|
|
137
|
+
operator=a.operator or "GREATER_THAN_OR_EQUAL_TO",
|
|
138
|
+
parameters=parameters,
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
return VolumeAssertionInfoClass(
|
|
142
|
+
type="ROW_COUNT_TOTAL",
|
|
143
|
+
entity=a.entityUrn,
|
|
144
|
+
rowCountTotal=RowCountTotalClass(
|
|
145
|
+
operator=a.operator or "GREATER_THAN_OR_EQUAL_TO", parameters=parameters
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _build_data_schema_assertion(doc: AssertionDoc) -> SchemaAssertionInfoClass:
|
|
151
|
+
a = doc.assertion
|
|
152
|
+
fields = [
|
|
153
|
+
SchemaFieldClass(
|
|
154
|
+
fieldPath=f.path,
|
|
155
|
+
type=schema_field_data_type(f.type),
|
|
156
|
+
nativeDataType=f.nativeType or f.type,
|
|
157
|
+
)
|
|
158
|
+
for f in (a.schemaFields or [])
|
|
159
|
+
]
|
|
160
|
+
expected_schema = SchemaMetadataClass(
|
|
161
|
+
schemaName=f"{doc.id}-expected-schema",
|
|
162
|
+
platform=_platform_urn_from_dataset_urn(a.entityUrn),
|
|
163
|
+
version=0,
|
|
164
|
+
hash="",
|
|
165
|
+
platformSchema=OtherSchemaClass(rawSchema=""),
|
|
166
|
+
fields=fields,
|
|
167
|
+
)
|
|
168
|
+
return SchemaAssertionInfoClass(
|
|
169
|
+
entity=a.entityUrn, schema=expected_schema, compatibility=a.compatibility
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _build_custom_assertion(doc: AssertionDoc) -> CustomAssertionInfoClass:
|
|
174
|
+
a = doc.assertion
|
|
175
|
+
# `CustomAssertionInfoClass.field` is a schemaField URN string, not a
|
|
176
|
+
# SchemaFieldSpec -- build the URN from the dataset URN + the column path.
|
|
177
|
+
field_urn = make_schema_field_urn(a.entityUrn, a.fieldPath) if a.fieldPath else None
|
|
178
|
+
return CustomAssertionInfoClass(
|
|
179
|
+
type=a.customType or "CUSTOM", entity=a.entityUrn, field=field_urn, logic=a.logic or ""
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
_BUILDERS = {
|
|
184
|
+
"FRESHNESS": (_build_freshness_assertion, "freshnessAssertion"),
|
|
185
|
+
"VOLUME": (_build_volume_assertion, "volumeAssertion"),
|
|
186
|
+
"SQL": (_build_sql_assertion, "sqlAssertion"),
|
|
187
|
+
"FIELD": (_build_field_assertion, "fieldAssertion"),
|
|
188
|
+
"DATA_SCHEMA": (_build_data_schema_assertion, "schemaAssertion"),
|
|
189
|
+
"CUSTOM": (_build_custom_assertion, "customAssertion"),
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _build_assertion_actions(actions: AssertionActionsDoc | None) -> AssertionActionsClass | None:
|
|
194
|
+
if actions is None:
|
|
195
|
+
return None
|
|
196
|
+
return AssertionActionsClass(
|
|
197
|
+
onSuccess=[AssertionActionClass(type=a.type) for a in (actions.onSuccess or [])] or None,
|
|
198
|
+
onFailure=[AssertionActionClass(type=a.type) for a in (actions.onFailure or [])] or None,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def build_assertion(
|
|
203
|
+
doc: AssertionDoc, index: ReferenceIndex, report: YamlSourceReport
|
|
204
|
+
) -> Iterable[MetadataWorkUnit]:
|
|
205
|
+
builder = _BUILDERS.get(doc.assertion.type)
|
|
206
|
+
if builder is None:
|
|
207
|
+
raise ValueError(
|
|
208
|
+
f"Unsupported assertion type '{doc.assertion.type}' for assertion '{doc.id}'. "
|
|
209
|
+
f"Supported types: {sorted(_BUILDERS)}"
|
|
210
|
+
)
|
|
211
|
+
build_fn, kwarg_name = builder
|
|
212
|
+
sub_assertion = build_fn(doc)
|
|
213
|
+
|
|
214
|
+
entity_urn = AssertionUrn(doc.id).urn()
|
|
215
|
+
|
|
216
|
+
custom_properties = None
|
|
217
|
+
if doc.properties:
|
|
218
|
+
custom_properties = stringify_custom_properties(
|
|
219
|
+
{
|
|
220
|
+
k: v
|
|
221
|
+
for k, v in {
|
|
222
|
+
"name": doc.properties.name,
|
|
223
|
+
"dbt_test": doc.properties.dbt_test,
|
|
224
|
+
}.items()
|
|
225
|
+
if v is not None
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# The sub-assertion kwarg name is chosen from `_BUILDERS` at runtime; `Any`
|
|
230
|
+
# is the honest element type for that dispatch.
|
|
231
|
+
sub_assertion_kwarg: dict[str, Any] = {kwarg_name: sub_assertion}
|
|
232
|
+
aspect = AssertionInfoClass(
|
|
233
|
+
type=doc.assertion.type,
|
|
234
|
+
description=doc.description,
|
|
235
|
+
customProperties=custom_properties,
|
|
236
|
+
source=AssertionSourceClass(type=doc.sourceType),
|
|
237
|
+
**sub_assertion_kwarg,
|
|
238
|
+
)
|
|
239
|
+
yield mcp_workunit(entity_urn, aspect)
|
|
240
|
+
|
|
241
|
+
if doc.assertionNote:
|
|
242
|
+
yield mcp_workunit(
|
|
243
|
+
entity_urn,
|
|
244
|
+
AssertionNoteClass(
|
|
245
|
+
content=doc.assertionNote,
|
|
246
|
+
lastModified=AuditStampClass(time=0, actor=DEFAULT_ACTOR_URN),
|
|
247
|
+
),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
actions_aspect = _build_assertion_actions(doc.assertionActions)
|
|
251
|
+
if actions_aspect:
|
|
252
|
+
yield mcp_workunit(entity_urn, actions_aspect)
|
|
253
|
+
|
|
254
|
+
yield from common_aspect_mcps(entity_urn, doc, index, report, f"ASSERTION '{doc.id}'")
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from collections.abc import Iterable
|
|
2
|
+
|
|
3
|
+
from datahub.ingestion.api.workunit import MetadataWorkUnit
|
|
4
|
+
from datahub.sdk.chart import Chart
|
|
5
|
+
|
|
6
|
+
from datahub_yaml_source.builders.common import common_sdk_kwargs, stringify_custom_properties
|
|
7
|
+
from datahub_yaml_source.models import ChartDoc
|
|
8
|
+
from datahub_yaml_source.urns import ReferenceIndex, container_key, dataset_urn
|
|
9
|
+
from datahub_yaml_source.yaml_source_report import YamlSourceReport
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_chart(
|
|
13
|
+
doc: ChartDoc, index: ReferenceIndex, report: YamlSourceReport
|
|
14
|
+
) -> Iterable[MetadataWorkUnit]:
|
|
15
|
+
context = f"CHART '{doc.name}'"
|
|
16
|
+
common = common_sdk_kwargs(doc, index, report, context)
|
|
17
|
+
|
|
18
|
+
# `parent_container=` defaults to a private `Unset` sentinel, not `None`
|
|
19
|
+
# -- passing `None` explicitly still triggers `_set_container(None)`,
|
|
20
|
+
# which emits a (harmless but unwanted-here) empty `browsePathsV2`. Only
|
|
21
|
+
# pass the kwarg at all when there's an actual container (same trap as
|
|
22
|
+
# `build_dataset()`/`build_data_flow()`).
|
|
23
|
+
if doc.container is not None:
|
|
24
|
+
if not index.has_container(doc.container):
|
|
25
|
+
report.report_dangling_reference(f"{context} references an undeclared container")
|
|
26
|
+
common["parent_container"] = container_key(doc.container)
|
|
27
|
+
|
|
28
|
+
chart = Chart(
|
|
29
|
+
platform=doc.platform,
|
|
30
|
+
name=doc.name,
|
|
31
|
+
platform_instance=doc.instance,
|
|
32
|
+
display_name=doc.displayName,
|
|
33
|
+
description=doc.description,
|
|
34
|
+
external_url=doc.externalUrl,
|
|
35
|
+
chart_url=doc.chartUrl,
|
|
36
|
+
chart_type=doc.chartType,
|
|
37
|
+
custom_properties=stringify_custom_properties(doc.properties),
|
|
38
|
+
input_datasets=[dataset_urn(ref) for ref in (doc.inputDatasets or [])] or None,
|
|
39
|
+
**common,
|
|
40
|
+
)
|
|
41
|
+
yield from chart.as_workunits()
|