babelqueue 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.
- babelqueue/__init__.py +33 -0
- babelqueue/codec.py +96 -0
- babelqueue/contracts.py +31 -0
- babelqueue/dead_letter.py +39 -0
- babelqueue/exceptions.py +11 -0
- babelqueue/py.typed +0 -0
- babelqueue/routing.py +14 -0
- babelqueue-0.1.0.dist-info/METADATA +133 -0
- babelqueue-0.1.0.dist-info/RECORD +11 -0
- babelqueue-0.1.0.dist-info/WHEEL +4 -0
- babelqueue-0.1.0.dist-info/licenses/LICENSE +21 -0
babelqueue/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""BabelQueue — Polyglot Queues, Simplified.
|
|
2
|
+
|
|
3
|
+
The framework-agnostic Python core: the canonical wire-envelope codec, contracts,
|
|
4
|
+
and dead-letter helpers. Framework adapters (Celery, Django, ...) build on this.
|
|
5
|
+
|
|
6
|
+
from babelqueue import EnvelopeCodec
|
|
7
|
+
|
|
8
|
+
payload = EnvelopeCodec.make("urn:babel:orders:created", {"order_id": 1042})
|
|
9
|
+
body = EnvelopeCodec.encode(payload) # send `body` over Redis/RabbitMQ
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from . import dead_letter
|
|
15
|
+
from .codec import SCHEMA_VERSION, SOURCE_LANG, EnvelopeCodec
|
|
16
|
+
from .contracts import HasTraceId, PolyglotMessage
|
|
17
|
+
from .exceptions import BabelQueueError, UnknownUrnError
|
|
18
|
+
from .routing import UnknownUrnStrategy
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"EnvelopeCodec",
|
|
24
|
+
"SCHEMA_VERSION",
|
|
25
|
+
"SOURCE_LANG",
|
|
26
|
+
"PolyglotMessage",
|
|
27
|
+
"HasTraceId",
|
|
28
|
+
"UnknownUrnStrategy",
|
|
29
|
+
"BabelQueueError",
|
|
30
|
+
"UnknownUrnError",
|
|
31
|
+
"dead_letter",
|
|
32
|
+
"__version__",
|
|
33
|
+
]
|
babelqueue/codec.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""The canonical BabelQueue wire envelope — the single Python implementation.
|
|
2
|
+
|
|
3
|
+
The shape is frozen as ``{job, trace_id, data, meta, attempts}`` (schema_version 1)
|
|
4
|
+
so a Python service interoperates byte-for-byte with the PHP/Laravel, Go, ... SDKs.
|
|
5
|
+
The ``job`` field carries the message URN (never a class name); ``trace_id`` is a
|
|
6
|
+
cross-service correlation id preserved across every hop. Pure stdlib — no deps.
|
|
7
|
+
|
|
8
|
+
Full spec: https://babelqueue.com
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from typing import Any, Dict, Mapping, Optional
|
|
17
|
+
|
|
18
|
+
from .exceptions import BabelQueueError
|
|
19
|
+
|
|
20
|
+
SCHEMA_VERSION = 1
|
|
21
|
+
SOURCE_LANG = "python"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EnvelopeCodec:
|
|
25
|
+
"""Builds, encodes and decodes the canonical envelope."""
|
|
26
|
+
|
|
27
|
+
SCHEMA_VERSION = SCHEMA_VERSION
|
|
28
|
+
SOURCE_LANG = SOURCE_LANG
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def make(
|
|
32
|
+
urn: str,
|
|
33
|
+
data: Mapping[str, Any],
|
|
34
|
+
*,
|
|
35
|
+
queue: str = "default",
|
|
36
|
+
trace_id: Optional[str] = None,
|
|
37
|
+
) -> Dict[str, Any]:
|
|
38
|
+
"""Build the canonical envelope for a ``(urn, data)`` pair.
|
|
39
|
+
|
|
40
|
+
``trace_id`` is reused when given (trace continuation), otherwise a fresh
|
|
41
|
+
UUID is minted. ``attempts`` starts at 0 and is the top-level transport
|
|
42
|
+
counter (kept out of the immutable ``meta`` block).
|
|
43
|
+
"""
|
|
44
|
+
urn = (urn or "").strip()
|
|
45
|
+
if not urn:
|
|
46
|
+
raise BabelQueueError(
|
|
47
|
+
"A polyglot message must expose a stable, non-empty URN so consumers "
|
|
48
|
+
"can identify it without any class name."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
trace_id = (trace_id or "").strip() or str(uuid.uuid4())
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
"job": urn,
|
|
55
|
+
"trace_id": trace_id,
|
|
56
|
+
"data": dict(data),
|
|
57
|
+
"meta": {
|
|
58
|
+
"id": str(uuid.uuid4()),
|
|
59
|
+
"queue": queue,
|
|
60
|
+
"lang": SOURCE_LANG,
|
|
61
|
+
"schema_version": SCHEMA_VERSION,
|
|
62
|
+
"created_at": int(time.time() * 1000),
|
|
63
|
+
},
|
|
64
|
+
"attempts": 0,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def from_message(message: Any, queue: str = "default") -> Dict[str, Any]:
|
|
69
|
+
"""Build the envelope from a message object (see :class:`PolyglotMessage`).
|
|
70
|
+
|
|
71
|
+
If the message also exposes ``get_babel_trace_id()`` (see
|
|
72
|
+
:class:`HasTraceId`) and returns a non-empty value, that trace id is reused.
|
|
73
|
+
"""
|
|
74
|
+
get_trace = getattr(message, "get_babel_trace_id", None)
|
|
75
|
+
trace_id = get_trace() if callable(get_trace) else None
|
|
76
|
+
|
|
77
|
+
return EnvelopeCodec.make(
|
|
78
|
+
message.get_babel_urn(),
|
|
79
|
+
message.to_payload(),
|
|
80
|
+
queue=queue,
|
|
81
|
+
trace_id=trace_id,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def encode(envelope: Mapping[str, Any]) -> str:
|
|
86
|
+
"""Encode the envelope as compact UTF-8 JSON (unescaped unicode/slashes)."""
|
|
87
|
+
return json.dumps(envelope, ensure_ascii=False, separators=(",", ":"))
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def decode(raw: str) -> Dict[str, Any]:
|
|
91
|
+
"""Decode a raw JSON body; returns ``{}`` for malformed/non-object input."""
|
|
92
|
+
try:
|
|
93
|
+
decoded = json.loads(raw)
|
|
94
|
+
except (ValueError, TypeError):
|
|
95
|
+
return {}
|
|
96
|
+
return decoded if isinstance(decoded, dict) else {}
|
babelqueue/contracts.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Typed, duck-typing-friendly contracts for polyglot messages.
|
|
2
|
+
|
|
3
|
+
These mirror the other SDKs' contracts so a typed message class can be checked,
|
|
4
|
+
but the codec also accepts plain ``(urn, data)`` — Python does not require a class.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Mapping, Optional, Protocol, runtime_checkable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@runtime_checkable
|
|
13
|
+
class PolyglotMessage(Protocol):
|
|
14
|
+
"""A producible message: a stable URN plus a pure, JSON-serialisable payload."""
|
|
15
|
+
|
|
16
|
+
def get_babel_urn(self) -> str:
|
|
17
|
+
"""The message URN (e.g. ``urn:babel:orders:created``), never a class name."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
def to_payload(self) -> Mapping[str, Any]:
|
|
21
|
+
"""The pure, JSON-serialisable payload carried under the envelope ``data``."""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@runtime_checkable
|
|
26
|
+
class HasTraceId(Protocol):
|
|
27
|
+
"""Optional: lets a message continue an existing distributed trace."""
|
|
28
|
+
|
|
29
|
+
def get_babel_trace_id(self) -> Optional[str]:
|
|
30
|
+
"""An inherited trace id to reuse, or ``None`` to mint a new one."""
|
|
31
|
+
...
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Builds the additive ``dead_letter`` block attached when a message is
|
|
2
|
+
dead-lettered. Pure: returns an annotated copy; the original identity
|
|
3
|
+
(trace_id, meta.id, data) is preserved. Because the field is additive and
|
|
4
|
+
optional, the envelope stays at schema_version 1. See https://babelqueue.com.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Dict, Mapping, Optional
|
|
11
|
+
|
|
12
|
+
from .codec import SOURCE_LANG
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def annotate(
|
|
16
|
+
envelope: Mapping[str, Any],
|
|
17
|
+
reason: str,
|
|
18
|
+
original_queue: str,
|
|
19
|
+
attempts: int,
|
|
20
|
+
*,
|
|
21
|
+
error: Optional[str] = None,
|
|
22
|
+
exception: Optional[str] = None,
|
|
23
|
+
lang: str = SOURCE_LANG,
|
|
24
|
+
) -> Dict[str, Any]:
|
|
25
|
+
"""Return a copy of ``envelope`` with a ``dead_letter`` block.
|
|
26
|
+
|
|
27
|
+
``reason`` is one of ``failed`` | ``unknown_urn`` | ``poison``.
|
|
28
|
+
"""
|
|
29
|
+
result = dict(envelope)
|
|
30
|
+
result["dead_letter"] = {
|
|
31
|
+
"reason": reason,
|
|
32
|
+
"error": error,
|
|
33
|
+
"exception": exception,
|
|
34
|
+
"failed_at": int(time.time() * 1000),
|
|
35
|
+
"original_queue": original_queue,
|
|
36
|
+
"attempts": attempts,
|
|
37
|
+
"lang": lang,
|
|
38
|
+
}
|
|
39
|
+
return result
|
babelqueue/exceptions.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Exception hierarchy for BabelQueue."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BabelQueueError(Exception):
|
|
7
|
+
"""Base for every recoverable BabelQueue error (bad config, empty URN, ...)."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UnknownUrnError(BabelQueueError):
|
|
11
|
+
"""A consumed message carries a URN with no mapped handler (strategy "fail")."""
|
babelqueue/py.typed
ADDED
|
File without changes
|
babelqueue/routing.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Unknown-URN strategy names, shared with every other SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class UnknownUrnStrategy:
|
|
7
|
+
"""What a consumer does with a message whose URN has no mapped handler."""
|
|
8
|
+
|
|
9
|
+
FAIL = "fail"
|
|
10
|
+
DELETE = "delete"
|
|
11
|
+
RELEASE = "release"
|
|
12
|
+
DEAD_LETTER = "dead_letter"
|
|
13
|
+
|
|
14
|
+
ALL = (FAIL, DELETE, RELEASE, DEAD_LETTER)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: babelqueue
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers.
|
|
5
|
+
Project-URL: Homepage, https://babelqueue.com
|
|
6
|
+
Project-URL: Source, https://github.com/BabelQueue/babelqueue-python
|
|
7
|
+
Project-URL: Issues, https://github.com/BabelQueue/babelqueue-python/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/BabelQueue/babelqueue-python/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Muhammet Şafak <info@muhammetsafak.com.tr>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: envelope,json,messaging,microservices,polyglot,queue
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Provides-Extra: amqp
|
|
26
|
+
Requires-Dist: pika>=1.3; extra == 'amqp'
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
29
|
+
Provides-Extra: redis
|
|
30
|
+
Requires-Dist: redis>=4; extra == 'redis'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# BabelQueue for Python
|
|
34
|
+
|
|
35
|
+
[](https://github.com/BabelQueue/babelqueue-python/actions/workflows/ci.yml)
|
|
36
|
+
[](https://pypi.org/project/babelqueue/)
|
|
37
|
+
[](https://pypi.org/project/babelqueue/)
|
|
38
|
+
[](LICENSE)
|
|
39
|
+
|
|
40
|
+
> **Polyglot Queues, Simplified.** Read and write the canonical BabelQueue message
|
|
41
|
+
> envelope from Python — so your Python services (AI/ML, data processing, …)
|
|
42
|
+
> exchange messages with Laravel, Symfony, Go, .NET and Node over one strict JSON
|
|
43
|
+
> format, on the broker you already run.
|
|
44
|
+
|
|
45
|
+
This is the framework-agnostic **Python core**: the wire-envelope codec,
|
|
46
|
+
contracts, and dead-letter helpers — **zero runtime dependencies** (standard
|
|
47
|
+
library only). The full standard is documented at
|
|
48
|
+
**[babelqueue.com](https://babelqueue.com)**.
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install babelqueue
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Requires Python `>=3.9`.
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from babelqueue import EnvelopeCodec
|
|
62
|
+
|
|
63
|
+
# Produce — build the canonical envelope and publish the JSON to your broker.
|
|
64
|
+
envelope = EnvelopeCodec.make("urn:babel:orders:created", {"order_id": 1042})
|
|
65
|
+
body = EnvelopeCodec.encode(envelope) # -> UTF-8 JSON string
|
|
66
|
+
# redis.rpush("queues:orders", body) / channel.basic_publish(body=body, ...)
|
|
67
|
+
|
|
68
|
+
# Consume — decode a message produced by ANY BabelQueue SDK.
|
|
69
|
+
incoming = EnvelopeCodec.decode(body)
|
|
70
|
+
urn = incoming["job"] # "urn:babel:orders:created"
|
|
71
|
+
data = incoming["data"] # {"order_id": 1042}
|
|
72
|
+
trace_id = incoming["trace_id"] # correlate across services
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The envelope is identical to every other SDK's:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"job": "urn:babel:orders:created",
|
|
80
|
+
"trace_id": "…",
|
|
81
|
+
"data": { "order_id": 1042 },
|
|
82
|
+
"meta": { "id": "…", "queue": "default", "lang": "python", "schema_version": 1, "created_at": 1749132727000 },
|
|
83
|
+
"attempts": 0
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Typed messages (optional)
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from babelqueue import EnvelopeCodec, PolyglotMessage
|
|
91
|
+
|
|
92
|
+
class OrderCreated: # structurally a PolyglotMessage
|
|
93
|
+
def __init__(self, order_id: int):
|
|
94
|
+
self.order_id = order_id
|
|
95
|
+
def get_babel_urn(self) -> str:
|
|
96
|
+
return "urn:babel:orders:created"
|
|
97
|
+
def to_payload(self) -> dict:
|
|
98
|
+
return {"order_id": self.order_id}
|
|
99
|
+
|
|
100
|
+
envelope = EnvelopeCodec.from_message(OrderCreated(1042), queue="orders")
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Continue an existing trace by adding `get_babel_trace_id(self) -> str | None`
|
|
104
|
+
(see `HasTraceId`), or pass `trace_id=` to `EnvelopeCodec.make`.
|
|
105
|
+
|
|
106
|
+
### Dead-letter
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from babelqueue import dead_letter
|
|
110
|
+
|
|
111
|
+
dlq = dead_letter.annotate(envelope, "failed", "orders", attempts=3, error="boom")
|
|
112
|
+
# publish `EnvelopeCodec.encode(dlq)` to the "orders.dlq" queue
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## What's here vs. coming
|
|
116
|
+
|
|
117
|
+
- **Now (this package):** the codec, contracts, dead-letter and unknown-URN
|
|
118
|
+
helpers, plus the shared conformance fixtures. Bring your own broker client.
|
|
119
|
+
- **Next (planned):** a built-in runtime — `BabelQueue(broker_url=...)` with an
|
|
120
|
+
`@app.handler("urn:…")` decorator over `redis`/`pika` — and **Celery** / **Django**
|
|
121
|
+
adapters. Install via extras (`babelqueue[redis]`, `babelqueue[celery]`, …).
|
|
122
|
+
|
|
123
|
+
## Testing
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
pip install -e ".[dev]"
|
|
127
|
+
pytest
|
|
128
|
+
# (or, dependency-free) python -m unittest discover -s tests
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT © Muhammet Şafak. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
babelqueue/__init__.py,sha256=7h4_jURrYdOcIIul38ck2FRFjipvMyXRJkTpFw466i4,940
|
|
2
|
+
babelqueue/codec.py,sha256=l54YTPC9ScieUqjQh-eEoQ3oEq20JoSmy2Q00zebynQ,3218
|
|
3
|
+
babelqueue/contracts.py,sha256=zl7137t2agSdKnGjUDxajSXR8xLQTaAsqWEY69zDG0A,1030
|
|
4
|
+
babelqueue/dead_letter.py,sha256=BbNJF6E9BsLom6iOsVTOABbKQwLRWLztsaxmyebmqK0,1112
|
|
5
|
+
babelqueue/exceptions.py,sha256=R6p4tPmRRYbEUTvf9baBdmDvvyKhl5xubqk-NGhhD-w,325
|
|
6
|
+
babelqueue/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
babelqueue/routing.py,sha256=WRTEKrBWd9Tm0BVLm_axwNLA1F5_ljRQyFaGiCuhmB8,351
|
|
8
|
+
babelqueue-0.1.0.dist-info/METADATA,sha256=o6Q5Mns9r0tzbD2efLj37gQTIhOLkQRCgFwLWM-1SuA,4895
|
|
9
|
+
babelqueue-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
10
|
+
babelqueue-0.1.0.dist-info/licenses/LICENSE,sha256=OJMGANMbwEJV5fkqnS8MDTiZJp9ZizRpOo4HYWR0m6E,1072
|
|
11
|
+
babelqueue-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Muhammet Şafak
|
|
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.
|