metricflow-sql 0.1.1__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.
Files changed (56) hide show
  1. metricflow_sql/__init__.py +8 -0
  2. metricflow_sql/compiler/__init__.py +0 -0
  3. metricflow_sql/compiler/catalog/__init__.py +0 -0
  4. metricflow_sql/compiler/catalog/cluster.py +152 -0
  5. metricflow_sql/compiler/catalog/information_schema.py +47 -0
  6. metricflow_sql/compiler/catalog/metadata.py +65 -0
  7. metricflow_sql/compiler/catalog/passthrough.py +184 -0
  8. metricflow_sql/compiler/catalog/protocols.py +67 -0
  9. metricflow_sql/compiler/catalog/semantic.py +59 -0
  10. metricflow_sql/compiler/compiler.py +297 -0
  11. metricflow_sql/compiler/rules/__init__.py +0 -0
  12. metricflow_sql/compiler/rules/expand_constellations.py +790 -0
  13. metricflow_sql/compiler/rules/expand_datasets.py +1208 -0
  14. metricflow_sql/compiler/rules/expand_metadata.py +88 -0
  15. metricflow_sql/compiler/rules/isolate_fragments.py +244 -0
  16. metricflow_sql/compiler/rules/passthrough_tables.py +36 -0
  17. metricflow_sql/compiler/table_arguments.py +35 -0
  18. metricflow_sql/converters/__init__.py +0 -0
  19. metricflow_sql/converters/lookml.py +1041 -0
  20. metricflow_sql/objects/__init__.py +0 -0
  21. metricflow_sql/objects/base.py +308 -0
  22. metricflow_sql/objects/constellation.py +210 -0
  23. metricflow_sql/objects/dataset.py +275 -0
  24. metricflow_sql/objects/entity.py +131 -0
  25. metricflow_sql/objects/reference.py +35 -0
  26. metricflow_sql/objects/wiki.py +101 -0
  27. metricflow_sql/py.typed +0 -0
  28. metricflow_sql/registry/__init__.py +0 -0
  29. metricflow_sql/registry/memory.py +409 -0
  30. metricflow_sql/registry/operation.py +25 -0
  31. metricflow_sql/registry/reference_resolution.py +68 -0
  32. metricflow_sql/registry/registry.py +225 -0
  33. metricflow_sql/registry/type_resolver.py +460 -0
  34. metricflow_sql/schemas/__init__.py +0 -0
  35. metricflow_sql/schemas/bundle.py +327 -0
  36. metricflow_sql/schemas/fragment.schema.json +1165 -0
  37. metricflow_sql/schemas/generate.py +70 -0
  38. metricflow_sql/schemas/load.py +333 -0
  39. metricflow_sql/schemas/v1/__init__.py +51 -0
  40. metricflow_sql/schemas/v1/fragment.py +544 -0
  41. metricflow_sql/server/__init__.py +4 -0
  42. metricflow_sql/server/app.py +33 -0
  43. metricflow_sql/server/compile.py +69 -0
  44. metricflow_sql/server/errors.py +10 -0
  45. metricflow_sql/server/serve.py +161 -0
  46. metricflow_sql/utils/__init__.py +0 -0
  47. metricflow_sql/utils/composed_set.py +32 -0
  48. metricflow_sql/utils/normalize.py +74 -0
  49. metricflow_sql/utils/ordered_set.py +24 -0
  50. metricflow_sql/utils/sql.py +226 -0
  51. metricflow_sql/utils/template.py +57 -0
  52. metricflow_sql-0.1.1.dist-info/METADATA +30 -0
  53. metricflow_sql-0.1.1.dist-info/RECORD +56 -0
  54. metricflow_sql-0.1.1.dist-info/WHEEL +4 -0
  55. metricflow_sql-0.1.1.dist-info/entry_points.txt +2 -0
  56. metricflow_sql-0.1.1.dist-info/licenses/LICENSE +29 -0
@@ -0,0 +1,275 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass, field
5
+ from enum import StrEnum
6
+ from typing import Protocol, runtime_checkable
7
+
8
+ from sqlglot import expressions as exp
9
+ from sqlglot import parse_one
10
+
11
+ from metricflow_sql.objects.base import (
12
+ PARENT_LINK,
13
+ BaseObject,
14
+ Id,
15
+ Link,
16
+ Path,
17
+ Prefix,
18
+ deps_from_dataset_expr,
19
+ )
20
+ from metricflow_sql.utils.normalize import resolve_name
21
+ from metricflow_sql.utils.ordered_set import OrderedSet
22
+ from metricflow_sql.utils.sql import ref_type
23
+ from metricflow_sql.utils.template import render_sql
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class _DatasetField(BaseObject):
30
+ dataset: exp.Table
31
+ name: exp.Identifier
32
+
33
+ @property
34
+ def id(self) -> Id:
35
+ return Id(Prefix.FIELD, (self.dataset, self.name))
36
+
37
+ def links(self) -> OrderedSet[Link]:
38
+ links = super().links()
39
+ links.add(Link(target=Id(Prefix.TABLE, (self.dataset,)), label=PARENT_LINK))
40
+ return links
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Column(_DatasetField):
45
+ """
46
+ Concrete column in the underlying database.
47
+ """
48
+
49
+ datatype: exp.DataType | None = None
50
+
51
+ def dependencies(self) -> OrderedSet[Id | Path]:
52
+ return OrderedSet([Id(Prefix.TABLE, (self.dataset,))])
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Calculation(_DatasetField):
57
+ """
58
+ Derived column.
59
+
60
+ `expr` is a SQL expression, where columns reference other `Column`s, `Calculation`s, or
61
+ `Relationship`s.
62
+
63
+ `datatype` is an optional declaration. The type resolver infers undeclared types
64
+ from dependencies without changing this object.
65
+ """
66
+
67
+ expr: exp.Expr
68
+ datatype: exp.DataType | None = None
69
+
70
+ def dependencies(self) -> OrderedSet[Id | Path]:
71
+ return deps_from_dataset_expr(self.dataset, self.expr)
72
+
73
+
74
+ class Cardinality(StrEnum):
75
+ """Relationship cardinality"""
76
+
77
+ MANY_TO_ONE = "many_to_one"
78
+ ONE_TO_MANY = "one_to_many"
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class Relationship(_DatasetField):
83
+ """
84
+ Relationship to its `target`, an `Entity` or a `Dataset`.
85
+
86
+ Unique by `dataset`/`name`.
87
+
88
+ `key` maps the target's fields to expressions on this dataset. For an entity, the fields are
89
+ its `Entity.key` columns or its attributes. For a dataset, they are its `DatasetKey` parts or
90
+ any of its fields. A key that binds all of the target's key is many-to-one, and any other is
91
+ one-to-many.
92
+
93
+ `cardinality` optionally declares which, and must then agree with the key.
94
+ """
95
+
96
+ target: exp.Table
97
+ key: dict[exp.Identifier, exp.Expr]
98
+ cardinality: Cardinality | None = None
99
+
100
+ @property
101
+ def datatype(self) -> exp.DataType:
102
+ """A reference to the target, e.g. `REF(customer)`; registries resolve its fields."""
103
+ return ref_type(self.target)
104
+
105
+ def dependencies(self) -> OrderedSet[Id | Path]:
106
+ deps: OrderedSet[Id | Path] = OrderedSet()
107
+ deps.add(Id(Prefix.TABLE, (self.target,)))
108
+ for name in self.key:
109
+ deps.add(Id(Prefix.FIELD, (self.target, name)))
110
+ for expr in self.key.values():
111
+ deps |= deps_from_dataset_expr(self.dataset, expr)
112
+ return deps
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class Parameter:
117
+ """
118
+ A value a query passes on its root table function, e.g. `store_sales(min_quantity => 90)`,
119
+ to every dataset the query reaches through relationships.
120
+ """
121
+
122
+ datatype: exp.DataType | None = None
123
+ default: exp.Expr | None = None
124
+
125
+
126
+ def parameter_value(parameter: Parameter, argument: exp.Expr | None) -> exp.Expr:
127
+ """What the query passed, else the parameter's default, else NULL."""
128
+ return argument or parameter.default or exp.null()
129
+
130
+
131
+ def argument(arguments: dict[str, exp.Expr], parameter: exp.Identifier) -> exp.Expr | None:
132
+ """
133
+ What the query passed for `parameter`, by its name as `resolve_name` resolves it: sqlglot
134
+ doesn't keep an argument name's quotes, e.g. `"MinAmount" => 90`.
135
+ """
136
+ name = resolve_name(parameter.name, arguments)
137
+ return None if name is None else arguments[name]
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class KeyParameter:
142
+ """A placeholder of a `KeyCondition`, e.g. `:as_of`."""
143
+
144
+ datatype: exp.DataType | None = None
145
+
146
+
147
+ @dataclass(frozen=True)
148
+ class KeyCondition:
149
+ """
150
+ A part of a `DatasetKey` that identifies rows by a condition rather than a field's value,
151
+ e.g. `:as_of >= valid_from AND :as_of < valid_to` for versions valid over a period.
152
+
153
+ `on` reads the dataset's fields, and the values relationships bind as `:name` placeholders.
154
+ `parameters` are those placeholders, by name.
155
+ """
156
+
157
+ on: exp.Expr
158
+ parameters: dict[exp.Identifier, KeyParameter]
159
+
160
+
161
+ @dataclass(frozen=True)
162
+ class DatasetKey:
163
+ """
164
+ What identifies a row of a dataset, e.g. `[user_id, item_id]`.
165
+
166
+ Each part is the name of a field, whose value identifies rows, or a `KeyCondition`.
167
+ """
168
+
169
+ parts: tuple[exp.Identifier | KeyCondition, ...]
170
+
171
+ def fields(self) -> tuple[exp.Identifier, ...]:
172
+ """The parts that are fields, e.g. `user_id`."""
173
+ fields: list[exp.Identifier] = []
174
+ for part in self.parts:
175
+ match part:
176
+ case exp.Identifier():
177
+ fields.append(part)
178
+ return tuple(fields)
179
+
180
+ def conditions(self) -> tuple[KeyCondition, ...]:
181
+ """The parts that are conditions."""
182
+ conditions: list[KeyCondition] = []
183
+ for part in self.parts:
184
+ match part:
185
+ case KeyCondition():
186
+ conditions.append(part)
187
+ return tuple(conditions)
188
+
189
+ def names(self) -> tuple[exp.Identifier, ...]:
190
+ """
191
+ The names relationships bind to the key, in order: fields, and condition parameters,
192
+ e.g. `user_id`, `as_of`.
193
+ """
194
+ names: list[exp.Identifier] = []
195
+ for part in self.parts:
196
+ match part:
197
+ case exp.Identifier():
198
+ names.append(part)
199
+ case KeyCondition(parameters=parameters):
200
+ names.extend(parameters)
201
+ return tuple(names)
202
+
203
+ def dependencies(self, dataset: exp.Table) -> OrderedSet[Id | Path]:
204
+ """
205
+ The fields of `dataset` the key reads, e.g. a calculation it names, which joins and fan-out
206
+ grouping expand like a `Source`'s `on`.
207
+ """
208
+ deps: OrderedSet[Id | Path] = OrderedSet()
209
+ for name in self.fields():
210
+ deps.add(Id(Prefix.FIELD, (dataset, name)))
211
+ for condition in self.conditions():
212
+ deps |= deps_from_dataset_expr(dataset, condition.on)
213
+ return deps
214
+
215
+
216
+ @runtime_checkable
217
+ class Dataset(Protocol):
218
+ @property
219
+ def name(self) -> exp.Table: ...
220
+
221
+ @property
222
+ def parameters(self) -> dict[exp.Identifier, Parameter]: ...
223
+
224
+ @property
225
+ def key(self) -> DatasetKey | None: ...
226
+
227
+ def from_(self, arguments: dict[str, exp.Expr]) -> exp.Expr:
228
+ """What the dataset reads from, given the query's table function arguments."""
229
+ ...
230
+
231
+
232
+ @dataclass(frozen=True)
233
+ class Table(BaseObject):
234
+ name: exp.Table
235
+ location: exp.Table
236
+ parameters: dict[exp.Identifier, Parameter] = field(default_factory=dict)
237
+ key: DatasetKey | None = None
238
+
239
+ def from_(self, arguments: dict[str, exp.Expr]) -> exp.Expr:
240
+ return self.location.copy()
241
+
242
+ @property
243
+ def id(self) -> Id:
244
+ return Id(Prefix.TABLE, (self.name,))
245
+
246
+ def dependencies(self) -> OrderedSet[Id | Path]:
247
+ return self.key.dependencies(self.name) if self.key is not None else OrderedSet()
248
+
249
+
250
+ @dataclass(frozen=True)
251
+ class View(BaseObject):
252
+ """
253
+ The rows of `sql`, a Jinja template over the view's `parameters`, written in `dialect`.
254
+ """
255
+
256
+ name: exp.Table
257
+ sql: str
258
+ dialect: str | None = None
259
+ parameters: dict[exp.Identifier, Parameter] = field(default_factory=dict)
260
+ key: DatasetKey | None = None
261
+
262
+ def from_(self, arguments: dict[str, exp.Expr]) -> exp.Expr:
263
+ values = {
264
+ name.name: parameter_value(parameter, argument(arguments, name))
265
+ for name, parameter in self.parameters.items()
266
+ }
267
+ rendered = render_sql(self.sql, values, dialect=self.dialect)
268
+ return exp.Subquery(this=parse_one(rendered, dialect=self.dialect))
269
+
270
+ @property
271
+ def id(self) -> Id:
272
+ return Id(Prefix.TABLE, (self.name,))
273
+
274
+ def dependencies(self) -> OrderedSet[Id | Path]:
275
+ return self.key.dependencies(self.name) if self.key is not None else OrderedSet()
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+
6
+ from sqlglot import exp
7
+
8
+ from metricflow_sql.objects.base import (
9
+ PARENT_LINK,
10
+ BaseObject,
11
+ Id,
12
+ Link,
13
+ Path,
14
+ Prefix,
15
+ dataset_paths,
16
+ deps_from_dataset_expr,
17
+ )
18
+ from metricflow_sql.utils.ordered_set import OrderedSet
19
+ from metricflow_sql.utils.sql import column_paths
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class KeyColumn:
26
+ """
27
+ Specification for an individual `Entity.key`.
28
+
29
+ `optional` means that `Dataset` relationships don't need to reference it,
30
+ but then they don't get access to `Attributes` from `Source`s that implement it.
31
+ """
32
+
33
+ datatype: exp.DataType | None = None
34
+ optional: bool = False
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Entity(BaseObject):
39
+ """
40
+ An Entity is an identifiable, referenceable business object.
41
+
42
+ Entities are like virtual datasets, composed from many datasets.
43
+ `Attribute`s act like an entity's columns.
44
+
45
+ Entities are unique by `name`.
46
+
47
+ `key` is an interface that `Source`s must implement and `Dataset` relationships reference.
48
+ """
49
+
50
+ name: exp.Table
51
+ key: dict[exp.Identifier, KeyColumn]
52
+
53
+ @property
54
+ def id(self) -> Id:
55
+ return Id(Prefix.TABLE, (self.name,))
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Source(BaseObject):
60
+ """
61
+ `Entity`s have `Attribute`s that are sourced from many `Dataset`s.
62
+ A Source defines such a source.
63
+
64
+ Sources are unique on `dataset`/`entity` combinations.
65
+
66
+ `on` is a SQL expression that defines how the `Dataset` implements the `Entity.key`.
67
+ It is used as the ON clause of JOIN expressions, where columns are relative to the `dataset`,
68
+ and entity key values, provided by the other side of the join, are referencable with
69
+ `:name` placeholders.
70
+ """
71
+
72
+ dataset: exp.Table
73
+ entity: exp.Table
74
+ on: exp.Expr
75
+
76
+ @property
77
+ def id(self) -> Id:
78
+ return Id(Prefix.SOURCE, (self.entity, self.dataset))
79
+
80
+ def dependencies(self) -> OrderedSet[Id | Path]:
81
+ deps: OrderedSet[Id | Path] = OrderedSet()
82
+ deps |= deps_from_dataset_expr(self.dataset, self.on)
83
+ deps.add(Id(Prefix.TABLE, (self.entity,)))
84
+ deps.add(Id(Prefix.TABLE, (self.dataset,)))
85
+ return deps
86
+
87
+ def links(self) -> OrderedSet[Link]:
88
+ links = super().links()
89
+ links.add(Link(target=Id(Prefix.TABLE, (self.entity,)), label=PARENT_LINK))
90
+ return links
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class Attribute(BaseObject):
95
+ """
96
+ An Attribute is a property of an `Entity`.
97
+
98
+ Attributes are unique on `entity`/`name` combinations.
99
+
100
+ `expr` is a SQL expression, where qualified columns reference fields from a `Source`
101
+ and unqualified columns reference other Attributes.
102
+
103
+ `datatype` should be resolved from dependencies.
104
+ """
105
+
106
+ entity: exp.Table
107
+ name: exp.Identifier
108
+ expr: exp.Expr
109
+ datatype: exp.DataType | None = None
110
+
111
+ @property
112
+ def id(self) -> Id:
113
+ return Id(Prefix.FIELD, (self.entity, self.name))
114
+
115
+ def dependencies(self) -> OrderedSet[Id | Path]:
116
+ deps: OrderedSet[Id | Path] = OrderedSet()
117
+ for col in self.expr.find_all(exp.Column):
118
+ if not col.table:
119
+ deps.add(Id(Prefix.FIELD, (self.entity, col.this.copy())))
120
+ for parts in column_paths(self.expr):
121
+ # TODO - there's some ambiguity on whether an expression is <source>.<field> or <attribute>.<path>
122
+ for source_dataset, field_parts in dataset_paths(parts, self.entity.args["db"]):
123
+ deps.add(Id(Prefix.SOURCE, (self.entity, source_dataset)))
124
+ deps.add(Path(base=Id(Prefix.TABLE, (source_dataset,)), path=field_parts))
125
+ deps.add(Id(Prefix.FIELD, (source_dataset, self.name)))
126
+ return deps
127
+
128
+ def links(self) -> OrderedSet[Link]:
129
+ links = super().links()
130
+ links.add(Link(target=Id(Prefix.TABLE, (self.entity,)), label=PARENT_LINK))
131
+ return links
@@ -0,0 +1,35 @@
1
+ """
2
+ References to objects in wiki links, e.g. `[revenue](metric:sales:revenue)`.
3
+
4
+ A reference is an id, whose prefix may be an alias - see `Id.resolve`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+
11
+ from sqlglot.dialects.dialect import NormalizationStrategy
12
+
13
+ from metricflow_sql.objects.base import Id, Prefix
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def parse_reference(url: str, dialect: str, strategy: NormalizationStrategy) -> Id | None:
19
+ """
20
+ Resolve `url` to the id it references, or None if it isn't a reference (e.g. `https:`).
21
+
22
+ Names are percent-decoded by `Id.resolve` (as ids escape `:` and `%`, and markdown parsers
23
+ encode URLs), and any `#fragment` is dropped, e.g. `Metric:sales:net%20revenue#notes` names the field `net revenue`.
24
+
25
+ Raises `ValueError` for a reference with a known prefix but a malformed name, so typos
26
+ surface instead of silently dropping the link.
27
+ """
28
+ prefix_text, separator, rest = url.partition(":")
29
+ try:
30
+ Prefix.parse(prefix_text)
31
+ except ValueError:
32
+ return None
33
+ if not separator:
34
+ return None
35
+ return Id.resolve(f"{prefix_text}:{rest.partition('#')[0]}", dialect, strategy)
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass, field
5
+ from typing import Literal
6
+
7
+ from sqlglot import exp
8
+ from sqlglot.dialects.dialect import NormalizationStrategy
9
+ from wenmode import Wenmode
10
+ from wenmode.ast import plain_text, walk
11
+ from wenmode.nodes import Heading, Root
12
+ from wenmode.nodes import Link as MarkdownLink
13
+ from wenmode.presets import github
14
+
15
+ from metricflow_sql.objects.base import BaseObject, Chunk, Id, Link, Prefix
16
+ from metricflow_sql.objects.reference import parse_reference
17
+ from metricflow_sql.utils.ordered_set import OrderedSet
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Sections longer than this are split into several chunks, on block boundaries.
22
+ MAX_CHUNK_CHARS = 2000
23
+
24
+ _MARKDOWN = Wenmode(rules=github())
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Page(BaseObject):
29
+ """
30
+ A markdown wiki page, stored alongside the semantic layer.
31
+
32
+ Pages are unique by `name`, which may contain `/` to organize pages hierarchically. Unlike
33
+ the semantic objects, pages aren't in namespaces, so a name may contain `.`, e.g.
34
+ `guides/v1.2`.
35
+
36
+ Links to other objects are regular markdown links with a reference as the URL, e.g.
37
+ `[revenue](metric:sales:revenue)` - see `metricflow_sql.objects.reference`.
38
+ """
39
+
40
+ name: str
41
+ content: str
42
+ # The fragment's, which its links are written in
43
+ dialect: str
44
+ # The registry's, so links read names as queries do. It's not the page's, so not compared.
45
+ normalization_strategy: NormalizationStrategy = field(compare=False)
46
+ content_type: Literal["text/markdown"] = "text/markdown"
47
+
48
+ @property
49
+ def id(self) -> Id:
50
+ return Id(Prefix.PAGE, (exp.to_identifier(self.name, quoted=True),))
51
+
52
+ def links(self) -> OrderedSet[Link]:
53
+ """
54
+ Links to other objects, in order of appearance.
55
+
56
+ Inline, reference-style and autolinks all count; links in code spans and blocks
57
+ don't, nor do links that aren't references (e.g. `https:`). Pages aren't in namespaces,
58
+ so a bare name in a reference is in the default one.
59
+ """
60
+ links: OrderedSet[Link] = OrderedSet()
61
+ for node in walk(self._parse()):
62
+ match node:
63
+ case MarkdownLink(url=url):
64
+ target = parse_reference(url, self.dialect, self.normalization_strategy)
65
+ if target is not None:
66
+ links.add(Link(target=target, label=plain_text(node)))
67
+ return links
68
+
69
+ def chunks(self) -> tuple[Chunk, ...]:
70
+ """
71
+ The default name/label/description chunk, then the content split by heading.
72
+
73
+ Each chunk's `section` is its heading trail, e.g. `("Setup", "Install")` for text
74
+ under a `###` heading within a `#` heading, skipping the missing `##` level.
75
+ """
76
+ chunks = list(super().chunks())
77
+ section: tuple[str, ...] = ()
78
+ blocks: list[str] = []
79
+
80
+ def flush() -> None:
81
+ if blocks:
82
+ chunks.append(Chunk(text="\n\n".join(blocks), section=section))
83
+ blocks.clear()
84
+
85
+ for node in self._parse().children:
86
+ match node:
87
+ case Heading(depth=depth):
88
+ flush()
89
+ section = (*section[: depth - 1], plain_text(node))
90
+ case _:
91
+ text = plain_text(node)
92
+ if not text:
93
+ continue
94
+ if blocks and sum(map(len, blocks)) + len(text) > MAX_CHUNK_CHARS:
95
+ flush()
96
+ blocks.append(text)
97
+ flush()
98
+ return tuple(chunks)
99
+
100
+ def _parse(self) -> Root:
101
+ return _MARKDOWN.parse(self.content)
File without changes
File without changes