ddredis 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.
- ddredis/__init__.py +3 -0
- ddredis/cache.py +141 -0
- ddredis/decorators.py +21 -0
- ddredis/lock.py +65 -0
- ddredis/py.typed +0 -0
- ddredis-0.0.1.dist-info/METADATA +137 -0
- ddredis-0.0.1.dist-info/RECORD +9 -0
- ddredis-0.0.1.dist-info/WHEEL +4 -0
- ddredis-0.0.1.dist-info/licenses/LICENSE +21 -0
ddredis/__init__.py
ADDED
ddredis/cache.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from abc import ABC
|
|
2
|
+
from functools import cached_property
|
|
3
|
+
from random import randint
|
|
4
|
+
from typing import ClassVar, Protocol, Self, TypeVar, cast
|
|
5
|
+
|
|
6
|
+
from ddutils.class_helpers import get_generic_base_argument
|
|
7
|
+
from ddutils.convertors import convert_camel_case_to_snake_case
|
|
8
|
+
from redis.asyncio import Redis
|
|
9
|
+
|
|
10
|
+
from ddredis.decorators import suppress_redis_errors
|
|
11
|
+
|
|
12
|
+
JITTER_PERCENT = 10
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Serializable(Protocol):
|
|
16
|
+
"""Protocol for objects that can be serialized/deserialized to/from JSON."""
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def model_validate_json(cls, json_data: str | bytes) -> Self: ...
|
|
20
|
+
|
|
21
|
+
def model_dump_json(self) -> str: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Stringable(Protocol):
|
|
25
|
+
def __str__(self) -> str: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GenericCache[DomainT: Serializable](ABC):
|
|
29
|
+
"""
|
|
30
|
+
GenericCache: An async generic caching repository for managing domain objects in Redis.
|
|
31
|
+
|
|
32
|
+
This class provides a base implementation for caching domain objects in Redis.
|
|
33
|
+
It supports CRUD operations (`get`, `create`, `update`, `delete`)
|
|
34
|
+
and uses JSON serialization/deserialization to store and retrieve data.
|
|
35
|
+
|
|
36
|
+
Domain class must implement `model_validate_json` and `model_dump_json` methods
|
|
37
|
+
(compatible with Pydantic, msgspec, or custom implementations).
|
|
38
|
+
|
|
39
|
+
Class Attributes:
|
|
40
|
+
ttl (int): Time-to-live for cache entries in seconds. Default is 5 minutes.
|
|
41
|
+
redis_client (Redis): Redis client instance.
|
|
42
|
+
|
|
43
|
+
Example Usage:
|
|
44
|
+
redis_client = Redis.from_url('redis://localhost/0')
|
|
45
|
+
|
|
46
|
+
class MyDomain(BaseModel):
|
|
47
|
+
id: int
|
|
48
|
+
name: str
|
|
49
|
+
|
|
50
|
+
class MyDomainCache(GenericCache[MyDomain]):
|
|
51
|
+
ttl = 10 * 60 # 10 minutes
|
|
52
|
+
redis_client = redis_client
|
|
53
|
+
|
|
54
|
+
cache = MyDomainCache()
|
|
55
|
+
|
|
56
|
+
domain = MyDomain(id=1, name="Test")
|
|
57
|
+
await cache.create(key=domain.id, value=domain)
|
|
58
|
+
retrieved = await cache.get(key=1)
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
_domain_class: ClassVar[type[Serializable]]
|
|
62
|
+
|
|
63
|
+
ttl: ClassVar[int] = 5 * 60 # 5 minutes
|
|
64
|
+
redis_client: ClassVar[Redis]
|
|
65
|
+
|
|
66
|
+
def __init_subclass__(cls, **kwargs):
|
|
67
|
+
super().__init_subclass__(**kwargs)
|
|
68
|
+
|
|
69
|
+
domain_class = get_generic_base_argument(cls, GenericCache)
|
|
70
|
+
if domain_class is None or isinstance(domain_class, TypeVar):
|
|
71
|
+
raise TypeError(f'{cls.__name__} must specify domain type: class {cls.__name__}(GenericCache[YourDomain])')
|
|
72
|
+
|
|
73
|
+
cls._domain_class = domain_class
|
|
74
|
+
|
|
75
|
+
@cached_property
|
|
76
|
+
def _key_prefix(self) -> str:
|
|
77
|
+
return convert_camel_case_to_snake_case(self._domain_class.__name__)
|
|
78
|
+
|
|
79
|
+
@cached_property
|
|
80
|
+
def _jitter(self) -> int:
|
|
81
|
+
return self.ttl * JITTER_PERCENT // 100
|
|
82
|
+
|
|
83
|
+
def _generate_key(self, key: Stringable) -> str:
|
|
84
|
+
"""Generates a Redis key for the given identifier with the domain-specific prefix."""
|
|
85
|
+
return f':{self._key_prefix}:{key!s}'
|
|
86
|
+
|
|
87
|
+
def _generate_ttl(self) -> int:
|
|
88
|
+
"""Generates a TTL value with jitter."""
|
|
89
|
+
return self.ttl + randint(-self._jitter, self._jitter)
|
|
90
|
+
|
|
91
|
+
async def _get(self, key: str) -> DomainT | None:
|
|
92
|
+
"""Reads and deserializes a domain object by its already-prefixed Redis key."""
|
|
93
|
+
cached_data = await self.redis_client.get(key)
|
|
94
|
+
if not cached_data:
|
|
95
|
+
return None
|
|
96
|
+
try:
|
|
97
|
+
return cast('DomainT', self._domain_class.model_validate_json(cached_data))
|
|
98
|
+
except Exception: # noqa: BLE001
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
@suppress_redis_errors
|
|
102
|
+
async def get(self, key: Stringable) -> DomainT | None:
|
|
103
|
+
"""Retrieves and deserializes a domain object from the cache by its key."""
|
|
104
|
+
return await self._get(self._generate_key(key))
|
|
105
|
+
|
|
106
|
+
@suppress_redis_errors
|
|
107
|
+
async def create(self, key: Stringable, value: DomainT) -> None:
|
|
108
|
+
"""Caches a new domain object or overwrites an existing one. Sets a TTL for the entry."""
|
|
109
|
+
_key = self._generate_key(key)
|
|
110
|
+
await self.redis_client.set(_key, value.model_dump_json(), ex=self._generate_ttl())
|
|
111
|
+
|
|
112
|
+
@suppress_redis_errors
|
|
113
|
+
async def update(self, key: Stringable, value: DomainT) -> None:
|
|
114
|
+
"""Updates a domain object in the cache. Alias for `create`."""
|
|
115
|
+
await self.create(key=key, value=value)
|
|
116
|
+
|
|
117
|
+
@suppress_redis_errors
|
|
118
|
+
async def delete(self, key: Stringable) -> None:
|
|
119
|
+
"""Removes a domain object from the cache by its key."""
|
|
120
|
+
await self.redis_client.delete(self._generate_key(key))
|
|
121
|
+
|
|
122
|
+
@suppress_redis_errors
|
|
123
|
+
async def get_list(self, key_prefix: Stringable) -> list[DomainT]:
|
|
124
|
+
"""Returns all cached domain objects whose keys start with the given prefix."""
|
|
125
|
+
pattern = f'{self._generate_key(key_prefix)}*'
|
|
126
|
+
result: list[DomainT] = []
|
|
127
|
+
async for redis_key in self.redis_client.scan_iter(match=pattern):
|
|
128
|
+
item = await self._get(redis_key)
|
|
129
|
+
if item is not None:
|
|
130
|
+
result.append(item)
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
@suppress_redis_errors
|
|
134
|
+
async def delete_by_filters(self, key_prefix: Stringable) -> None:
|
|
135
|
+
"""Removes all cached domain objects whose keys start with the given prefix."""
|
|
136
|
+
pattern = f'{self._generate_key(key_prefix)}*'
|
|
137
|
+
async for redis_key in self.redis_client.scan_iter(match=pattern):
|
|
138
|
+
await self.redis_client.delete(redis_key)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
__all__ = ('GenericCache', 'Serializable', 'Stringable')
|
ddredis/decorators.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
|
+
from functools import wraps
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from redis.exceptions import RedisError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def suppress_redis_errors[T](func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T | None]]:
|
|
9
|
+
"""Decorator to suppress Redis exceptions and return None on failure."""
|
|
10
|
+
|
|
11
|
+
@wraps(func)
|
|
12
|
+
async def wrapper(*args: Any, **kwargs: Any) -> T | None:
|
|
13
|
+
try:
|
|
14
|
+
return await func(*args, **kwargs)
|
|
15
|
+
except RedisError:
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
return wrapper
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
__all__ = ('suppress_redis_errors',)
|
ddredis/lock.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
from typing import Self
|
|
3
|
+
|
|
4
|
+
from redis.asyncio import Redis
|
|
5
|
+
from redis.asyncio.lock import Lock, LockNotOwnedError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AlreadyAcquiredError(Exception): ...
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RedisLock:
|
|
12
|
+
"""
|
|
13
|
+
Distributed lock backed by Redis (`SET NX PX` + Lua release).
|
|
14
|
+
|
|
15
|
+
Caveat: `timeout` is the Redis-side TTL of the key — not a client-side cancellation
|
|
16
|
+
timer. If the protected code runs longer than `timeout`, Redis silently removes the
|
|
17
|
+
key and a competing caller can acquire the same lock and execute in parallel,
|
|
18
|
+
violating mutual exclusion. The original code keeps running unaware. `release()`
|
|
19
|
+
swallows `LockNotOwnedError` for this case, so the cleanup is silent — but the
|
|
20
|
+
invariant has already been broken upstream.
|
|
21
|
+
|
|
22
|
+
To avoid this, either pick a `timeout` that comfortably exceeds the worst-case
|
|
23
|
+
runtime of the block, or call `extend()` periodically (heartbeat) for long
|
|
24
|
+
operations whose duration is hard to predict.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, redis_client: Redis, key: str, timeout: float = 10.0, blocking: bool = False):
|
|
28
|
+
blocking_timeout = timeout if blocking else None
|
|
29
|
+
self.timeout = timeout
|
|
30
|
+
self.lock = Lock(
|
|
31
|
+
redis=redis_client,
|
|
32
|
+
name=key,
|
|
33
|
+
timeout=timeout,
|
|
34
|
+
blocking=blocking,
|
|
35
|
+
blocking_timeout=blocking_timeout,
|
|
36
|
+
thread_local=False,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
async def acquire(self) -> None:
|
|
40
|
+
acquired = await self.lock.acquire()
|
|
41
|
+
|
|
42
|
+
if not acquired:
|
|
43
|
+
raise AlreadyAcquiredError('Lock is already acquired')
|
|
44
|
+
|
|
45
|
+
async def release(self) -> None:
|
|
46
|
+
with contextlib.suppress(LockNotOwnedError):
|
|
47
|
+
await self.lock.release()
|
|
48
|
+
|
|
49
|
+
async def extend(self) -> None:
|
|
50
|
+
# `replace_ttl=True` because every caller means this as a heartbeat: the lock should
|
|
51
|
+
# outlive the holder by `timeout`, counted from now. redis-py's default ADDS to the
|
|
52
|
+
# remaining TTL, so a long-lived holder beating steadily would push the expiry further
|
|
53
|
+
# out with every beat — and a lock whose holder is killed would then sit unreleased for
|
|
54
|
+
# as long as it had been beating.
|
|
55
|
+
await self.lock.extend(self.timeout, replace_ttl=True)
|
|
56
|
+
|
|
57
|
+
async def __aenter__(self) -> Self:
|
|
58
|
+
await self.acquire()
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
62
|
+
await self.release()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
__all__ = ('AlreadyAcquiredError', 'RedisLock')
|
ddredis/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ddredis
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Redis helpers: generic JSON cache and distributed lock
|
|
5
|
+
Keywords: python,redis,cache,lock,ddredis
|
|
6
|
+
Author: davyddd
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Dist: redis>=5.0,<9
|
|
15
|
+
Requires-Dist: ddutils>=0.2.2,<0.3.0
|
|
16
|
+
Requires-Python: >=3.12, <3.15
|
|
17
|
+
Project-URL: Repository, https://github.com/davyddd/ddredis
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# ddredis
|
|
21
|
+
|
|
22
|
+
[](https://pypi.python.org/pypi/ddredis)
|
|
23
|
+
[](https://pepy.tech/project/ddredis)
|
|
24
|
+
[](https://github.com/davyddd/ddredis)
|
|
25
|
+
[](https://app.codecov.io/github/davyddd/ddredis)
|
|
26
|
+
[](https://github.com/davyddd/ddredis/blob/main/LICENSE)
|
|
27
|
+
|
|
28
|
+
Redis helpers: generic JSON cache and distributed lock
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
Install the library using pip:
|
|
33
|
+
```bash
|
|
34
|
+
pip install ddredis
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Development
|
|
38
|
+
|
|
39
|
+
The project runs entirely in Docker. Requires Docker and [Fabric](https://www.fabfile.org/) on the host:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
fab build # build the dev image
|
|
43
|
+
fab tests # run pytest
|
|
44
|
+
fab linters # run ruff (with --fix), ty and complexipy
|
|
45
|
+
fab shell # IPython inside the container
|
|
46
|
+
fab bash # bash inside the container
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
This project was generated from [dd-lib-stub](https://github.com/davyddd/dd-lib-stub);
|
|
50
|
+
run `copier update` to pull in template updates.
|
|
51
|
+
|
|
52
|
+
## GenericCache
|
|
53
|
+
|
|
54
|
+
Caches domain objects in Redis as JSON. The domain class must provide `model_validate_json` and
|
|
55
|
+
`model_dump_json` (pydantic models do), keys are prefixed with the snake-cased domain name, every entry gets
|
|
56
|
+
the class `ttl` with ±10% jitter so a burst of writes does not expire at once, and Redis errors turn into
|
|
57
|
+
"not found" instead of failing the caller.
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from typing import ClassVar
|
|
61
|
+
|
|
62
|
+
from pydantic import BaseModel
|
|
63
|
+
from redis.asyncio import Redis
|
|
64
|
+
|
|
65
|
+
from ddredis.cache import GenericCache
|
|
66
|
+
|
|
67
|
+
redis_client = Redis.from_url('redis://localhost/0')
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Profile(BaseModel):
|
|
71
|
+
profile_id: int
|
|
72
|
+
name: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ProfileCache(GenericCache[Profile]):
|
|
76
|
+
ttl = 10 * 60 # seconds
|
|
77
|
+
redis_client: ClassVar[Redis] = redis_client
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
cache = ProfileCache()
|
|
81
|
+
|
|
82
|
+
await cache.create(key=1, value=Profile(profile_id=1, name='John')) # SET :profile:1 ... EX ttl±10%
|
|
83
|
+
profile = await cache.get(key=1) # Profile | None
|
|
84
|
+
await cache.update(key=1, value=profile) # alias for create
|
|
85
|
+
await cache.delete(key=1)
|
|
86
|
+
|
|
87
|
+
await cache.get_list(key_prefix='team-a') # every :profile:team-a* entry
|
|
88
|
+
await cache.delete_by_filters(key_prefix='team-a')
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Redis errors are swallowed on purpose: for a cache, "unavailable" and "miss" lead to the same fallback. Do
|
|
92
|
+
not use the cache for state where `None` has a meaning of its own. Transient failures (a worker that was just
|
|
93
|
+
reloaded, a dropped connection) belong to the client, not to the cache: redis-py retries the errors listed in
|
|
94
|
+
`retry_on_error` with its default `Retry` (3 attempts, exponential backoff with jitter), so a production client
|
|
95
|
+
looks like this:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from redis.asyncio import Redis
|
|
99
|
+
from redis.exceptions import ConnectionError, TimeoutError
|
|
100
|
+
|
|
101
|
+
redis_client = Redis.from_url(
|
|
102
|
+
'redis://localhost/0',
|
|
103
|
+
max_connections=20,
|
|
104
|
+
socket_connect_timeout=5,
|
|
105
|
+
socket_timeout=1,
|
|
106
|
+
retry_on_error=[ConnectionError, TimeoutError],
|
|
107
|
+
decode_responses=True,
|
|
108
|
+
)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## RedisLock
|
|
112
|
+
|
|
113
|
+
Distributed lock on `SET NX PX` with a Lua release (redis-py's `Lock`, `thread_local=False`). Acquiring a
|
|
114
|
+
held lock raises `AlreadyAcquiredError` unless `blocking=True`, in which case it waits up to `timeout`.
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from ddredis.lock import AlreadyAcquiredError, RedisLock
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
async with RedisLock(redis_client, 'sync-profiles', timeout=30):
|
|
121
|
+
await sync_profiles()
|
|
122
|
+
except AlreadyAcquiredError:
|
|
123
|
+
pass # another worker is on it
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`timeout` is the Redis-side TTL of the key, not a client-side cancellation: if the block runs longer, Redis
|
|
127
|
+
drops the key and a competitor may enter while the first holder is still running. Pick a timeout that
|
|
128
|
+
comfortably exceeds the worst-case runtime, or call `extend()` periodically as a heartbeat. `extend()`
|
|
129
|
+
replaces the TTL with the full `timeout` counted from now rather than adding to the remaining time, so a
|
|
130
|
+
long-lived holder beating steadily never pushes its expiry further out. `release()` tolerates a lock that
|
|
131
|
+
already expired.
|
|
132
|
+
|
|
133
|
+
## suppress_redis_errors
|
|
134
|
+
|
|
135
|
+
Decorator for async functions: `RedisError` subclasses become a `None` result, everything else propagates.
|
|
136
|
+
`GenericCache` methods use it; apply it to your own Redis reads that should degrade instead of failing.
|
|
137
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
ddredis/__init__.py,sha256=Lo082hCxyyT6OBJ4HQS5JkZ27KFIJsb6oh0uE1KA5FY,81
|
|
2
|
+
ddredis/cache.py,sha256=GhbrH5mJVkero5d6yUWYyWcqgl3xldhHgd8wdTiuQY8,5266
|
|
3
|
+
ddredis/decorators.py,sha256=tKnh3Hmh6qPkKp5dXu6WXAbpzxX5FuiXH8MGVQ2Tj0Q,572
|
|
4
|
+
ddredis/lock.py,sha256=5SiySSHnGCf2NfwLZNKvMrLCVceHUsc-2Id1G75j20Q,2449
|
|
5
|
+
ddredis/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
ddredis-0.0.1.dist-info/licenses/LICENSE,sha256=_DkcY418J4TyqQWxa0QjLzg1jCl1NiR-XE_UyK-Rv6g,1070
|
|
7
|
+
ddredis-0.0.1.dist-info/WHEEL,sha256=lrO5MD1WVAWzcbNy_L2BwtfPrcM3KfUFpbKYXLkJX4A,80
|
|
8
|
+
ddredis-0.0.1.dist-info/METADATA,sha256=JXcUxjeUHVPbKgydPOMnkDuYYQ26uqEuVSEi-LJzWKc,5093
|
|
9
|
+
ddredis-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Artem Davydov
|
|
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.
|