dagster-dataframely 0.0.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.
- dagster_dataframely/__init__.py +32 -0
- dagster_dataframely/asset.py +195 -0
- dagster_dataframely/checks.py +94 -0
- dagster_dataframely/errors.py +154 -0
- dagster_dataframely/io_managers.py +137 -0
- dagster_dataframely/metadata.py +84 -0
- dagster_dataframely/naming.py +97 -0
- dagster_dataframely/py.typed +0 -0
- dagster_dataframely/runtime.py +153 -0
- dagster_dataframely-0.0.1.dist-info/METADATA +76 -0
- dagster_dataframely-0.0.1.dist-info/RECORD +13 -0
- dagster_dataframely-0.0.1.dist-info/WHEEL +4 -0
- dagster_dataframely-0.0.1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from dagster_dataframely.asset import dataframely_asset
|
|
2
|
+
from dagster_dataframely.checks import check_specs
|
|
3
|
+
from dagster_dataframely.errors import (
|
|
4
|
+
CheckNameCollisionError,
|
|
5
|
+
CollectionNotSupportedError,
|
|
6
|
+
DagsterDataframelyError,
|
|
7
|
+
ReservedColumnError,
|
|
8
|
+
SchemaGateError,
|
|
9
|
+
UnwritableDtypeError,
|
|
10
|
+
ValidationAbortError,
|
|
11
|
+
)
|
|
12
|
+
from dagster_dataframely.io_managers import DataframelyParquetIOManager
|
|
13
|
+
from dagster_dataframely.metadata import schema_metadata, table_schema
|
|
14
|
+
from dagster_dataframely.naming import check_name
|
|
15
|
+
from dagster_dataframely.runtime import process
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"CheckNameCollisionError",
|
|
19
|
+
"CollectionNotSupportedError",
|
|
20
|
+
"DagsterDataframelyError",
|
|
21
|
+
"DataframelyParquetIOManager",
|
|
22
|
+
"ReservedColumnError",
|
|
23
|
+
"SchemaGateError",
|
|
24
|
+
"UnwritableDtypeError",
|
|
25
|
+
"ValidationAbortError",
|
|
26
|
+
"check_name",
|
|
27
|
+
"check_specs",
|
|
28
|
+
"dataframely_asset",
|
|
29
|
+
"process",
|
|
30
|
+
"schema_metadata",
|
|
31
|
+
"table_schema",
|
|
32
|
+
]
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""The front door: one decorator argument attaches a dataframely schema to a Dagster asset.
|
|
2
|
+
|
|
3
|
+
The door coordinates four artifacts no single `@dg.asset` parameter accepts as a bundle: the out, the check specs, the definition metadata, and the wrapped runtime. First-party precedent for a decorator that does this is `@dbt_assets`.
|
|
4
|
+
|
|
5
|
+
This module carries no `from __future__ import annotations`. At a 3.12 floor it would buy only unquoted forward references, while turning user-facing annotations into strings that Dagster's runtime introspection rejects. The counter-trap is that typing-only names such as `dg.CoercibleToAssetDep` are absent at runtime, so they are spelled here with runtime-real types.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import functools
|
|
9
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
10
|
+
from collections.abc import Set as AbstractSet
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import dagster as dg
|
|
14
|
+
import dataframely as dy
|
|
15
|
+
import polars as pl
|
|
16
|
+
|
|
17
|
+
from dagster_dataframely.checks import check_specs
|
|
18
|
+
from dagster_dataframely.errors import CollectionNotSupportedError
|
|
19
|
+
from dagster_dataframely.metadata import schema_metadata
|
|
20
|
+
from dagster_dataframely.runtime import AssetYield, process
|
|
21
|
+
|
|
22
|
+
TransformFn = Callable[..., pl.DataFrame | pl.LazyFrame]
|
|
23
|
+
|
|
24
|
+
# The union `@dg.asset` accepts, spelled out because `AutomationCondition` is generic and its two parameterizations are not interchangeable.
|
|
25
|
+
AutomationCondition = (
|
|
26
|
+
dg.AutomationCondition[dg.AssetKey]
|
|
27
|
+
| dg.AutomationCondition[dg.AssetKey | dg.AssetCheckKey]
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
#: Runtime-real spelling of Dagster's `CoercibleToAssetDep`, which is typing-only.
|
|
31
|
+
AssetDep = (
|
|
32
|
+
dg.AssetKey
|
|
33
|
+
| str
|
|
34
|
+
| Sequence[str]
|
|
35
|
+
| dg.AssetSpec
|
|
36
|
+
| dg.AssetsDefinition
|
|
37
|
+
| dg.SourceAsset
|
|
38
|
+
| dg.AssetDep
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def dataframely_asset( # noqa: PLR0913 - the forwarded surface is the point
|
|
43
|
+
*,
|
|
44
|
+
# --- door-owned ---
|
|
45
|
+
schema: type[dy.Schema],
|
|
46
|
+
key_prefix: str | Sequence[str] | None = None,
|
|
47
|
+
# --- carried to the asset itself, matching @dg.asset's vocabulary ---
|
|
48
|
+
io_manager_key: str | None = None,
|
|
49
|
+
metadata: Mapping[str, Any] | None = None,
|
|
50
|
+
tags: Mapping[str, str] | None = None,
|
|
51
|
+
owners: Sequence[str] | None = None,
|
|
52
|
+
kinds: AbstractSet[str] | None = None,
|
|
53
|
+
automation_condition: AutomationCondition | None = None,
|
|
54
|
+
freshness_policy: dg.FreshnessPolicy | None = None,
|
|
55
|
+
# --- forwarded to @dg.multi_asset, verbatim ---
|
|
56
|
+
name: str | None = None,
|
|
57
|
+
ins: Mapping[str, dg.AssetIn] | None = None,
|
|
58
|
+
deps: Iterable[AssetDep] | None = None,
|
|
59
|
+
description: str | None = None,
|
|
60
|
+
# Narrower than Dagster's own six-member union, deliberately: a mapping is the
|
|
61
|
+
# spelling worth a static guarantee, and the rest are legacy.
|
|
62
|
+
config_schema: Mapping[str, Any] | None = None,
|
|
63
|
+
required_resource_keys: AbstractSet[str] | None = None,
|
|
64
|
+
partitions_def: dg.PartitionsDefinition[str] | None = None,
|
|
65
|
+
hooks: AbstractSet[dg.HookDefinition] | None = None,
|
|
66
|
+
backfill_policy: dg.BackfillPolicy | None = None,
|
|
67
|
+
op_tags: Mapping[str, Any] | None = None,
|
|
68
|
+
resource_defs: Mapping[str, object] | None = None,
|
|
69
|
+
group_name: str | None = None,
|
|
70
|
+
retry_policy: dg.RetryPolicy | None = None,
|
|
71
|
+
code_version: str | None = None,
|
|
72
|
+
pool: str | None = None,
|
|
73
|
+
) -> Callable[[TransformFn], dg.AssetsDefinition]:
|
|
74
|
+
"""Turns a polars transform into an asset that validates its output against `schema`.
|
|
75
|
+
|
|
76
|
+
The contract then lives in exactly one place. From the single declaration, the Columns tab fills in before the asset has ever run, every dataframely rule becomes an asset check with its own pass/fail history, and a frame whose shape does not match the schema aborts the run before a single row is filtered.
|
|
77
|
+
|
|
78
|
+
The transform keeps plain polars annotations: nothing rewrites the signature, upstream dependencies bind as ordinary parameters, and the return may be a `DataFrame` or a `LazyFrame`. It takes no `context` parameter; the wrapper reaches the context itself.
|
|
79
|
+
|
|
80
|
+
**Every row has to be good.** A run that rejects even one row fails and writes nothing, leaving the last-known-good table in place. There is no lenient mode and no strict flag, deliberately: landing the survivors and dropping the rest is precisely the failure this package exists to make visible, so it is not reachable by configuration. To drop rows anyway, filter in the asset body, where the drop is a line you wrote:
|
|
81
|
+
|
|
82
|
+
good, _ = Orders.filter(raw_orders)
|
|
83
|
+
return good
|
|
84
|
+
|
|
85
|
+
Routing rejected rows to a sibling asset instead of failing is planned work, tracked in issue #19.
|
|
86
|
+
|
|
87
|
+
Every parameter is declared explicitly with its runtime-real type, so editors autocomplete them and `group_nme="sales"` is a static error rather than an import-time crash. `outs`, `check_specs` and `specs` are surfaces this decorator owns and are simply absent, so they cannot be contested. `can_subset` is absent too: a subset executes but saves nothing.
|
|
88
|
+
|
|
89
|
+
`@dg.multi_asset` is the mechanism, but the vocabulary is `@dg.asset`'s, because this decorator is designed for a single table that happens to grow a quarantine sibling. Anything `@dg.asset` lets you say about one asset is sayable here under the same name, and a test asserts that in both directions.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
schema: The dataframely schema the transform's output must satisfy.
|
|
93
|
+
key_prefix: Prefix for the asset key. The checks follow it automatically.
|
|
94
|
+
io_manager_key: Resource key the table is stored under. The quarantine inherits it unless its own `dg.AssetOut` names a different one (#19).
|
|
95
|
+
metadata: Definition metadata to carry alongside the schema's own. `dagster/column_schema` and `dagster_dataframely/schema` are the package's and win a collision.
|
|
96
|
+
tags: Asset tags, for filtering and grouping in the catalog.
|
|
97
|
+
owners: Asset owners, as emails or `team:<name>`.
|
|
98
|
+
kinds: Kind badges shown on the asset in the graph.
|
|
99
|
+
automation_condition: Declarative automation condition for the asset.
|
|
100
|
+
freshness_policy: Freshness policy for the asset.
|
|
101
|
+
name: Asset name. Defaults to the function name.
|
|
102
|
+
ins: Explicit input mapping, for the cases a parameter name cannot express.
|
|
103
|
+
deps: Upstream assets this one depends on without loading.
|
|
104
|
+
description: Asset description. Defaults to the function's docstring.
|
|
105
|
+
config_schema: Run configuration schema for the underlying op.
|
|
106
|
+
required_resource_keys: Resources the transform reaches through the context.
|
|
107
|
+
partitions_def: Partitioning for the asset. The state machine then runs per partition, on that partition's frame.
|
|
108
|
+
hooks: Hooks to attach to the underlying op.
|
|
109
|
+
backfill_policy: How Dagster backfills this asset's partitions.
|
|
110
|
+
op_tags: Tags on the underlying op, for run launcher and executor routing.
|
|
111
|
+
resource_defs: Resources bound to this asset specifically.
|
|
112
|
+
group_name: Asset group.
|
|
113
|
+
retry_policy: Retry policy for the underlying op.
|
|
114
|
+
code_version: Version string for change-based staleness.
|
|
115
|
+
pool: Concurrency pool the underlying op runs in.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
A decorator producing a `multi_asset` with one out and one check per rule.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
CollectionNotSupportedError: `schema` is a `dy.Collection`.
|
|
122
|
+
|
|
123
|
+
Example:
|
|
124
|
+
>>> import dagster as dg
|
|
125
|
+
>>> import dataframely as dy
|
|
126
|
+
>>> import polars as pl
|
|
127
|
+
>>> import dagster_dataframely as dd
|
|
128
|
+
>>> class Orders(dy.Schema):
|
|
129
|
+
... order_id = dy.String(primary_key=True)
|
|
130
|
+
... amount = dy.Float64(nullable=False, min=0.0)
|
|
131
|
+
>>> @dd.dataframely_asset(schema=Orders, group_name="sales")
|
|
132
|
+
... def orders(raw_orders: pl.DataFrame) -> pl.DataFrame:
|
|
133
|
+
... return raw_orders.select("order_id", "amount")
|
|
134
|
+
"""
|
|
135
|
+
# Deliberately narrow: anything else keeps failing however it already fails.
|
|
136
|
+
if isinstance(schema, type) and issubclass(schema, dy.Collection):
|
|
137
|
+
raise CollectionNotSupportedError(schema.__name__)
|
|
138
|
+
|
|
139
|
+
forwarded: dict[str, Any] = {
|
|
140
|
+
"ins": ins,
|
|
141
|
+
"deps": deps,
|
|
142
|
+
"description": description,
|
|
143
|
+
"config_schema": config_schema,
|
|
144
|
+
"required_resource_keys": required_resource_keys,
|
|
145
|
+
"partitions_def": partitions_def,
|
|
146
|
+
"hooks": hooks,
|
|
147
|
+
"backfill_policy": backfill_policy,
|
|
148
|
+
"op_tags": op_tags,
|
|
149
|
+
"resource_defs": resource_defs,
|
|
150
|
+
"group_name": group_name,
|
|
151
|
+
"retry_policy": retry_policy,
|
|
152
|
+
"code_version": code_version,
|
|
153
|
+
"pool": pool,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
def decorate(fn: TransformFn) -> dg.AssetsDefinition:
|
|
157
|
+
asset_name = name or fn.__name__
|
|
158
|
+
prefix: list[str] = []
|
|
159
|
+
if key_prefix is not None:
|
|
160
|
+
prefix = [key_prefix] if isinstance(key_prefix, str) else list(key_prefix)
|
|
161
|
+
key = dg.AssetKey([*prefix, asset_name])
|
|
162
|
+
|
|
163
|
+
@dg.multi_asset(
|
|
164
|
+
name=asset_name,
|
|
165
|
+
outs={
|
|
166
|
+
asset_name: dg.AssetOut(
|
|
167
|
+
key=key,
|
|
168
|
+
# The gate and the abort path both end the step without yielding.
|
|
169
|
+
is_required=False,
|
|
170
|
+
# The package's two keys are applied last, so a user cannot accidentally displace the Columns tab or the schema carrier.
|
|
171
|
+
metadata={**(metadata or {}), **schema_metadata(schema)},
|
|
172
|
+
io_manager_key=io_manager_key,
|
|
173
|
+
tags=tags,
|
|
174
|
+
owners=owners,
|
|
175
|
+
kinds=set(kinds) if kinds else None,
|
|
176
|
+
automation_condition=automation_condition,
|
|
177
|
+
freshness_policy=freshness_policy,
|
|
178
|
+
)
|
|
179
|
+
},
|
|
180
|
+
check_specs=check_specs(schema, asset=key),
|
|
181
|
+
**forwarded,
|
|
182
|
+
)
|
|
183
|
+
@functools.wraps(fn)
|
|
184
|
+
def compute(*args: object, **kwargs: object) -> AssetYield:
|
|
185
|
+
# No `context` parameter, deliberately: a user-side postponed-annotations import makes Dagster reject a qualified annotation for one.
|
|
186
|
+
yield from process(
|
|
187
|
+
schema,
|
|
188
|
+
fn(*args, **kwargs),
|
|
189
|
+
context=dg.AssetExecutionContext.get(),
|
|
190
|
+
good_out=asset_name,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return compute
|
|
194
|
+
|
|
195
|
+
return decorate
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Asset-check specs derived from a schema, and the results a run reports against them.
|
|
2
|
+
|
|
3
|
+
Specs come off the schema, never off a run's `FailureInfo`. A rule that rejected nothing still gets a spec and still reports `0 failed`, so a clean run is a row in every rule's history rather than a gap in it.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import dagster as dg
|
|
7
|
+
import dataframely as dy
|
|
8
|
+
|
|
9
|
+
from dagster_dataframely.naming import (
|
|
10
|
+
GATE_CHECK,
|
|
11
|
+
check_name,
|
|
12
|
+
rule_description,
|
|
13
|
+
validate_namespace,
|
|
14
|
+
validation_rules,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def check_specs(
|
|
19
|
+
schema: type[dy.Schema],
|
|
20
|
+
*,
|
|
21
|
+
# Not `dg.CoercibleToAssetKey`: typing-only, so absent at runtime.
|
|
22
|
+
asset: str | dg.AssetKey,
|
|
23
|
+
) -> list[dg.AssetCheckSpec]:
|
|
24
|
+
"""Builds one check spec per validation rule, plus the schema gate.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
schema: The schema the checks are derived from.
|
|
28
|
+
asset: The asset key the checks hang off. Build it once and pass the same key to the asset's out, so the two cannot drift.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
The gate spec first, then one spec per rule in the schema's own order.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
ReservedColumnError: A user column sits inside the reserved namespace.
|
|
35
|
+
CheckNameCollisionError: Two rules rewrite to the same check name.
|
|
36
|
+
"""
|
|
37
|
+
validate_namespace(schema)
|
|
38
|
+
gate = dg.AssetCheckSpec(
|
|
39
|
+
GATE_CHECK,
|
|
40
|
+
asset=asset,
|
|
41
|
+
description=f"Columns and dtypes match {schema.__name__}.",
|
|
42
|
+
blocking=True,
|
|
43
|
+
)
|
|
44
|
+
return [
|
|
45
|
+
gate,
|
|
46
|
+
*(
|
|
47
|
+
dg.AssetCheckSpec(
|
|
48
|
+
check_name(rule),
|
|
49
|
+
asset=asset,
|
|
50
|
+
# The rendered-constraint rung of the ladder arrives with #20.
|
|
51
|
+
description=rule_description(schema, rule) or rule,
|
|
52
|
+
)
|
|
53
|
+
for rule in validation_rules(schema)
|
|
54
|
+
),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _rule_results(
|
|
59
|
+
schema: type[dy.Schema],
|
|
60
|
+
counts: dict[str, int],
|
|
61
|
+
*,
|
|
62
|
+
asset_key: dg.AssetKey,
|
|
63
|
+
severity: dg.AssetCheckSeverity,
|
|
64
|
+
) -> list[dg.AssetCheckResult]:
|
|
65
|
+
"""Builds one result per rule from `FailureInfo.counts()`.
|
|
66
|
+
|
|
67
|
+
Severity is the run's outcome rather than the rule's: when nothing lands, no failure is a warning.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
schema: The schema the results report against.
|
|
71
|
+
counts: Failure count per rule; rules that rejected nothing are absent.
|
|
72
|
+
asset_key: The asset the results hang off. Stated explicitly because the abort path yields results on their own, with no materialization to infer it from.
|
|
73
|
+
severity: Severity for every failing result in this run.
|
|
74
|
+
"""
|
|
75
|
+
results: list[dg.AssetCheckResult] = []
|
|
76
|
+
for rule, definition in validation_rules(schema).items():
|
|
77
|
+
failed: int = counts.get(rule, 0)
|
|
78
|
+
metadata: dict[str, str | int] = {
|
|
79
|
+
"dy_rule": rule,
|
|
80
|
+
# The expression, not the bound: tightening `min` must not rename the check and orphan its history.
|
|
81
|
+
"dy_rule__expr": str(definition.expr),
|
|
82
|
+
}
|
|
83
|
+
if failed:
|
|
84
|
+
metadata["dy_failed_count"] = failed
|
|
85
|
+
results.append(
|
|
86
|
+
dg.AssetCheckResult(
|
|
87
|
+
check_name=check_name(rule),
|
|
88
|
+
asset_key=asset_key,
|
|
89
|
+
passed=not failed,
|
|
90
|
+
severity=severity,
|
|
91
|
+
metadata=metadata,
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
return results
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""The package's exception family, all subclassing `DagsterDataframelyError` so they can be caught together.
|
|
2
|
+
|
|
3
|
+
Every message names the schema, the culprit and the fix, because the message is the whole of what a user sees. It carries no colon: Python already prints `ModuleError: ` ahead of it, and a second colon in the first clause reads as a stutter. Each error takes its culprits as data and builds its own message; none of them knows how the culprits were found.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
|
|
8
|
+
import polars as pl
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DagsterDataframelyError(Exception):
|
|
12
|
+
"""Base for every error this package raises."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ReservedColumnError(DagsterDataframelyError):
|
|
16
|
+
"""A user column sits inside the reserved `dy_` namespace.
|
|
17
|
+
|
|
18
|
+
Raised at definition time. Left to runtime, the collision would surface as a check name that quietly means two different things.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, schema_name: str, columns: list[str], prefix: str) -> None:
|
|
22
|
+
"""Names the offending columns only, never the whole schema.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
schema_name: The schema the columns belong to.
|
|
26
|
+
columns: The column names inside the reserved namespace.
|
|
27
|
+
prefix: The reserved prefix itself.
|
|
28
|
+
"""
|
|
29
|
+
culprits = ", ".join(f"'{column}'" for column in columns)
|
|
30
|
+
plural, verb, pronoun = (
|
|
31
|
+
("", "uses", "it") if len(columns) == 1 else ("s", "use", "them")
|
|
32
|
+
)
|
|
33
|
+
super().__init__(
|
|
34
|
+
f"Column{plural} {culprits} of {schema_name} {verb} the reserved "
|
|
35
|
+
f"'{prefix}' prefix. Rename {pronoun}. This package generates every "
|
|
36
|
+
f"check name and quarantine column under that namespace."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CheckNameCollisionError(DagsterDataframelyError):
|
|
41
|
+
"""Two rules rewrite to the same asset-check name.
|
|
42
|
+
|
|
43
|
+
Raised at definition time, ahead of Dagster's own `Duplicate check specs`, which names the collision but not the rules that caused it.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, schema_name: str, first: str, second: str, name: str) -> None:
|
|
47
|
+
"""Names both culprits and the name they collide on.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
schema_name: The schema both rules belong to.
|
|
51
|
+
first: The rule seen first.
|
|
52
|
+
second: The rule that collided with it.
|
|
53
|
+
name: The asset-check name they both rewrite to.
|
|
54
|
+
"""
|
|
55
|
+
super().__init__(
|
|
56
|
+
f"Rules '{first}' and '{second}' of {schema_name} both become "
|
|
57
|
+
f"asset-check name '{name}' after the '|' -> '__' rewrite. "
|
|
58
|
+
f"Rename one of them."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CollectionNotSupportedError(DagsterDataframelyError):
|
|
63
|
+
"""`schema=` received a `dy.Collection`.
|
|
64
|
+
|
|
65
|
+
Raised at decoration time. The guard exists because a Collection is real, adjacent, and the most plausible wrong thing a dataframely user reaches for; it is deliberately not generalised into a type check on `schema=`.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, collection_name: str) -> None:
|
|
69
|
+
"""States the boundary and makes no promise about a future release.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
collection_name: The collection class that was passed.
|
|
73
|
+
"""
|
|
74
|
+
super().__init__(
|
|
75
|
+
f"{collection_name} is a dataframely Collection. This decorator takes a single `dy.Schema`. Declare one asset per member, each with the member's own schema."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class SchemaGateError(DagsterDataframelyError):
|
|
80
|
+
"""A frame arrived with wrong dtypes or missing columns.
|
|
81
|
+
|
|
82
|
+
A pipeline defect rather than a data defect, so the whole asset aborts: no rows are filtered and nothing is written.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, schema_name: str, problems: Sequence[Mapping[str, str]]) -> None:
|
|
86
|
+
"""Names each offending column with its expected and actual dtype.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
schema_name: The schema the frame failed to match.
|
|
90
|
+
problems: One mapping of `column`, `expected` and `actual` per offending column.
|
|
91
|
+
"""
|
|
92
|
+
culprits = ", ".join(
|
|
93
|
+
f"'{problem['column']}' (expected {problem['expected']}, "
|
|
94
|
+
f"got {problem['actual']})"
|
|
95
|
+
for problem in problems
|
|
96
|
+
)
|
|
97
|
+
plural, verb = ("", "does") if len(problems) == 1 else ("s", "do")
|
|
98
|
+
super().__init__(
|
|
99
|
+
f"Column{plural} {culprits} {verb} not match {schema_name}. "
|
|
100
|
+
f"Fix the transform, or cast deliberately with `{schema_name}.cast(frame)` "
|
|
101
|
+
f"in the asset body. This package never casts on your behalf."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ValidationAbortError(DagsterDataframelyError):
|
|
106
|
+
"""Rows were rejected, so the asset writes nothing.
|
|
107
|
+
|
|
108
|
+
Without somewhere to route rejected rows, every row has to be good. Landing the survivors and dropping the rest is the failure this package exists to make visible, so it is not reachable by configuration: a drop is a line the engineer writes in the asset body, the way a cast is.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self, schema_name: str, rejected: int, counts: Mapping[str, int]
|
|
113
|
+
) -> None:
|
|
114
|
+
"""States the damage per rule, and the two fixes that exist today.
|
|
115
|
+
|
|
116
|
+
The fix clause will name `quarantine=` once that out exists (#19). Until then it would send the reader to a keyword argument the door does not accept, and an error message is a promise.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
schema_name: The schema that rejected the rows.
|
|
120
|
+
rejected: How many rows were rejected.
|
|
121
|
+
counts: Failure count per rule, for the rules that rejected anything. The counts can sum past `rejected`, because one row can break several rules.
|
|
122
|
+
"""
|
|
123
|
+
culprits = ", ".join(f"{count} by '{rule}'" for rule, count in counts.items())
|
|
124
|
+
plural = "" if rejected == 1 else "s"
|
|
125
|
+
super().__init__(
|
|
126
|
+
f"{schema_name} rejected {rejected} row{plural}, {culprits}. Nothing "
|
|
127
|
+
f"was written, so the last-known-good table survives. Fix the rows "
|
|
128
|
+
f"upstream, or drop them deliberately in the asset body. This package "
|
|
129
|
+
f"never discards rows on your behalf."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class UnwritableDtypeError(DagsterDataframelyError):
|
|
134
|
+
"""A column holds a dtype the bound IO manager cannot write.
|
|
135
|
+
|
|
136
|
+
Raised from `handle_output` before the write. Left to polars, the same frame fails with a `ComputeError` from inside the writer, or with a Rust panic.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(self, extension: str, columns: Mapping[str, pl.DataType]) -> None:
|
|
140
|
+
"""Names the culprits and the fix.
|
|
141
|
+
|
|
142
|
+
Dagster's wrapping `DagsterExecutionHandleOutputError` already names the step, so naming the asset again here would only repeat it.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
extension: The file extension being written, e.g. `.parquet`.
|
|
146
|
+
columns: The offending column names, mapped to their dtypes.
|
|
147
|
+
"""
|
|
148
|
+
culprits = ", ".join(f"'{name}' ({dtype})" for name, dtype in columns.items())
|
|
149
|
+
plural, pronoun = ("", "it") if len(columns) == 1 else ("s", "them")
|
|
150
|
+
super().__init__(
|
|
151
|
+
f"Column{plural} {culprits} cannot be written to {extension}. "
|
|
152
|
+
f"Convert or drop {pronoun} in the asset body. "
|
|
153
|
+
f"This IO manager never casts on your behalf."
|
|
154
|
+
)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""The package's IO managers.
|
|
2
|
+
|
|
3
|
+
The asset body owns what the data is; the manager owns where and how it lands. So the manager emits only what varied this run: `path`, `bytes_written` and `dagster/storage_kind`.
|
|
4
|
+
|
|
5
|
+
Design decisions:
|
|
6
|
+
- `dagster/column_schema` describes the data, not the write. The asset definition emits it, not the IO manager.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, override
|
|
10
|
+
|
|
11
|
+
import polars as pl
|
|
12
|
+
from dagster import (
|
|
13
|
+
ConfigurableIOManagerFactory,
|
|
14
|
+
DagsterInvariantViolationError,
|
|
15
|
+
MetadataValue,
|
|
16
|
+
UPathIOManager,
|
|
17
|
+
)
|
|
18
|
+
from pydantic import Field
|
|
19
|
+
from upath import UPath
|
|
20
|
+
|
|
21
|
+
from dagster_dataframely.errors import UnwritableDtypeError
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from dagster import InitResourceContext, InputContext, OutputContext
|
|
25
|
+
|
|
26
|
+
_STORAGE_KIND_KEY = "dagster/storage_kind"
|
|
27
|
+
_PARQUET_EXTENSION = ".parquet"
|
|
28
|
+
|
|
29
|
+
# Parquet's only refusal. Polars cannot nest an `Object`, so scanning top-level dtypes is enough.
|
|
30
|
+
_UNWRITABLE_DTYPES: tuple[pl.DataType | type[pl.DataType], ...] = (pl.Object,)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _ParquetIOManager(UPathIOManager):
|
|
34
|
+
"""Writes polars frames to `.parquet`, built by the `DataframelyParquetIOManager` users bind."""
|
|
35
|
+
|
|
36
|
+
extension = _PARQUET_EXTENSION
|
|
37
|
+
|
|
38
|
+
def __init__(self, base_dir: str) -> None:
|
|
39
|
+
"""Roots the manager at `base_dir`, a directory or cloud URI."""
|
|
40
|
+
super().__init__(base_path=UPath(base_dir))
|
|
41
|
+
|
|
42
|
+
@override
|
|
43
|
+
def handle_output(
|
|
44
|
+
self, context: "OutputContext", obj: pl.DataFrame | pl.LazyFrame
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Rejects what parquet cannot represent, then hands the frame to the base manager.
|
|
47
|
+
|
|
48
|
+
Here rather than in `dump_to_path` so that a rejection writes nothing and creates no directory, and not at definition time because an asset cannot know which IO manager it will be bound to.
|
|
49
|
+
"""
|
|
50
|
+
if not isinstance(obj, (pl.DataFrame, pl.LazyFrame)):
|
|
51
|
+
wrong_type = f"This manager writes polars frames, but the output is a {type(obj).__name__}. Annotate the asset `-> None` if it manages its own storage, so that Dagster skips the IO manager entirely."
|
|
52
|
+
raise DagsterInvariantViolationError(wrong_type)
|
|
53
|
+
|
|
54
|
+
unwritable = {
|
|
55
|
+
name: dtype
|
|
56
|
+
for name, dtype in obj.collect_schema().items()
|
|
57
|
+
if dtype in _UNWRITABLE_DTYPES
|
|
58
|
+
}
|
|
59
|
+
if unwritable:
|
|
60
|
+
raise UnwritableDtypeError(extension=_PARQUET_EXTENSION, columns=unwritable)
|
|
61
|
+
|
|
62
|
+
super().handle_output(context, obj)
|
|
63
|
+
|
|
64
|
+
@override
|
|
65
|
+
def dump_to_path(
|
|
66
|
+
self, context: "OutputContext", obj: pl.DataFrame | pl.LazyFrame, path: UPath
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Writes the frame, then stats the path for `bytes_written`.
|
|
69
|
+
|
|
70
|
+
`get_metadata` never sees the path it was written to, so the size is taken here. It comes off the disk, so it reports the compression actually achieved rather than an in-memory estimate.
|
|
71
|
+
"""
|
|
72
|
+
if isinstance(obj, pl.LazyFrame):
|
|
73
|
+
context.log.warning(
|
|
74
|
+
"Collecting a LazyFrame before the write. This manager supports polars DataFrame; "
|
|
75
|
+
"sinking lazily is planned work, tracked in issue #27."
|
|
76
|
+
)
|
|
77
|
+
frame = obj.collect() if isinstance(obj, pl.LazyFrame) else obj
|
|
78
|
+
with path.open("wb") as file:
|
|
79
|
+
frame.write_parquet(file)
|
|
80
|
+
|
|
81
|
+
context.add_output_metadata(
|
|
82
|
+
{
|
|
83
|
+
"bytes_written": MetadataValue.int(path.stat().st_size),
|
|
84
|
+
_STORAGE_KIND_KEY: MetadataValue.text("parquet"),
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@override
|
|
89
|
+
def load_from_path(self, context: "InputContext", path: UPath) -> pl.DataFrame:
|
|
90
|
+
"""Reads the file back eagerly, through the same filesystem the write went out on.
|
|
91
|
+
|
|
92
|
+
`context` is unused: parquet is self-describing, so reading a file needs nothing from the asset definition.
|
|
93
|
+
"""
|
|
94
|
+
with path.open("rb") as file:
|
|
95
|
+
return pl.read_parquet(file)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class DataframelyParquetIOManager(ConfigurableIOManagerFactory[_ParquetIOManager]):
|
|
99
|
+
"""Stores polars frames as `.parquet` files under `base_dir`, locally or in cloud storage.
|
|
100
|
+
|
|
101
|
+
`base_dir` is a universal-pathlib path, so `s3://bucket/prefix`, `gs://...` and `az://...` are written the way a local directory is, on credentials from the ambient environment. A cloud scheme needs its fsspec filesystem installed alongside this package: `s3fs`, `gcsfs` or `adlfs`.
|
|
102
|
+
|
|
103
|
+
Every materialization carries `path`, `bytes_written` and `dagster/storage_kind`, and nothing else: no column schema, no data sample, no statistics pass. A dtype that parquet cannot represent raises `UnwritableDtypeError` before the write.
|
|
104
|
+
|
|
105
|
+
Polars `DataFrame` is the supported type. A `LazyFrame` output is collected before the write, with a warning in the run log, and a read always returns a `DataFrame`. Sinking and scanning lazily is planned work, tracked in issue #27.
|
|
106
|
+
|
|
107
|
+
Attributes:
|
|
108
|
+
base_dir: Directory or cloud URI the manager writes parquet files under.
|
|
109
|
+
|
|
110
|
+
Example:
|
|
111
|
+
>>> import dagster as dg
|
|
112
|
+
>>> import polars as pl
|
|
113
|
+
>>> import dagster_dataframely as dd
|
|
114
|
+
>>> @dg.asset
|
|
115
|
+
... def orders() -> pl.DataFrame:
|
|
116
|
+
... return pl.DataFrame({"order_id": ["a", "b"]})
|
|
117
|
+
>>> defs = dg.Definitions(
|
|
118
|
+
... assets=[orders],
|
|
119
|
+
... resources={
|
|
120
|
+
... "io_manager": dd.DataframelyParquetIOManager(
|
|
121
|
+
... base_dir="s3://my-bucket/warehouse"
|
|
122
|
+
... )
|
|
123
|
+
... },
|
|
124
|
+
... )
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
base_dir: str = Field(
|
|
128
|
+
description="Directory or cloud URI the manager writes parquet files under."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
@override
|
|
132
|
+
def create_io_manager(self, context: "InitResourceContext") -> _ParquetIOManager:
|
|
133
|
+
"""Builds the manager that does the writing.
|
|
134
|
+
|
|
135
|
+
`context` is unused: `base_dir` is the only source of the base path, so the `path` in the event log is always the one the user configured.
|
|
136
|
+
"""
|
|
137
|
+
return _ParquetIOManager(base_dir=self.base_dir)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""What the asset definition declares about its data, before it has ever run.
|
|
2
|
+
|
|
3
|
+
The seam: the asset body owns what the data is, the IO manager owns where and how it landed. A schema is what the data is, so it lives here and the IO manager never emits it.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import dagster as dg
|
|
7
|
+
import dataframely as dy
|
|
8
|
+
|
|
9
|
+
# `@public` upstream, but absent from `dagster` and with no `MetadataValue.object()` factory. Pinned by its own test (#16).
|
|
10
|
+
from dagster._core.definitions.metadata.metadata_value import (
|
|
11
|
+
ObjectMetadataValue,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
_COLUMN_SCHEMA_KEY = "dagster/column_schema"
|
|
15
|
+
SCHEMA_CARRIER_KEY = "dagster_dataframely/schema"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _tags(column: dy.Column) -> dict[str, str] | None:
|
|
19
|
+
"""Renders a column's free-form metadata as Dagster tags.
|
|
20
|
+
|
|
21
|
+
Values are stringified because `TableColumn.tags` is `Mapping[str, str]` and Dagster rejects anything else at definition time. That is a display rendering, not a cast: no data is touched, and refusing instead would mean a `metadata={"pii": False}` dataframely explicitly permits could not be attached to an asset at all.
|
|
22
|
+
"""
|
|
23
|
+
if not column.metadata:
|
|
24
|
+
return None
|
|
25
|
+
return {key: str(value) for key, value in column.metadata.items()}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def table_schema(schema: type[dy.Schema]) -> dg.TableSchema:
|
|
29
|
+
"""Projects a schema onto Dagster's Columns tab.
|
|
30
|
+
|
|
31
|
+
Dtype, description, nullability, uniqueness and tags. Constraint pills and the table-level primary key arrive with the renderer (#20).
|
|
32
|
+
|
|
33
|
+
`unique` is read from the column's own flag and never derived from `primary_key`. dataframely keeps the two independent: a key member gets a composite `as_struct(...).is_unique()` rule and `column.unique` stays `False`, so deriving would claim a per-column uniqueness that nothing enforces.
|
|
34
|
+
|
|
35
|
+
Tags come from `Column.metadata`, which dataframely stores and never reads. It is the one dataframely attribute with no other home here, and free-form key/value annotation is exactly what Dagster's column tags are for.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
schema: The schema to project.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
A table schema whose columns are in the schema's own order.
|
|
42
|
+
"""
|
|
43
|
+
return dg.TableSchema(
|
|
44
|
+
columns=[
|
|
45
|
+
dg.TableColumn(
|
|
46
|
+
name=name,
|
|
47
|
+
type=str(column.dtype),
|
|
48
|
+
description=column.description,
|
|
49
|
+
constraints=dg.TableColumnConstraints(
|
|
50
|
+
nullable=column.nullable, unique=column.unique
|
|
51
|
+
),
|
|
52
|
+
tags=_tags(column),
|
|
53
|
+
)
|
|
54
|
+
for name, column in schema.columns().items()
|
|
55
|
+
]
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def schema_metadata(
|
|
60
|
+
schema: type[dy.Schema],
|
|
61
|
+
) -> dict[str, dg.TableSchema | ObjectMetadataValue]:
|
|
62
|
+
"""Builds the definition metadata a schema-backed asset declares.
|
|
63
|
+
|
|
64
|
+
Two entries: the Columns tab, and the carrier that takes the live schema class to the IO manager on both the write and the read path. The carrier's label is passed explicitly because deriving it would yield the metaclass name, `SchemaMeta`.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
schema: The schema being attached to the asset.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
A mapping to hand to `dg.AssetOut(metadata=...)`.
|
|
71
|
+
|
|
72
|
+
Example:
|
|
73
|
+
>>> import dagster as dg
|
|
74
|
+
>>> import dataframely as dy
|
|
75
|
+
>>> import dagster_dataframely as dd
|
|
76
|
+
>>> class Orders(dy.Schema):
|
|
77
|
+
... order_id = dy.String(primary_key=True)
|
|
78
|
+
>>> out = dg.AssetOut(metadata=dd.schema_metadata(Orders))
|
|
79
|
+
"""
|
|
80
|
+
return {
|
|
81
|
+
_COLUMN_SCHEMA_KEY: table_schema(schema),
|
|
82
|
+
# A raw class here is deprecated upstream, so the carrier is explicit.
|
|
83
|
+
SCHEMA_CARRIER_KEY: ObjectMetadataValue(schema.__name__, instance=schema),
|
|
84
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""The reserved namespace, the rule-name rewrite, and the two definition-time collision errors.
|
|
2
|
+
|
|
3
|
+
`dy_` is hardcoded rather than configurable. A reserved namespace is not a preference; its whole value is being the same string in every project, so a knob would only let one project make its check names unrecognisable to the next.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import inspect
|
|
7
|
+
|
|
8
|
+
import dataframely as dy
|
|
9
|
+
|
|
10
|
+
# Neither has a public equivalent. Pinned by their own tests (#16).
|
|
11
|
+
from dataframely._rule import Rule, RuleFactory
|
|
12
|
+
|
|
13
|
+
from dagster_dataframely.errors import CheckNameCollisionError, ReservedColumnError
|
|
14
|
+
|
|
15
|
+
# Spelled out again wherever a name is built, never interpolated: one grep for `dy_rule__` finds every producer and consumer.
|
|
16
|
+
RESERVED_PREFIX = "dy_"
|
|
17
|
+
|
|
18
|
+
#: The gate check. Present at every granularity, always blocking.
|
|
19
|
+
GATE_CHECK = "dy_schema__dtypes"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check_name(rule_name: str) -> str:
|
|
23
|
+
"""Rewrites a dataframely rule name into an asset-check name.
|
|
24
|
+
|
|
25
|
+
`amount|min` becomes `dy_rule__amount__min`, the same string wherever the rule shows up.
|
|
26
|
+
|
|
27
|
+
The rewrite is forced, not chosen: every check spec becomes an op output named `<asset>_<check>`, and Dagster validates that against `^[A-Za-z0-9_]+$`, which `|` fails.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
rule_name: The rule name dataframely reports, `|`-delimited for column rules.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
The asset-check name, inside the reserved namespace.
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
>>> check_name("amount|min")
|
|
37
|
+
'dy_rule__amount__min'
|
|
38
|
+
>>> check_name("paid_orders_have_amount")
|
|
39
|
+
'dy_rule__paid_orders_have_amount'
|
|
40
|
+
"""
|
|
41
|
+
return f"dy_rule__{rule_name.replace('|', '__')}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def validation_rules(schema: type[dy.Schema]) -> dict[str, Rule]:
|
|
45
|
+
"""Returns the schema's validation rules, keyed by rule name.
|
|
46
|
+
|
|
47
|
+
`with_cast=False` drops the `<column>|dtype` pseudo-rules, which would otherwise duplicate the gate at a different severity and without blocking.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
schema: The schema to read rules from.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Each rule keyed by the name dataframely gives it, `|`-delimited for column rules.
|
|
54
|
+
"""
|
|
55
|
+
return schema._validation_rules(with_cast=False) # noqa: SLF001
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def rule_description(schema: type[dy.Schema], rule_name: str) -> str | None:
|
|
59
|
+
"""Returns a rule's docstring, or `None` for a rule that has no place to carry one.
|
|
60
|
+
|
|
61
|
+
A `@dy.rule()` leaves its `RuleFactory` on the class, so the decorated function and its docstring stay reachable by name long after the metaclass has built the `Rule`. Column rules are generated from column arguments and have no function at all, and their `|` means the lookup misses rather than needing a branch.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
schema: The schema the rule belongs to.
|
|
65
|
+
rule_name: The rule name dataframely reports.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The rule's docstring, dedented, or `None` if it has none.
|
|
69
|
+
"""
|
|
70
|
+
factory = getattr(schema, rule_name, None)
|
|
71
|
+
if not isinstance(factory, RuleFactory):
|
|
72
|
+
return None
|
|
73
|
+
return inspect.getdoc(factory.validation_fn)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def validate_namespace(schema: type[dy.Schema]) -> None:
|
|
77
|
+
"""Raises the two errors Dagster would otherwise report opaquely, or not at all.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
schema: The schema whose columns and rule names are being claimed.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
ReservedColumnError: A user column sits inside the reserved namespace.
|
|
84
|
+
CheckNameCollisionError: Two rules rewrite to the same asset-check name.
|
|
85
|
+
"""
|
|
86
|
+
reserved: list[str] = [
|
|
87
|
+
column for column in schema.columns() if column.startswith(RESERVED_PREFIX)
|
|
88
|
+
]
|
|
89
|
+
if reserved:
|
|
90
|
+
raise ReservedColumnError(schema.__name__, reserved, RESERVED_PREFIX)
|
|
91
|
+
|
|
92
|
+
seen: dict[str, str] = {}
|
|
93
|
+
for rule in validation_rules(schema):
|
|
94
|
+
name: str = check_name(rule)
|
|
95
|
+
if name in seen:
|
|
96
|
+
raise CheckNameCollisionError(schema.__name__, seen[name], rule, name)
|
|
97
|
+
seen[name] = rule
|
|
File without changes
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""The state machine a schema-backed asset runs: gate, filter, then one of three outcomes.
|
|
2
|
+
|
|
3
|
+
The asset's declared shape is the failure policy. There is no lenient/strict flag anywhere, so the failure behaviour is visible in the definition rather than in an argument's value, and it cannot disagree with what the asset actually declares.
|
|
4
|
+
|
|
5
|
+
The two outcomes that need somewhere to put rejected rows arrive with the quarantine (#19).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
|
|
10
|
+
import dagster as dg
|
|
11
|
+
import dataframely as dy
|
|
12
|
+
import polars as pl
|
|
13
|
+
|
|
14
|
+
from dagster_dataframely.checks import _rule_results
|
|
15
|
+
from dagster_dataframely.errors import SchemaGateError, ValidationAbortError
|
|
16
|
+
from dagster_dataframely.naming import GATE_CHECK
|
|
17
|
+
|
|
18
|
+
AssetYield = Iterator[dg.MaterializeResult[pl.DataFrame] | dg.AssetCheckResult]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _require_frame(frame: object, out_name: str) -> None:
|
|
22
|
+
"""Rejects a transform output the gate cannot read.
|
|
23
|
+
|
|
24
|
+
The parameter's annotation is a promise Dagster cannot enforce, because it calls the transform dynamically. Left alone, a forgotten return annotation surfaces two frames down as `'NoneType' object has no attribute 'collect_schema'`.
|
|
25
|
+
|
|
26
|
+
Dagster's own error rather than the package's: this is a wiring mistake, not a data one, which is the line `_ParquetIOManager` already draws.
|
|
27
|
+
"""
|
|
28
|
+
if isinstance(frame, (pl.DataFrame, pl.LazyFrame)):
|
|
29
|
+
return
|
|
30
|
+
wrong_type = (
|
|
31
|
+
f"'{out_name}' returned a {type(frame).__name__}. A schema-backed asset must "
|
|
32
|
+
f"return a polars DataFrame or LazyFrame, because the gate reads its columns "
|
|
33
|
+
f"and dtypes before anything is written. An asset that manages its own storage "
|
|
34
|
+
f"has no schema to validate, so write it as a plain `@dg.asset`."
|
|
35
|
+
)
|
|
36
|
+
raise dg.DagsterInvariantViolationError(wrong_type)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _gate_problems(
|
|
40
|
+
schema: type[dy.Schema], frame: pl.DataFrame | pl.LazyFrame
|
|
41
|
+
) -> list[dict[str, str]]:
|
|
42
|
+
"""Compares the frame's shape against the schema, naming every mismatch.
|
|
43
|
+
|
|
44
|
+
An explicit pre-check rather than a `try`/`except` around `filter`, which would behave differently depending on what the transform returned: `filter(cast=False)` raises at call time on a `DataFrame`, but on a `LazyFrame` it returns cleanly and the same error surfaces only on the eventual collect. The door promises either return type works, so the gate cannot be built on a difference between them.
|
|
45
|
+
|
|
46
|
+
Only public API, and none of it executes: `collect_schema()` resolves a `LazyFrame`'s shape without running it.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
One mapping of `column`, `expected` and `actual` per offending column, empty when the frame matches. The same list feeds the failing check's metadata and `SchemaGateError`, so the two cannot disagree.
|
|
50
|
+
"""
|
|
51
|
+
actual = frame.collect_schema()
|
|
52
|
+
return [
|
|
53
|
+
{
|
|
54
|
+
"column": name,
|
|
55
|
+
"expected": str(column.dtype),
|
|
56
|
+
"actual": str(actual[name]) if name in actual else "<missing>",
|
|
57
|
+
}
|
|
58
|
+
for name, column in schema.columns().items()
|
|
59
|
+
if name not in actual or not column.validate_dtype(actual[name])
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _gate_failure(
|
|
64
|
+
problems: list[dict[str, str]], *, asset_key: dg.AssetKey
|
|
65
|
+
) -> dg.AssetCheckResult:
|
|
66
|
+
"""Builds the failing gate check, tabulating every offending column."""
|
|
67
|
+
return dg.AssetCheckResult(
|
|
68
|
+
check_name=GATE_CHECK,
|
|
69
|
+
asset_key=asset_key,
|
|
70
|
+
passed=False,
|
|
71
|
+
severity=dg.AssetCheckSeverity.ERROR,
|
|
72
|
+
metadata={
|
|
73
|
+
"dy_schema__errors": dg.MetadataValue.table(
|
|
74
|
+
[dg.TableRecord(problem) for problem in problems]
|
|
75
|
+
)
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _check_results(
|
|
81
|
+
schema: type[dy.Schema], failure: dy.FailureInfo, *, asset_key: dg.AssetKey
|
|
82
|
+
) -> list[dg.AssetCheckResult]:
|
|
83
|
+
"""Builds every check result for a run that made it past the gate.
|
|
84
|
+
|
|
85
|
+
Severity is derived here, once, from whether the run rejected anything. That is what makes it a property of the run's outcome rather than of any one rule: no code path can hand two sibling checks different severities.
|
|
86
|
+
"""
|
|
87
|
+
severity = (
|
|
88
|
+
dg.AssetCheckSeverity.ERROR if len(failure) else dg.AssetCheckSeverity.WARN
|
|
89
|
+
)
|
|
90
|
+
return [
|
|
91
|
+
dg.AssetCheckResult(check_name=GATE_CHECK, asset_key=asset_key, passed=True),
|
|
92
|
+
*_rule_results(
|
|
93
|
+
schema, failure.counts(), asset_key=asset_key, severity=severity
|
|
94
|
+
),
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def process(
|
|
99
|
+
schema: type[dy.Schema],
|
|
100
|
+
frame: pl.DataFrame | pl.LazyFrame,
|
|
101
|
+
*,
|
|
102
|
+
context: dg.AssetExecutionContext,
|
|
103
|
+
good_out: str,
|
|
104
|
+
) -> AssetYield:
|
|
105
|
+
"""Validates a transform's output and reports it to Dagster.
|
|
106
|
+
|
|
107
|
+
Two stages and three exits. The gate runs first, so a wrong-shaped frame never pays to be filtered. Then `Schema.filter(frame, cast=False)` splits the rows: it is the only validation call, because `validate()` carries per-rule detail as a string and this package needs structured counts.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
schema: The schema the frame must satisfy.
|
|
111
|
+
frame: Whatever the transform returned.
|
|
112
|
+
context: The executing asset's context, for resolving the out to its key.
|
|
113
|
+
good_out: The output name the validated frame materializes under.
|
|
114
|
+
|
|
115
|
+
Yields:
|
|
116
|
+
A `MaterializeResult` carrying the good frame and every check result, or, on either failure path, the check results on their own.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
DagsterInvariantViolationError: The transform returned something that is not a polars frame.
|
|
120
|
+
SchemaGateError: The frame's columns or dtypes do not match the schema.
|
|
121
|
+
ValidationAbortError: Rows were rejected and no quarantine is declared.
|
|
122
|
+
"""
|
|
123
|
+
_require_frame(frame, good_out)
|
|
124
|
+
good_key = context.asset_key_for_output(good_out)
|
|
125
|
+
|
|
126
|
+
# --- Stage 1: the schema gate ---
|
|
127
|
+
problems = _gate_problems(schema, frame)
|
|
128
|
+
if problems:
|
|
129
|
+
# Exit: pipeline defect. Nothing is filtered and nothing is written, so a wrong-shaped frame cannot corrupt the table.
|
|
130
|
+
yield _gate_failure(problems, asset_key=good_key)
|
|
131
|
+
raise SchemaGateError(schema.__name__, problems)
|
|
132
|
+
|
|
133
|
+
# --- Stage 2: the row filter ---
|
|
134
|
+
# Eager, not lazy: `filter` already collected, and `row_count` needs the length.
|
|
135
|
+
result, failure = schema.filter(frame, cast=False)
|
|
136
|
+
good = result.collect() if isinstance(result, pl.LazyFrame) else result
|
|
137
|
+
checks = _check_results(schema, failure, asset_key=good_key)
|
|
138
|
+
rejected = len(failure)
|
|
139
|
+
|
|
140
|
+
if rejected:
|
|
141
|
+
# Exit: data defect with no quarantine declared, so consent to partial data was never given.
|
|
142
|
+
# Both halves are discarded and the last-known-good table survives, but every rule still reports, so the failed run says what failed and by how much.
|
|
143
|
+
# The two quarantine exits land here with #19.
|
|
144
|
+
yield from checks
|
|
145
|
+
raise ValidationAbortError(schema.__name__, rejected, failure.counts())
|
|
146
|
+
|
|
147
|
+
# Exit: everything survived. The only path that materializes.
|
|
148
|
+
yield dg.MaterializeResult(
|
|
149
|
+
asset_key=good_key,
|
|
150
|
+
value=good,
|
|
151
|
+
metadata={"dagster/row_count": len(good)},
|
|
152
|
+
check_results=checks,
|
|
153
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dagster-dataframely
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A Dataframely plugin for Dagster.
|
|
5
|
+
Keywords: dagster,data,data-quality,dataframely,polars,validation
|
|
6
|
+
Author: Ozan Ozbeker
|
|
7
|
+
Author-email: Ozan Ozbeker <github@ozanozbeker.com>
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 1 - Planning
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Database
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Dist: dagster>=1.13.16
|
|
23
|
+
Requires-Dist: dataframely>=3.0.0
|
|
24
|
+
Requires-Dist: polars>=1.43.2
|
|
25
|
+
Requires-Dist: pydantic>=2
|
|
26
|
+
Requires-Dist: universal-pathlib>=0.2.0
|
|
27
|
+
Maintainer: Ozan Ozbeker
|
|
28
|
+
Maintainer-email: Ozan Ozbeker <github@ozanozbeker.com>
|
|
29
|
+
Requires-Python: >=3.12
|
|
30
|
+
Project-URL: Homepage, https://github.com/ozanozbeker/dagster-dataframely
|
|
31
|
+
Project-URL: Repository, https://github.com/ozanozbeker/dagster-dataframely
|
|
32
|
+
Project-URL: Issues, https://github.com/ozanozbeker/dagster-dataframely/issues
|
|
33
|
+
Project-URL: Changelog, https://github.com/ozanozbeker/dagster-dataframely/releases
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# dagster-dataframely
|
|
37
|
+
|
|
38
|
+
Dataframely plugin for Dagster.
|
|
39
|
+
|
|
40
|
+
> [!WARNING]
|
|
41
|
+
> **Pre-release placeholder.**
|
|
42
|
+
> This package is under active design and ships no functionality yet.
|
|
43
|
+
> The name is reserved on PyPI while the design spec is finalized.
|
|
44
|
+
> Do not depend on it.
|
|
45
|
+
> Follow [issue #1](https://github.com/ozanozbeker/dagster-dataframely/issues/1) for progress.
|
|
46
|
+
|
|
47
|
+
## What it will do
|
|
48
|
+
|
|
49
|
+
[dataframely](https://github.com/Quantco/dataframely) declares schemas and validation rules for [polars](https://pola.rs) data frames.
|
|
50
|
+
[Dagster](https://dagster.io) has first-class surfaces for data contracts: column schema metadata and asset checks.
|
|
51
|
+
This package connects the two:
|
|
52
|
+
|
|
53
|
+
- **Asset checks derived from your schema.**
|
|
54
|
+
Each dataframely rule becomes a Dagster asset check, so per-rule pass/fail history shows up in the catalog without hand-writing checks.
|
|
55
|
+
- **Column schema metadata.**
|
|
56
|
+
Your schema populates Dagster's Columns tab automatically.
|
|
57
|
+
- **Quarantine, opt-in.**
|
|
58
|
+
Declare a second output and failing rows are routed there with per-rule attribution instead of failing the run.
|
|
59
|
+
- **Storage in the box.**
|
|
60
|
+
`DataframelyParquetIOManager` writes `.parquet` to a local directory or to `s3://`, `gs://` and `az://`, and it is the supported path.
|
|
61
|
+
|
|
62
|
+
Dependencies are `dagster` and `dataframely` (plus `polars`) only.
|
|
63
|
+
The IO manager imports `universal-pathlib` and `pydantic` directly, so both are declared too.
|
|
64
|
+
Both already ship with `dagster`, so nothing new lands in your environment.
|
|
65
|
+
|
|
66
|
+
## Installation
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
uv add dagster-dataframely
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Requires Python 3.12+.
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
[Apache-2.0](LICENSE)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
dagster_dataframely/__init__.py,sha256=WLz0W4e18yJUmWE5jPrM50mKYK-U58dVTkMClIqVSxI,953
|
|
2
|
+
dagster_dataframely/asset.py,sha256=MRZi1W1Zg0sc257kJ_C5hlmQKzvSK-quoldTnf4jBsI,10181
|
|
3
|
+
dagster_dataframely/checks.py,sha256=VQi52wG0tY8V4pXdBM8yB8MP2m3wWnKTb7fRmmBUqE0,3255
|
|
4
|
+
dagster_dataframely/errors.py,sha256=nkxWcf47OmEZPmh8AhZLgoNkdKcWIm4tBQCiyB0e_xM,7134
|
|
5
|
+
dagster_dataframely/io_managers.py,sha256=U7Ct4A7ukpvnrGbUFojCsCHP6Jcfevja84gFgGfnbzM,5953
|
|
6
|
+
dagster_dataframely/metadata.py,sha256=8uknv9GoIeGuM70TPW4oPEhONNWdPRkU_T3TDqHOrPI,3645
|
|
7
|
+
dagster_dataframely/naming.py,sha256=HRZrdP64CBhxVYH_GwKbaGkDUq9_uQpkn2_1gD8U0CU,3852
|
|
8
|
+
dagster_dataframely/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
dagster_dataframely/runtime.py,sha256=3P2DSwngDmiPWrvsdR2Kj8GYLnRdFvEgCgb3nUhROs0,7044
|
|
10
|
+
dagster_dataframely-0.0.1.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
11
|
+
dagster_dataframely-0.0.1.dist-info/WHEEL,sha256=EmLkUISDECbcUx3FMCYOqokNOJqNp2r0d4mJzjErvvs,80
|
|
12
|
+
dagster_dataframely-0.0.1.dist-info/METADATA,sha256=JIrCBBrHv-TKypYnlHlh9e2zDuBenLQMbOlCYNsAEcU,3098
|
|
13
|
+
dagster_dataframely-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright [yyyy] [name of copyright owner]
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|