pydagu 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.
- pydagu/__init__.py +7 -0
- pydagu/builder.py +508 -0
- pydagu/http.py +88 -0
- pydagu/models/__init__.py +59 -0
- pydagu/models/base.py +12 -0
- pydagu/models/dag.py +219 -0
- pydagu/models/executor.py +183 -0
- pydagu/models/handlers.py +30 -0
- pydagu/models/infrastructure.py +71 -0
- pydagu/models/notifications.py +26 -0
- pydagu/models/request.py +14 -0
- pydagu/models/response.py +82 -0
- pydagu/models/step.py +144 -0
- pydagu/models/types.py +16 -0
- pydagu-0.1.0.dist-info/METADATA +196 -0
- pydagu-0.1.0.dist-info/RECORD +18 -0
- pydagu-0.1.0.dist-info/WHEEL +4 -0
- pydagu-0.1.0.dist-info/licenses/LICENSE +21 -0
pydagu/models/step.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Step configuration models"""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Literal, Self
|
|
5
|
+
from pydantic import BaseModel, Field, model_validator
|
|
6
|
+
|
|
7
|
+
from pydagu.models.base import Precondition
|
|
8
|
+
from pydagu.models.executor import ExecutorConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RetryPolicy(BaseModel):
|
|
12
|
+
"""Retry policy for a step"""
|
|
13
|
+
|
|
14
|
+
limit: int = Field(
|
|
15
|
+
ge=0, description="Maximum number of retries", examples=[3, 5, 10]
|
|
16
|
+
)
|
|
17
|
+
intervalSec: int = Field(
|
|
18
|
+
ge=0, description="Interval between retries in seconds", examples=[30, 60, 300]
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ContinueOn(BaseModel):
|
|
23
|
+
"""Configuration for continuing execution on specific conditions"""
|
|
24
|
+
|
|
25
|
+
failure: bool | None = Field(None, description="Continue on failure")
|
|
26
|
+
skipped: bool | None = Field(None, description="Continue on skipped")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ParallelConfig(BaseModel):
|
|
30
|
+
"""Configuration for parallel step execution"""
|
|
31
|
+
|
|
32
|
+
items: list[str] = Field(
|
|
33
|
+
description="Items to process in parallel",
|
|
34
|
+
examples=[
|
|
35
|
+
["customers", "orders", "products"],
|
|
36
|
+
["file1.csv", "file2.csv", "file3.csv"],
|
|
37
|
+
],
|
|
38
|
+
)
|
|
39
|
+
maxConcurrent: int | None = Field(
|
|
40
|
+
None, ge=1, description="Maximum concurrent items", examples=[2, 5, 10]
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Step(BaseModel):
|
|
45
|
+
"""A step in the DAG"""
|
|
46
|
+
|
|
47
|
+
model_config = {
|
|
48
|
+
"json_schema_extra": {
|
|
49
|
+
"anyOf": [{"required": ["command"]}, {"required": ["script"]}],
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
name: str | None = Field(
|
|
54
|
+
None, description="Step name", examples=["extract-data", "validate-environment"]
|
|
55
|
+
)
|
|
56
|
+
description: str | None = Field(
|
|
57
|
+
None,
|
|
58
|
+
description="Step description",
|
|
59
|
+
examples=["Extract data from source database"],
|
|
60
|
+
)
|
|
61
|
+
command: str | None = Field(
|
|
62
|
+
None,
|
|
63
|
+
description="Command to execute",
|
|
64
|
+
examples=["python extract.py --date=${DATE}", "echo 'Processing...'"],
|
|
65
|
+
)
|
|
66
|
+
script: str | None = Field(
|
|
67
|
+
None,
|
|
68
|
+
description="Script path to execute",
|
|
69
|
+
examples=["./scripts/process.sh", "process.py"],
|
|
70
|
+
)
|
|
71
|
+
depends: str | list[str] | None = Field(
|
|
72
|
+
None,
|
|
73
|
+
description="Dependencies (step names)",
|
|
74
|
+
examples=["validate-environment", ["extract", "transform"]],
|
|
75
|
+
)
|
|
76
|
+
output: str | None = Field(
|
|
77
|
+
None,
|
|
78
|
+
description="Output variable name",
|
|
79
|
+
examples=["RAW_DATA_PATH", "RESULT_COUNT"],
|
|
80
|
+
)
|
|
81
|
+
params: str | list[str] | None = Field(
|
|
82
|
+
None,
|
|
83
|
+
description="Parameters for the step",
|
|
84
|
+
examples=[
|
|
85
|
+
"TYPE=${ITEM} INPUT=${RAW_DATA_PATH}",
|
|
86
|
+
["--verbose", "--config=prod"],
|
|
87
|
+
],
|
|
88
|
+
)
|
|
89
|
+
dir: str | None = Field(
|
|
90
|
+
None, description="Working directory", examples=["/data/workspace", "./scripts"]
|
|
91
|
+
)
|
|
92
|
+
executor: ExecutorConfig | None = Field(
|
|
93
|
+
None, description="Custom executor for this step"
|
|
94
|
+
)
|
|
95
|
+
continueOn: ContinueOn | None = Field(
|
|
96
|
+
None, description="Continue execution conditions"
|
|
97
|
+
)
|
|
98
|
+
retryPolicy: RetryPolicy | None = Field(None, description="Retry policy")
|
|
99
|
+
repeatPolicy: dict[str, Any] | None = Field(None, description="Repeat policy")
|
|
100
|
+
mailOnError: bool | None = Field(None, description="Send email on error")
|
|
101
|
+
preconditions: list[Precondition] | None = Field(
|
|
102
|
+
None, description="Step-level preconditions"
|
|
103
|
+
)
|
|
104
|
+
signalOnStop: (
|
|
105
|
+
Literal["SIGTERM", "SIGINT", "SIGKILL", "SIGHUP", "SIGQUIT"] | None
|
|
106
|
+
) = Field(
|
|
107
|
+
None,
|
|
108
|
+
description="Signal to send on stop",
|
|
109
|
+
examples=["SIGTERM", "SIGKILL", "SIGINT"],
|
|
110
|
+
)
|
|
111
|
+
parallel: ParallelConfig | None = Field(
|
|
112
|
+
None, description="Parallel execution configuration"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
@model_validator(mode="after")
|
|
116
|
+
def validate_step_has_action(self: Self) -> Self:
|
|
117
|
+
"""Validate that step has at least one of: command or script"""
|
|
118
|
+
if not (self.command or self.script):
|
|
119
|
+
raise ValueError(
|
|
120
|
+
"Step must have at least one of: command or script. "
|
|
121
|
+
"Examples: command='echo hello', script='./run.sh'"
|
|
122
|
+
)
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
@model_validator(mode="after")
|
|
126
|
+
def validate_http_executor_command(self: Self) -> Self:
|
|
127
|
+
"""Validate that HTTP executor steps have command in 'METHOD URL' format"""
|
|
128
|
+
if self.executor and self.executor.type == "http" and self.command:
|
|
129
|
+
# HTTP executor requires command in format: "METHOD URL"
|
|
130
|
+
# Valid methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS
|
|
131
|
+
# URL can be a literal or a Dagu parameter like ${WEBHOOK_URL}
|
|
132
|
+
http_method_pattern = re.compile(
|
|
133
|
+
r"^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(https?://\S+|\$\{[A-Z_]+\})",
|
|
134
|
+
re.IGNORECASE,
|
|
135
|
+
)
|
|
136
|
+
if not http_method_pattern.match(self.command):
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"HTTP executor command must be in format 'METHOD URL'. "
|
|
139
|
+
f"Got: '{self.command}'. "
|
|
140
|
+
f"Examples: 'GET https://api.example.com/data', "
|
|
141
|
+
f"'POST https://api.example.com/webhook', "
|
|
142
|
+
f"'POST ${{WEBHOOK_URL}}' (using Dagu parameter)"
|
|
143
|
+
)
|
|
144
|
+
return self
|
pydagu/models/types.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from typing import Annotated, TypeAlias
|
|
2
|
+
|
|
3
|
+
from pydantic import BeforeValidator
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _empty_str_to_none(v: str | None) -> None:
|
|
7
|
+
if v is None:
|
|
8
|
+
return None
|
|
9
|
+
if v == "":
|
|
10
|
+
return None
|
|
11
|
+
raise ValueError(
|
|
12
|
+
"Value is not empty"
|
|
13
|
+
) # Not str or None, Fall to next type. e.g. Decimal, or a non-empty str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
EmptyStrToNone: TypeAlias = Annotated[None, BeforeValidator(_empty_str_to_none)]
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pydagu
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client library for Dagu - A cron alternative with a Web UI
|
|
5
|
+
Project-URL: Homepage, https://github.com/yourusername/pydagu
|
|
6
|
+
Project-URL: Documentation, https://github.com/yourusername/pydagu#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/yourusername/pydagu
|
|
8
|
+
Project-URL: Issues, https://github.com/yourusername/pydagu/issues
|
|
9
|
+
Author-email: Patrick Dobbs <patrick.dobbs@gmail.com>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: cron,dag,dagu,scheduler,workflow
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Topic :: System :: Monitoring
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: httpx>=0.28.1
|
|
23
|
+
Requires-Dist: pydantic>=2.12.3
|
|
24
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: hypothesis-jsonschema>=0.23.1; extra == 'dev'
|
|
27
|
+
Requires-Dist: import-linter>=2.5.2; extra == 'dev'
|
|
28
|
+
Requires-Dist: pre-commit>=4.3.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-cov>=7.0.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=8.4.2; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.14.3; extra == 'dev'
|
|
32
|
+
Requires-Dist: schemathesis>=4.3.18; extra == 'dev'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# pydagu
|
|
36
|
+
|
|
37
|
+
[](https://badge.fury.io/py/pydagu)
|
|
38
|
+
[](https://github.com/yourusername/pydagu/actions/workflows/test.yml)
|
|
39
|
+
[](https://pypi.org/project/pydagu/)
|
|
40
|
+
[](https://opensource.org/licenses/MIT)
|
|
41
|
+
|
|
42
|
+
A Python client library for [Dagu](https://github.com/dagu-org/dagu) - providing type-safe DAG creation and HTTP API interaction with Pydantic validation.
|
|
43
|
+
|
|
44
|
+
## Features
|
|
45
|
+
|
|
46
|
+
- 🎯 **Type-safe**: Built with Pydantic models for full type safety and validation
|
|
47
|
+
- 🏗️ **Builder Pattern**: Fluent API for constructing DAGs and steps
|
|
48
|
+
- 🔌 **HTTP Client**: Complete client for Dagu's HTTP API
|
|
49
|
+
- 🔄 **Webhook Support**: Built-in patterns for webhook integration
|
|
50
|
+
- ✅ **Well-tested**: Comprehensive test suite with 95%+ coverage
|
|
51
|
+
- 📝 **Examples**: Production-ready integration examples
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install pydagu
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Prerequisites
|
|
60
|
+
|
|
61
|
+
You need a running [Dagu server](https://github.com/dagu-org/dagu) to run the tests. Install Dagu:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# macOS
|
|
65
|
+
brew install dagu-org/brew/dagu
|
|
66
|
+
|
|
67
|
+
# Linux
|
|
68
|
+
curl -L https://github.com/dagu-org/dagu/releases/latest/download/dagu_linux_amd64.tar.gz | tar xz
|
|
69
|
+
sudo mv dagu /usr/local/bin/
|
|
70
|
+
|
|
71
|
+
# Start the server
|
|
72
|
+
dagu server
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Quick Start
|
|
76
|
+
|
|
77
|
+
### Create and Run a Simple DAG
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from pydagu.builder import DagBuilder, StepBuilder
|
|
81
|
+
from pydagu.http import DaguHttpClient
|
|
82
|
+
from pydagu.models.request import StartDagRun
|
|
83
|
+
|
|
84
|
+
# Initialize client
|
|
85
|
+
client = DaguHttpClient(
|
|
86
|
+
dag_name="my-first-dag",
|
|
87
|
+
url_root="http://localhost:8080/api/v2/"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Build a DAG
|
|
91
|
+
dag = (
|
|
92
|
+
DagBuilder("my-first-dag")
|
|
93
|
+
.description("My first DAG with pydagu")
|
|
94
|
+
.add_step_models(
|
|
95
|
+
StepBuilder("hello-world")
|
|
96
|
+
.command("echo 'Hello from pydagu!'")
|
|
97
|
+
.build()
|
|
98
|
+
)
|
|
99
|
+
.build()
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Post the DAG to Dagu
|
|
103
|
+
client.post_dag(dag)
|
|
104
|
+
|
|
105
|
+
# Start a run
|
|
106
|
+
dag_run_id = client.start_dag_run(StartDagRun(dagName=dag.name))
|
|
107
|
+
|
|
108
|
+
# Check status
|
|
109
|
+
status = client.get_dag_run_status(dag_run_id.dagRunId)
|
|
110
|
+
print(f"Status: {status.statusLabel}")
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### HTTP Executor with Retry
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from pydagu.builder import StepBuilder
|
|
117
|
+
|
|
118
|
+
step = (
|
|
119
|
+
StepBuilder("api-call")
|
|
120
|
+
.command("POST https://api.example.com/webhook")
|
|
121
|
+
.http_executor(
|
|
122
|
+
headers={
|
|
123
|
+
"Content-Type": "application/json",
|
|
124
|
+
"Authorization": "Bearer ${API_TOKEN}"
|
|
125
|
+
},
|
|
126
|
+
body={"event": "user.created", "user_id": "123"},
|
|
127
|
+
timeout=30
|
|
128
|
+
)
|
|
129
|
+
.retry(limit=3, interval=5)
|
|
130
|
+
.build()
|
|
131
|
+
)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Chained Steps with Dependencies
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from pydagu.builder import DagBuilder, StepBuilder
|
|
138
|
+
|
|
139
|
+
dag = (
|
|
140
|
+
DagBuilder("data-pipeline")
|
|
141
|
+
.add_step_models(
|
|
142
|
+
StepBuilder("extract")
|
|
143
|
+
.command("python extract_data.py")
|
|
144
|
+
.output("EXTRACTED_FILE")
|
|
145
|
+
.build(),
|
|
146
|
+
|
|
147
|
+
StepBuilder("transform")
|
|
148
|
+
.command("python transform_data.py ${EXTRACTED_FILE}")
|
|
149
|
+
.depends_on("extract")
|
|
150
|
+
.output("TRANSFORMED_FILE")
|
|
151
|
+
.build(),
|
|
152
|
+
|
|
153
|
+
StepBuilder("load")
|
|
154
|
+
.command("python load_data.py ${TRANSFORMED_FILE}")
|
|
155
|
+
.depends_on("transform")
|
|
156
|
+
.build()
|
|
157
|
+
)
|
|
158
|
+
.build()
|
|
159
|
+
)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Development
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
# Clone the repository
|
|
166
|
+
git clone https://github.com/yourusername/pydagu.git
|
|
167
|
+
cd pydagu
|
|
168
|
+
|
|
169
|
+
# Install with dev dependencies
|
|
170
|
+
pip install -e ".[dev]"
|
|
171
|
+
|
|
172
|
+
# Run tests (requires Dagu server running)
|
|
173
|
+
pytest tests/ -v
|
|
174
|
+
|
|
175
|
+
# Run with coverage
|
|
176
|
+
pytest tests/ --cov=pydagu --cov-report=html
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Documentation
|
|
180
|
+
|
|
181
|
+
- [Dagu Documentation](https://dagu.readthedocs.io/)
|
|
182
|
+
- [Webhook Integration Guide](examples/README.md)
|
|
183
|
+
- [API Reference](https://github.com/yourusername/pydagu/wiki)
|
|
184
|
+
|
|
185
|
+
## Contributing
|
|
186
|
+
|
|
187
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
188
|
+
|
|
189
|
+
## License
|
|
190
|
+
|
|
191
|
+
MIT License - see [LICENSE](LICENSE) file for details.
|
|
192
|
+
|
|
193
|
+
## Related Projects
|
|
194
|
+
|
|
195
|
+
- [Dagu](https://github.com/dagu-org/dagu) - The underlying DAG execution engine
|
|
196
|
+
- [Airflow](https://airflow.apache.org/) - Alternative workflow orchestration platform
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
pydagu/__init__.py,sha256=kfDenuf6m3o0UNuxUuETSCkRJdoQQacVRp8khYDiKpw,218
|
|
2
|
+
pydagu/builder.py,sha256=_GfMrzaxHxYBKPxGaZsNwooCvqkjgK7pB_6ZZsXB6oU,16486
|
|
3
|
+
pydagu/http.py,sha256=pd25yx39sjHrQnXegWFS-bf_yEdiOVKJiewlpgi-gNw,3101
|
|
4
|
+
pydagu/models/__init__.py,sha256=5QMhVLCjHE_err-9cq6oIUfLpKT8zou3g8jm62Vw9LY,1388
|
|
5
|
+
pydagu/models/base.py,sha256=sPZ0ysADgTDegnTiE2rgsyqezAJLbleGdyJ_dTEYOp8,339
|
|
6
|
+
pydagu/models/dag.py,sha256=QvZBPif8yfzy5wn3TWnuDpqGNcCUuKMqBjKtfF1Zytw,7965
|
|
7
|
+
pydagu/models/executor.py,sha256=8WZd85JXKXQEL8_dyEt8XEWs-7yip4Xn_QRsUPPnPks,5934
|
|
8
|
+
pydagu/models/handlers.py,sha256=oWC4OKGpMzzqTr3lxQiA08BuodDtyLYu8SkgLMaLR4Q,945
|
|
9
|
+
pydagu/models/infrastructure.py,sha256=O9QCX7PwiAwcznQUQ5YNGjbBoyY6qM_RpG6vvB9Ojh4,2147
|
|
10
|
+
pydagu/models/notifications.py,sha256=F58k8FAdvIF0pQalvvk3UVypaDQ3eNUlZ7unM17f6yk,837
|
|
11
|
+
pydagu/models/request.py,sha256=I8b10lN7WfY9onQaGIInGMujalqJwqSTcthrtqexCco,313
|
|
12
|
+
pydagu/models/response.py,sha256=GkYDBKdMDmbd1EiQJVFoyt5UudRXYC_eMwUydSqqdpQ,1596
|
|
13
|
+
pydagu/models/step.py,sha256=jeErnStYPtA_9LByJrsEoPGo6DdmZlp7iq_O0SRkkNQ,5146
|
|
14
|
+
pydagu/models/types.py,sha256=7baIATh_m4FH5OcsMLcG1HKh4nVpv0hcHdoOEg6Vw90,413
|
|
15
|
+
pydagu-0.1.0.dist-info/METADATA,sha256=FUWiOOguZTOBrTWtygo-K3E4MBv1_2aPdMOIlSGCiy4,5611
|
|
16
|
+
pydagu-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
17
|
+
pydagu-0.1.0.dist-info/licenses/LICENSE,sha256=TaXqim4LU9XdIufnAraWqxqVFmiyS2mwbi0K8QJszlA,1081
|
|
18
|
+
pydagu-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Patrick [Your Last Name]
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|