arroyopy 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.
arroyopy/__init__.py ADDED
File without changes
arroyopy/listener.py ADDED
@@ -0,0 +1,14 @@
1
+ import asyncio
2
+ from abc import ABC, abstractmethod
3
+
4
+
5
+ class Listener(ABC):
6
+ message_queue: asyncio.Queue
7
+
8
+ @abstractmethod
9
+ async def start(self, message_queue) -> None:
10
+ self.message_queue = message_queue
11
+
12
+ @abstractmethod
13
+ async def stop(self) -> None:
14
+ pass
arroyopy/operator.py ADDED
@@ -0,0 +1,52 @@
1
+ import asyncio
2
+ import logging
3
+ from abc import ABC, abstractmethod
4
+ from typing import List
5
+
6
+ from .listener import Listener
7
+ from .publisher import Publisher
8
+ from .schemas import Message
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class Operator(ABC):
14
+ listeners: List[Listener] = []
15
+ publishers: List[Publisher] = []
16
+ stop_requested: bool = False
17
+
18
+ def __init__(self):
19
+ self.listener_queue = asyncio.Queue()
20
+
21
+ @abstractmethod
22
+ async def process(self, message: Message) -> None:
23
+ pass
24
+
25
+ async def add_listener(self, listener: Listener) -> None: # noqa
26
+ self.listeners.append(listener)
27
+ await listener.start(self.listener_queue)
28
+
29
+ def remove_listener(self, listener: Listener) -> None: # noqa
30
+ self.listeners.remove(listener)
31
+
32
+ def add_publisher(self, publisher: Publisher) -> None:
33
+ self.publishers.append(publisher)
34
+
35
+ def remove_publisher(self, publisher: Publisher) -> None:
36
+ self.publishers.remove(publisher)
37
+
38
+ async def publish(self, message: Message) -> None:
39
+ for publisher in self.publishers:
40
+ await publisher.publish(message)
41
+
42
+ async def start(self):
43
+ # Process messages from the queue
44
+ while True:
45
+ if self.stop_requested:
46
+ logger.info("Stopping operator...")
47
+ for listener in self.listeners:
48
+ await listener.stop()
49
+ break
50
+ message = await self.queue.get()
51
+ processed_message = await self.process(message)
52
+ await self.publish(processed_message)
arroyopy/publisher.py ADDED
@@ -0,0 +1,12 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Generic, TypeVar
3
+
4
+ from .schemas import Message
5
+
6
+ T = TypeVar("T", bound=Message)
7
+
8
+
9
+ class Publisher(ABC, Generic[T]):
10
+ @abstractmethod
11
+ async def publish(self, message: Message) -> None:
12
+ pass
arroyopy/redis.py ADDED
@@ -0,0 +1,62 @@
1
+ import logging
2
+
3
+ from redis.asyncio.client import Redis
4
+
5
+ from .listener import Listener
6
+ from .operator import Operator
7
+
8
+ logger = logging.getLogger("arroyo.zmq")
9
+
10
+
11
+ class RedisListener(Listener):
12
+ def __init__(
13
+ self, redis_client: Redis, redis_channel_name: str, operator: Operator
14
+ ):
15
+ self.stop_requested = False
16
+ self.redis_client: Redis = redis_client
17
+ self.redis_channel_name = redis_channel_name
18
+ self.operator = operator
19
+
20
+ @classmethod
21
+ async def from_client(
22
+ cls, redis_client: Redis, redis_channel_name: str, operator: Operator
23
+ ):
24
+ return RedisListener(redis_client, redis_channel_name, operator)
25
+
26
+ async def start(self):
27
+ logger.info("Listener started")
28
+ pubsub = self.redis_client.pubsub()
29
+ await pubsub.subscribe(self.redis_channel_name)
30
+ # Listen for messages in the subscribed channel
31
+ while True:
32
+ if self.stop_requested:
33
+ return
34
+ # get_message blocks until timeout, returning None if no message in that time
35
+ raw_msg = await pubsub.get_message(
36
+ ignore_subscribe_messages=True, timeout=1.0
37
+ )
38
+ if raw_msg is None:
39
+ continue
40
+ msg = raw_msg["data"]
41
+ if logger.getEffectiveLevel() == logging.DEBUG:
42
+ logger.debug(f"{msg=}")
43
+ await self.operator.process(msg)
44
+
45
+ async def stop(self):
46
+ self.stop_requested = True
47
+ await self.redis_client.aclose()
48
+
49
+
50
+ class RedisPublisher:
51
+ def __init__(self, redis_client: Redis, redis_channel_name: str):
52
+ self.redis_client: Redis = redis_client
53
+ self.redis_channel_name = redis_channel_name
54
+
55
+ @classmethod
56
+ async def from_client(cls, redis_client: Redis, redis_channel_name: str):
57
+ return RedisPublisher(redis_client, redis_channel_name)
58
+
59
+ async def publish(self, message):
60
+ await self.redis_client.publish(self.redis_channel_name, message)
61
+ if logger.getEffectiveLevel() == logging.DEBUG:
62
+ logger.debug(f"Published {message=}")
arroyopy/schemas.py ADDED
@@ -0,0 +1,67 @@
1
+ import numpy
2
+ import numpy.typing
3
+ import pandas
4
+ from pydantic import BaseModel, field_validator
5
+
6
+
7
+ class Message:
8
+ """ "
9
+ Base class for messages. Is not a pydantic model
10
+ in case implementations choose not to use pydantic
11
+ as a validation and (de)serialization system but still
12
+ want to indicate that they pass arroyo Messages.
13
+ """
14
+
15
+ pass
16
+
17
+
18
+ class PydanticMessage(Message, BaseModel):
19
+ pass
20
+
21
+
22
+ class Start(PydanticMessage):
23
+ pass
24
+
25
+
26
+ class Stop(PydanticMessage):
27
+ pass
28
+
29
+
30
+ class Event(PydanticMessage):
31
+ pass
32
+
33
+
34
+ class DataFrameModel(BaseModel):
35
+ """
36
+ A Pydantic model for validating pd.DataFrame objects.
37
+ Does not parse array, merely validates that is a pd.DataFrame
38
+ """
39
+
40
+ df: pandas.DataFrame
41
+
42
+ @field_validator("df", mode="before")
43
+ def validate_is_numpy_array(cls, v):
44
+ if not isinstance(v, pandas.DataFrame):
45
+ raise TypeError(f"Expected pd.DataFrame, got {type(v)} instead.")
46
+ return v # Do not modify or parse the array
47
+
48
+ class Config:
49
+ arbitrary_types_allowed = True # Allow numpy.ndarray type
50
+
51
+
52
+ class NumpyArrayModel(BaseModel):
53
+ """
54
+ A Pydantic model for validating numpy.ndarray objects.
55
+ Does not parse array, merely validates that is a np.ndarray
56
+ """
57
+
58
+ array: numpy.ndarray
59
+
60
+ @field_validator("array", mode="before")
61
+ def validate_is_numpy_array(cls, v):
62
+ if not isinstance(v, numpy.ndarray):
63
+ raise TypeError(f"Expected numpy.ndarray, got {type(v)} instead.")
64
+ return v # Do not modify or parse the array
65
+
66
+ class Config:
67
+ arbitrary_types_allowed = True # Allow numpy.ndarray type
arroyopy/timing.py ADDED
@@ -0,0 +1,54 @@
1
+ import functools
2
+ import logging
3
+ import time
4
+
5
+ import pandas as pd
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ effective_level = logger.getEffectiveLevel()
10
+
11
+
12
+ class EventTimingDecorator:
13
+ """
14
+ Decorator to time functions and output the results in a pandas DataFrame.
15
+
16
+ This assumes within a single Event, there will be multiple calls to different functions.
17
+ When a new event starts, call `end_event` to store the timings and reset the timings for the next event.
18
+ When all events are done the timings can be accessed as a DataFrame using the `timing_dataframe` property.
19
+ After all events are done, call `reset` to clear all timings for the next event.
20
+ """
21
+
22
+ def __init__(self):
23
+ self.current_event_times = {}
24
+ self.events = []
25
+
26
+ def __call__(self, func):
27
+ @functools.wraps(func)
28
+ def wrapper(*args, **kwargs):
29
+ start_time = time.time()
30
+ result = func(*args, **kwargs)
31
+ end_time = time.time()
32
+ duration = end_time - start_time
33
+ self.current_event_times[func.__name__] = duration
34
+ if effective_level == logging.DEBUG:
35
+ (f"{func.__name__} took {duration:.4f} seconds")
36
+ return result
37
+
38
+ return wrapper
39
+
40
+ def end_event(self):
41
+ if self.current_event_times:
42
+ self.events.append(self.current_event_times)
43
+ self.current_event_times = {}
44
+
45
+ @property
46
+ def timing_dataframe(self):
47
+ return pd.DataFrame(self.current_event_times)
48
+
49
+ def reset(self):
50
+ self.current_event_times = {}
51
+ self.events = []
52
+
53
+
54
+ timer = EventTimingDecorator()
arroyopy/zmq.py ADDED
@@ -0,0 +1,60 @@
1
+ import asyncio
2
+ import logging
3
+
4
+ import zmq
5
+ import zmq.asyncio
6
+
7
+ from .listener import Listener
8
+ from .operator import Operator
9
+
10
+ logger = logging.getLogger("arroyo.zmq")
11
+
12
+
13
+ class ZMQListener(Listener):
14
+ stop_signal: bool = False
15
+
16
+ def __init__(self, operator: Operator, zmq_socket: zmq.Socket):
17
+ self.stop_requested = False
18
+ self.operator = operator
19
+ self.zmq_socket = zmq_socket
20
+
21
+ @classmethod
22
+ def from_socket(cls, zmq_socket: zmq.Socket):
23
+ """Construct a ZMQListenr using a provided socket. Gives
24
+ callers the ability to customize the ZMQ soket
25
+
26
+ Parameters
27
+ ----------
28
+ zmq_socket : zmq.Socket
29
+ provided socket
30
+
31
+ Returns
32
+ -------
33
+ ZMQListner
34
+ new ZMQListner
35
+ """
36
+ return ZMQListener(zmq_socket)
37
+
38
+ async def start(self):
39
+ logger.info("Listener started")
40
+ # timeout after 100 milliseconds so we can be stopped if requested
41
+ self.zmq_socket.setsockopt(zmq.RCVTIMEO, 100)
42
+ while True:
43
+ if self.stop_requested:
44
+ return
45
+ try:
46
+ msg = await self.zmq_socket.recv()
47
+ if logger.getEffectiveLevel() == logging.DEBUG:
48
+ logger.debug(f"{msg=}")
49
+ await self.operator.process(msg)
50
+ except zmq.Again:
51
+ # no message occured within the timeout period
52
+ pass
53
+ except asyncio.exceptions.CancelledError:
54
+ # in case this is being done in a asyncio.create_task call
55
+ pass
56
+
57
+ async def stop(self):
58
+ self.stop_requested = True
59
+ self.zmq_socket.close()
60
+ self.zmq_socket.context.term()
@@ -0,0 +1,255 @@
1
+ Metadata-Version: 2.4
2
+ Name: arroyopy
3
+ Version: 0.1.0
4
+ Summary: A library to simplify processing streams of data
5
+ Project-URL: Homepage, https://github.com/als-computing/arroyopy
6
+ Project-URL: Issues, https://github.com/als-computing/arroyopy/issues
7
+ Author-email: Dylan McReynolds <dmcreynolds@lbl.gov>
8
+ License: BSD-3
9
+ License-File: LICENSE
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: <3.13,>=3.11
13
+ Requires-Dist: numpy
14
+ Requires-Dist: pandas
15
+ Requires-Dist: pydantic>=2.0
16
+ Requires-Dist: python-dotenv
17
+ Requires-Dist: typer
18
+ Provides-Extra: dev
19
+ Requires-Dist: fakeredis; extra == 'dev'
20
+ Requires-Dist: flake8; extra == 'dev'
21
+ Requires-Dist: pre-commit; extra == 'dev'
22
+ Requires-Dist: pytest-asyncio; extra == 'dev'
23
+ Requires-Dist: pytest-mock; extra == 'dev'
24
+ Requires-Dist: pyzmq; extra == 'dev'
25
+ Requires-Dist: redis; extra == 'dev'
26
+ Requires-Dist: tiled[minimal-server]; extra == 'dev'
27
+ Provides-Extra: redis
28
+ Requires-Dist: redis; extra == 'redis'
29
+ Provides-Extra: tiled
30
+ Requires-Dist: tiled[client]; extra == 'tiled'
31
+ Provides-Extra: zmq
32
+ Requires-Dist: pyzmq; extra == 'zmq'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # Arroyo Stream Processing Toolset
36
+
37
+ Processing event or streaming data presents several technological challenges. A variety of technologies are often used by scientific user facilities. ZMQ is used to stream data and messages in a peer-to-peer fashion. Message brokers like Kafka, Redis and RabbitMQ are often employed to route and pass messages from instruments to processing workflows. Arroyo provides an API and structure to flexibly integrate with these tools and incorporate arbitrarily complex processing workflows, letting the hooks to the workflow code be independent of the connection code and hence reusable at a variety of instruments.
38
+
39
+ The basic structure of building an arroyo implementation is to implement groups of several classes:
40
+ -
41
+ - `Operator` - receives `Messages` from a listener and can optionally send `Messages` to one or more `Publisher` instances
42
+ - `Listener` - receives `Messages` from the external world, parse them into arroyo `Message` and sends them to an `Operator`
43
+ - `Publisher` - receives `Messages` from a `Listener` and publishes them to the outside world
44
+
45
+
46
+
47
+
48
+ Arroyo is un-opinionated about deployment decsions. It is intended support listener-operator-publisher groups in:
49
+ - Single process
50
+ - Chain of processes where listening, processing and publishing can linked together through a protocol like ZMQ. One process's publisher can communicate with another process's listener, etc.
51
+
52
+ This library is intended to provide classes, and will also include more specific common subclasses, like those that communicate over ZMQ or Redis.
53
+
54
+
55
+
56
+ ```mermaid
57
+
58
+ ---
59
+ title: Some sweet classes
60
+
61
+ note: I guess we use "None" instead of "void"
62
+ ---
63
+
64
+ classDiagram
65
+ namespace listener{
66
+
67
+ class Listener{
68
+ operator: Operator
69
+
70
+ *start(): None
71
+ *stop(): None
72
+ }
73
+
74
+
75
+ }
76
+
77
+ namespace operator{
78
+ class Operator{
79
+ publisher: List[Publisher]
80
+ *process(Message): None
81
+ add_publisher(Publisher): None
82
+ remove_publisher(Publisher): None
83
+
84
+ }
85
+ }
86
+
87
+ namespace publisher{
88
+ class Publisher{
89
+ *publish(Message): None
90
+ }
91
+
92
+ }
93
+
94
+ namespace message{
95
+
96
+ class Message{
97
+
98
+ }
99
+
100
+ class Start{
101
+ data: Dict
102
+ }
103
+
104
+ class Stop{
105
+ data: Dict
106
+ }
107
+
108
+ class Event{
109
+ metadata: Dict
110
+ payload: bytes
111
+ }
112
+ }
113
+
114
+ namespace zmq{
115
+ class ZMQListener{
116
+ operator: Operator
117
+ socket: zmq.Socket
118
+ }
119
+
120
+ class ZMQPublisher{
121
+ host: str
122
+ port: int
123
+ }
124
+
125
+ }
126
+
127
+ namespace redis{
128
+
129
+ class RedisListener{
130
+ operator: Redis.client
131
+ pubsub: Redis.pubsub
132
+ }
133
+
134
+ class RedisPublisher{
135
+ pubsub: Redis.pubsub
136
+ }
137
+
138
+ }
139
+
140
+
141
+
142
+ Listener <|-- ZMQListener
143
+ ZMQListener <|-- ZMQPubSubListener
144
+ Listener o-- Operator
145
+
146
+ Publisher <|-- ZMQPublisher
147
+ ZMQPublisher <|-- ZMQPubSubPublisher
148
+
149
+ Publisher <|-- RedisPublisher
150
+ Listener <|-- RedisListener
151
+ Operator o-- Publisher
152
+ Message <|-- Start
153
+ Message <|-- Stop
154
+ Message <|-- Event
155
+
156
+
157
+ ```
158
+ ##
159
+ In-process, listening for ZMQ
160
+
161
+ Note that this leaves Concrete classes undefined as placeholders
162
+
163
+ TODO: parent class labels
164
+
165
+ ```mermaid
166
+
167
+ sequenceDiagram
168
+ autonumber
169
+ ExternalPublisher ->> ZMQPubSubListener: publish(bytes)
170
+ loop receiving thread
171
+ activate ZMQPubSubListener
172
+ ZMQPubSubListener ->> ConcreteMessageParser: parse(bytes)
173
+ ZMQPubSubListener ->> MessageQueue: put(bytes)
174
+ deactivate ZMQPubSubListener
175
+
176
+
177
+ ZMQPubSubListener ->> MessageQueue: message(Message)
178
+ end
179
+ activate ConcreteOperator
180
+ loop polling thread
181
+ ConcreteOperator ->> MessageQueue: get(bytes)
182
+ end
183
+ loop processing thread
184
+ ConcreteOperator ->> ConcreteOperator: calculate()
185
+
186
+ ConcreteOperator ->> ConcretePublisher: publish()
187
+ end
188
+ deactivate ConcreteOperator
189
+ ```
190
+
191
+ # Devloper installation
192
+
193
+ ## Conda environment
194
+ We use pixi to be forward thinking tio help with CI. We like it because it helps you easily test that dependencies for a variety of architects can resolve.
195
+
196
+ However, at the time of writing we can't figure out how to get it to be a good developer experience. So, we create a conda environment like (note that at this time, we are using python 3.11 because of numpy and wheel availability):
197
+
198
+ ```
199
+ conda create -n arroyo python=3.11
200
+ conda activate arroyo
201
+ pip install -e '.[dev]'
202
+ ```
203
+
204
+ ## pre-commit
205
+ We use `pre-commit` in CI so you want to use it before commiting.
206
+ To test that your branches changes are all good, type:
207
+
208
+ ```
209
+ pre-commit run --all-files
210
+ ```
211
+
212
+ Since our configuration of `pre-commit` uses `black`, it's possible that it will change files. If you like the changes, you can add them to your `git` commit with
213
+
214
+ ```
215
+ git add .
216
+ ```
217
+
218
+ Then you can run `pre-commit run --all-files` again.
219
+
220
+ ## pixi
221
+ We use `pixi` for CI in github action. It's great for that but can't get our favorite developr tools to use the python environments that `pixi` creaetes in the `.pixi` folder. If you want to play with `pixi`, here are some tips:
222
+
223
+ To setup a development environment:
224
+
225
+ * Git clone this repo and CD into the directory
226
+ * Install [pixi](https://pixi.sh/v0.33.0/#installation)
227
+ * Install dependencies with
228
+ '''
229
+ pixi install
230
+ '''
231
+ * run pre-commit on the files
232
+ '''
233
+ pixi r pre-commit
234
+ '''
235
+
236
+
237
+ * Run pytest with
238
+ '''
239
+ pixi r test
240
+ '''
241
+
242
+ # Copyright
243
+ Arroyo Stream Processing Toolset (arroyopy) Copyright (c) 2025, The Regents of the University of California, through Lawrence Berkeley National Laboratory (subject to receipt of any required approvals from the U.S. Dept. of Energy).
244
+ All rights reserved.
245
+
246
+ If you have questions about your rights to use or distribute this software,
247
+ please contact Berkeley Lab's Intellectual Property Office at
248
+ IPO@lbl.gov.
249
+
250
+ NOTICE. This Software was developed under funding from the U.S. Department
251
+ of Energy and the U.S. Government consequently retains certain rights. As
252
+ such, the U.S. Government has been granted for itself and others acting on
253
+ its behalf a paid-up, nonexclusive, irrevocable, worldwide license in the
254
+ Software to reproduce, distribute copies to the public, prepare derivative
255
+ works, and perform publicly and display publicly, and to permit others to do so.
@@ -0,0 +1,12 @@
1
+ arroyopy/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ arroyopy/listener.py,sha256=Cy_3UWkguFI__BUzk7d4DNbA52mK_ch8t1KaRFyr7dU,289
3
+ arroyopy/operator.py,sha256=-NvqKMbXt5O2ikJ9FhQIlnQilkQT0bbZ-1nRNmvsTx8,1574
4
+ arroyopy/publisher.py,sha256=a3bR5BI-B9pduxrTeR70jP1BIUyZNpu1CwYFvHvTCQA,259
5
+ arroyopy/redis.py,sha256=7OJSuKhDlHTDNtGvRmCApU0FKZ648KVs8bczySZz_sM,2098
6
+ arroyopy/schemas.py,sha256=ONARC_pormPXg7SNqlx1FPeU0A2knNMlspU3YEclxyk,1637
7
+ arroyopy/timing.py,sha256=HXhwQmpLq87JT5ZFZTh2vAQbO196whD6_Yw1yGjZdzI,1624
8
+ arroyopy/zmq.py,sha256=w8WPFYbwbLlmTPiIAc-pdNRpmUdbpkiyktBz1_7bXRA,1716
9
+ arroyopy-0.1.0.dist-info/METADATA,sha256=tgKvc43MUqIiElxXtbUoJd3UAu-myQhQXB38xvo_ZXY,7548
10
+ arroyopy-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
11
+ arroyopy-0.1.0.dist-info/licenses/LICENSE,sha256=jCk8QHKtWh2hrT0X2XXNZZDfreHb9v0Th5kZ3rDCN2g,2434
12
+ arroyopy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,33 @@
1
+ Arroyo Stream Processing Toolset (arroyopy) Copyright (c) 2025, The Regents of the University of California, through Lawrence Berkeley National Laboratory (subject to receipt of any required approvals from the U.S. Dept. of Energy).
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+
7
+ (1) Redistributions of source code must retain the above copyright notice,
8
+ this list of conditions and the following disclaimer.
9
+
10
+ (2) Redistributions in binary form must reproduce the above copyright
11
+ notice, this list of conditions and the following disclaimer in the
12
+ documentation and/or other materials provided with the distribution.
13
+
14
+ (3) Neither the name of the University of California, Lawrence Berkeley
15
+ National Laboratory, U.S. Dept. of Energy nor the names of its contributors
16
+ may be used to endorse or promote products derived from this software
17
+ without specific prior written permission.
18
+
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
21
+ ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
22
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
23
+
24
+ You are under no obligation whatsoever to provide any bug fixes, patches,
25
+ or upgrades to the features, functionality or performance of the source
26
+ code ("Enhancements") to anyone; however, if you choose to make your
27
+ Enhancements available either publicly, or directly to Lawrence Berkeley
28
+ National Laboratory, without imposing a separate written license agreement
29
+ for such Enhancements, then you hereby grant the following license: a
30
+ non-exclusive, royalty-free perpetual license to install, use, modify,
31
+ prepare derivative works, incorporate into other computer software,
32
+ distribute, and sublicense such enhancements or derivative works thereof,
33
+ in binary and source code form.