jararaca 0.2.37a11__py3-none-any.whl → 0.3.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.

Potentially problematic release.


This version of jararaca might be problematic. Click here for more details.

@@ -1,5 +1,7 @@
1
1
  import asyncio
2
+ import inspect
2
3
  import logging
4
+ import time
3
5
  from contextlib import asynccontextmanager
4
6
  from dataclasses import dataclass
5
7
  from datetime import UTC, datetime
@@ -11,6 +13,7 @@ from croniter import croniter
11
13
  from jararaca.core.uow import UnitOfWorkContextProvider
12
14
  from jararaca.di import Container
13
15
  from jararaca.lifecycle import AppLifecycle
16
+ from jararaca.messagebus.decorators import ScheduleDispatchData
14
17
  from jararaca.microservice import Microservice, SchedulerAppContext
15
18
  from jararaca.scheduler.decorators import ScheduledAction
16
19
 
@@ -22,9 +25,6 @@ class SchedulerConfig:
22
25
  interval: int
23
26
 
24
27
 
25
- class SchedulerBackend: ...
26
-
27
-
28
28
  def extract_scheduled_actions(
29
29
  app: Microservice, container: Container
30
30
  ) -> list[tuple[Callable[..., Any], "ScheduledAction"]]:
@@ -52,12 +52,11 @@ class Scheduler:
52
52
  def __init__(
53
53
  self,
54
54
  app: Microservice,
55
- backend: SchedulerBackend,
56
- config: SchedulerConfig,
55
+ interval: int,
57
56
  ) -> None:
58
57
  self.app = app
59
- self.backend = backend
60
- self.config = config
58
+
59
+ self.interval = interval
61
60
  self.container = Container(self.app)
62
61
  self.uow_provider = UnitOfWorkContextProvider(app, self.container)
63
62
 
@@ -115,7 +114,15 @@ class Scheduler:
115
114
  ):
116
115
  try:
117
116
  async with ctx:
118
- await func()
117
+ signature = inspect.signature(func)
118
+ if len(signature.parameters) > 0:
119
+ logging.warning(
120
+ f"Scheduled action {func.__module__}.{func.__qualname__} has parameters, but no arguments were provided. Must be using scheduler-v2"
121
+ )
122
+ await func(ScheduleDispatchData(time.time()))
123
+ else:
124
+ await func()
125
+
119
126
  except BaseException as e:
120
127
  if action_specs.exception_handler:
121
128
  action_specs.exception_handler(e)
@@ -143,7 +150,7 @@ class Scheduler:
143
150
 
144
151
  await self.process_task(func, scheduled_action)
145
152
 
146
- await asyncio.sleep(self.config.interval)
153
+ await asyncio.sleep(self.interval)
147
154
 
148
155
  with asyncio.Runner(loop_factory=uvloop.new_event_loop) as runner:
149
156
  runner.run(run_scheduled_actions())
@@ -0,0 +1,346 @@
1
+ import asyncio
2
+ import contextlib
3
+ import logging
4
+ import signal
5
+ import time
6
+ from abc import ABC, abstractmethod
7
+ from contextlib import asynccontextmanager
8
+ from datetime import UTC, datetime
9
+ from types import FrameType
10
+ from typing import Any, AsyncGenerator, Callable
11
+ from urllib.parse import parse_qs
12
+
13
+ import aio_pika
14
+ import croniter
15
+ import urllib3
16
+ import urllib3.util
17
+ import uvloop
18
+ from aio_pika import connect_robust
19
+ from aio_pika.abc import AbstractChannel, AbstractRobustConnection
20
+ from aio_pika.pool import Pool
21
+
22
+ from jararaca.broker_backend import MessageBrokerBackend
23
+ from jararaca.broker_backend.mapper import get_message_broker_backend_from_url
24
+ from jararaca.core.uow import UnitOfWorkContextProvider
25
+ from jararaca.di import Container
26
+ from jararaca.lifecycle import AppLifecycle
27
+ from jararaca.microservice import Microservice
28
+ from jararaca.scheduler.decorators import ScheduledAction
29
+ from jararaca.scheduler.types import DelayedMessageData
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ SCHEDULED_ACTION_LIST = list[tuple[Callable[..., Any], "ScheduledAction"]]
34
+
35
+
36
+ def extract_scheduled_actions(
37
+ app: Microservice, container: Container
38
+ ) -> SCHEDULED_ACTION_LIST:
39
+ scheduled_actions: SCHEDULED_ACTION_LIST = []
40
+ for controllers in app.controllers:
41
+
42
+ controller_instance: Any = container.get_by_type(controllers)
43
+
44
+ controller_scheduled_actions = ScheduledAction.get_type_scheduled_actions(
45
+ controller_instance
46
+ )
47
+ scheduled_actions.extend(controller_scheduled_actions)
48
+
49
+ return scheduled_actions
50
+
51
+
52
+ # region Message Broker Dispatcher
53
+
54
+
55
+ class MessageBrokerDispatcher(ABC):
56
+
57
+ @abstractmethod
58
+ async def dispatch_scheduled_action(
59
+ self,
60
+ action_id: str,
61
+ timestamp: int,
62
+ ) -> None:
63
+ """
64
+ Dispatch a message to the message broker.
65
+ This is used to send a message to the message broker
66
+ to trigger the scheduled action.
67
+ """
68
+ raise NotImplementedError("dispatch() is not implemented yet.")
69
+
70
+ @abstractmethod
71
+ async def dispatch_delayed_message(
72
+ self,
73
+ delayed_message: DelayedMessageData,
74
+ ) -> None:
75
+ """
76
+ Dispatch a delayed message to the message broker.
77
+ This is used to send a message to the message broker
78
+ to trigger the scheduled action.
79
+ """
80
+
81
+ raise NotImplementedError("dispatch_delayed_message() is not implemented yet.")
82
+
83
+ @abstractmethod
84
+ async def initialize(self, scheduled_actions: SCHEDULED_ACTION_LIST) -> None:
85
+ raise NotImplementedError("initialize() is not implemented yet.")
86
+
87
+ async def dispose(self) -> None:
88
+ pass
89
+
90
+
91
+ class RabbitMQBrokerDispatcher(MessageBrokerDispatcher):
92
+
93
+ def __init__(self, url: str) -> None:
94
+ self.url = url
95
+
96
+ self.conn_pool: "Pool[AbstractRobustConnection]" = Pool(
97
+ self._create_connection,
98
+ max_size=10,
99
+ )
100
+
101
+ self.channel_pool: "Pool[AbstractChannel]" = Pool(
102
+ self._create_channel,
103
+ max_size=10,
104
+ )
105
+
106
+ splitted = urllib3.util.parse_url(url)
107
+
108
+ assert splitted.scheme in ["amqp", "amqps"], "Invalid URL scheme"
109
+
110
+ assert splitted.host, "Invalid URL host"
111
+
112
+ assert splitted.query, "Invalid URL query"
113
+
114
+ query_params: dict[str, list[str]] = parse_qs(splitted.query)
115
+
116
+ assert "exchange" in query_params, "Missing exchange parameter"
117
+
118
+ assert query_params["exchange"], "Empty exchange parameter"
119
+
120
+ self.exchange = str(query_params["exchange"][0])
121
+
122
+ async def _create_connection(self) -> AbstractRobustConnection:
123
+ """
124
+ Create a connection to the RabbitMQ server.
125
+ This is used to send messages to the RabbitMQ server.
126
+ """
127
+ connection = await connect_robust(self.url)
128
+ return connection
129
+
130
+ async def _create_channel(self) -> AbstractChannel:
131
+ """
132
+ Create a channel to the RabbitMQ server.
133
+ This is used to send messages to the RabbitMQ server.
134
+ """
135
+ async with self.conn_pool.acquire() as connection:
136
+ channel = await connection.channel()
137
+ return channel
138
+
139
+ async def dispatch_scheduled_action(self, action_id: str, timestamp: int) -> None:
140
+ """
141
+ Dispatch a message to the RabbitMQ server.
142
+ This is used to send a message to the RabbitMQ server
143
+ to trigger the scheduled action.
144
+ """
145
+
146
+ logger.info(f"Dispatching message to {action_id} at {timestamp}")
147
+ async with self.channel_pool.acquire() as channel:
148
+ exchange = await channel.get_exchange(self.exchange)
149
+
150
+ await exchange.publish(
151
+ aio_pika.Message(body=str(timestamp).encode()),
152
+ routing_key=action_id,
153
+ )
154
+ logger.info(f"Dispatched message to {action_id} at {timestamp}")
155
+
156
+ async def dispatch_delayed_message(
157
+ self, delayed_message: DelayedMessageData
158
+ ) -> None:
159
+ """
160
+ Dispatch a delayed message to the RabbitMQ server.
161
+ This is used to send a message to the RabbitMQ server
162
+ to trigger the scheduled action.
163
+ """
164
+ async with self.channel_pool.acquire() as channel:
165
+
166
+ exchange = await channel.get_exchange(self.exchange)
167
+ await exchange.publish(
168
+ aio_pika.Message(
169
+ body=delayed_message.payload,
170
+ ),
171
+ routing_key=f"{delayed_message.message_topic}.",
172
+ )
173
+
174
+ async def initialize(self, scheduled_actions: SCHEDULED_ACTION_LIST) -> None:
175
+ """
176
+ Initialize the RabbitMQ server.
177
+ This is used to create the exchange and queues for the scheduled actions.
178
+ """
179
+
180
+ async with self.channel_pool.acquire() as channel:
181
+
182
+ await channel.set_qos(prefetch_count=1)
183
+
184
+ await channel.declare_exchange(
185
+ name=self.exchange,
186
+ type="topic",
187
+ durable=True,
188
+ auto_delete=False,
189
+ )
190
+
191
+ for func, _ in scheduled_actions:
192
+ queue = await channel.declare_queue(
193
+ name=ScheduledAction.get_function_id(func),
194
+ durable=True,
195
+ )
196
+
197
+ await queue.bind(
198
+ exchange=self.exchange,
199
+ routing_key=ScheduledAction.get_function_id(func),
200
+ )
201
+
202
+ async def dispose(self) -> None:
203
+ await self.channel_pool.close()
204
+ await self.conn_pool.close()
205
+
206
+
207
+ def get_message_broker_dispatcher_from_url(url: str) -> MessageBrokerDispatcher:
208
+ """
209
+ Factory function to create a message broker instance from a URL.
210
+ Currently, only RabbitMQ is supported.
211
+ """
212
+ if url.startswith("amqp://") or url.startswith("amqps://"):
213
+ return RabbitMQBrokerDispatcher(url=url)
214
+ else:
215
+ raise ValueError(f"Unsupported message broker URL: {url}")
216
+
217
+
218
+ # endregion
219
+
220
+
221
+ class SchedulerV2:
222
+
223
+ def __init__(
224
+ self,
225
+ app: Microservice,
226
+ interval: int,
227
+ broker_url: str,
228
+ backend_url: str,
229
+ ) -> None:
230
+ self.app = app
231
+
232
+ self.broker: MessageBrokerDispatcher = get_message_broker_dispatcher_from_url(
233
+ broker_url
234
+ )
235
+ self.backend: MessageBrokerBackend = get_message_broker_backend_from_url(
236
+ backend_url
237
+ )
238
+
239
+ self.interval = interval
240
+ self.container = Container(self.app)
241
+ self.uow_provider = UnitOfWorkContextProvider(app, self.container)
242
+
243
+ self.shutdown_event = asyncio.Event()
244
+
245
+ self.lifecycle = AppLifecycle(app, self.container)
246
+
247
+ def run(self) -> None:
248
+
249
+ def on_signal_received(signal: int, frame_type: FrameType | None) -> None:
250
+ logger.info("Received shutdown signal")
251
+ self.shutdown_event.set()
252
+
253
+ signal.signal(signal.SIGINT, on_signal_received)
254
+
255
+ with asyncio.Runner(loop_factory=uvloop.new_event_loop) as runner:
256
+ runner.run(self.start_scheduler())
257
+
258
+ async def start_scheduler(self) -> None:
259
+ """
260
+ Declares the scheduled actions and starts the scheduler.
261
+ This is the main entry point for the scheduler.
262
+ """
263
+ async with self.lifecycle():
264
+
265
+ scheduled_actions = extract_scheduled_actions(self.app, self.container)
266
+
267
+ await self.broker.initialize(scheduled_actions)
268
+
269
+ await self.run_scheduled_actions(scheduled_actions)
270
+
271
+ async def run_scheduled_actions(
272
+ self, scheduled_actions: SCHEDULED_ACTION_LIST
273
+ ) -> None:
274
+
275
+ while not self.shutdown_event.is_set():
276
+ now = int(time.time())
277
+ for func, scheduled_action in scheduled_actions:
278
+ if self.shutdown_event.is_set():
279
+ break
280
+
281
+ async with self.backend.lock():
282
+
283
+ last_dispatch_time: int | None = (
284
+ await self.backend.get_last_dispatch_time(
285
+ ScheduledAction.get_function_id(func)
286
+ )
287
+ )
288
+
289
+ if last_dispatch_time is not None:
290
+ cron = croniter.croniter(
291
+ scheduled_action.cron, last_dispatch_time
292
+ )
293
+ next_run: datetime = cron.get_next(datetime).replace(tzinfo=UTC)
294
+ if next_run > datetime.now(UTC):
295
+ logger.info(
296
+ f"Skipping {func.__module__}.{func.__qualname__} until {next_run}"
297
+ )
298
+ continue
299
+
300
+ if not scheduled_action.allow_overlap:
301
+ if (
302
+ await self.backend.get_in_execution_count(
303
+ ScheduledAction.get_function_id(func)
304
+ )
305
+ > 0
306
+ ):
307
+ continue
308
+
309
+ await self.broker.dispatch_scheduled_action(
310
+ ScheduledAction.get_function_id(func),
311
+ now,
312
+ )
313
+
314
+ await self.backend.set_last_dispatch_time(
315
+ ScheduledAction.get_function_id(func), now
316
+ )
317
+
318
+ logger.info(
319
+ f"Scheduled {func.__module__}.{func.__qualname__} at {now}"
320
+ )
321
+
322
+ for (
323
+ delayed_message_data
324
+ ) in await self.backend.dequeue_next_delayed_messages(now):
325
+ await self.broker.dispatch_delayed_message(delayed_message_data)
326
+
327
+ with contextlib.suppress(asyncio.TimeoutError):
328
+ await asyncio.wait_for(self.shutdown_event.wait(), self.interval)
329
+
330
+ # await self.shutdown_event.wait(self.interval)
331
+
332
+ logger.info("Scheduler stopped")
333
+
334
+ await self.backend.dispose()
335
+ await self.broker.dispose()
336
+
337
+
338
+ @asynccontextmanager
339
+ async def none_context() -> AsyncGenerator[None, None]:
340
+ yield
341
+
342
+
343
+ logging.basicConfig(
344
+ level=logging.INFO,
345
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
346
+ )
@@ -0,0 +1,7 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class DelayedMessageData(BaseModel):
5
+ message_topic: str
6
+ dispatch_time: int
7
+ payload: bytes
File without changes
@@ -0,0 +1,84 @@
1
+ from aio_pika.abc import AbstractChannel, AbstractExchange, AbstractQueue
2
+
3
+
4
+ class RabbitmqUtils:
5
+
6
+ DEAD_LETTER_EXCHANGE = "dlx"
7
+ DEAD_LETTER_QUEUE = "dlq"
8
+
9
+ @classmethod
10
+ async def declare_dl_exchange(cls, channel: AbstractChannel) -> AbstractExchange:
11
+ """
12
+ Declare a Dead Letter Exchange (DLX) for the given channel.
13
+ """
14
+ await channel.set_qos(prefetch_count=1)
15
+ return await channel.declare_exchange(
16
+ cls.DEAD_LETTER_EXCHANGE,
17
+ type="direct",
18
+ durable=True,
19
+ auto_delete=False,
20
+ )
21
+
22
+ @classmethod
23
+ async def declare_dl_queue(cls, channel: AbstractChannel) -> AbstractQueue:
24
+ """
25
+ Declare a Dead Letter Queue (DLQ) for the given queue.
26
+ """
27
+ await channel.set_qos(prefetch_count=1)
28
+ return await channel.declare_queue(
29
+ cls.DEAD_LETTER_QUEUE,
30
+ durable=True,
31
+ arguments={
32
+ "x-dead-letter-exchange": "",
33
+ "x-dead-letter-routing-key": cls.DEAD_LETTER_EXCHANGE,
34
+ },
35
+ )
36
+
37
+ @classmethod
38
+ async def delcare_dl_kit(
39
+ cls,
40
+ channel: AbstractChannel,
41
+ ) -> tuple[AbstractExchange, AbstractQueue]:
42
+ """
43
+ Declare a Dead Letter Exchange and Queue (DLX and DLQ) for the given channel.
44
+ """
45
+ dlx = await cls.declare_dl_exchange(channel)
46
+ dlq = await cls.declare_dl_queue(channel)
47
+ await dlq.bind(dlx, routing_key=cls.DEAD_LETTER_EXCHANGE)
48
+ return dlx, dlq
49
+
50
+ @classmethod
51
+ async def declare_main_exchange(
52
+ cls,
53
+ channel: AbstractChannel,
54
+ exchange_name: str,
55
+ ) -> AbstractExchange:
56
+ """
57
+ Declare a main exchange for the given channel.
58
+ """
59
+ await channel.set_qos(prefetch_count=1)
60
+ return await channel.declare_exchange(
61
+ exchange_name,
62
+ type="topic",
63
+ durable=True,
64
+ auto_delete=False,
65
+ )
66
+
67
+ @classmethod
68
+ async def declare_queue(
69
+ cls,
70
+ channel: AbstractChannel,
71
+ queue_name: str,
72
+ ) -> AbstractQueue:
73
+ """
74
+ Declare a queue with the given name and properties.
75
+ """
76
+ await channel.set_qos(prefetch_count=1)
77
+ return await channel.declare_queue(
78
+ queue_name,
79
+ durable=True,
80
+ arguments={
81
+ "x-dead-letter-exchange": cls.DEAD_LETTER_EXCHANGE,
82
+ "x-dead-letter-routing-key": cls.DEAD_LETTER_EXCHANGE,
83
+ },
84
+ )
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.3
2
+ Name: jararaca
3
+ Version: 0.3.0
4
+ Summary: A simple and fast API framework for Python
5
+ Author: Lucas S
6
+ Author-email: me@luscasleo.dev
7
+ Requires-Python: >=3.11,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Provides-Extra: docs
13
+ Provides-Extra: http
14
+ Provides-Extra: opentelemetry
15
+ Provides-Extra: watch
16
+ Requires-Dist: aio-pika (>=9.4.3,<10.0.0)
17
+ Requires-Dist: croniter (>=3.0.3,<4.0.0)
18
+ Requires-Dist: fastapi (>=0.113.0,<0.114.0)
19
+ Requires-Dist: mako (>=1.3.5,<2.0.0)
20
+ Requires-Dist: opentelemetry-api (>=1.27.0,<2.0.0) ; extra == "opentelemetry"
21
+ Requires-Dist: opentelemetry-distro (>=0.49b2,<0.50) ; extra == "opentelemetry"
22
+ Requires-Dist: opentelemetry-exporter-otlp (>=1.27.0,<2.0.0) ; extra == "opentelemetry"
23
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http (>=1.27.0,<2.0.0) ; extra == "opentelemetry"
24
+ Requires-Dist: opentelemetry-sdk (>=1.27.0,<2.0.0) ; extra == "opentelemetry"
25
+ Requires-Dist: redis (>=5.0.8,<6.0.0)
26
+ Requires-Dist: sqlalchemy (>=2.0.34,<3.0.0)
27
+ Requires-Dist: types-croniter (>=3.0.3.20240731,<4.0.0.0)
28
+ Requires-Dist: types-redis (>=4.6.0.20240903,<5.0.0.0)
29
+ Requires-Dist: uvicorn (>=0.30.6,<0.31.0)
30
+ Requires-Dist: uvloop (>=0.20.0,<0.21.0)
31
+ Requires-Dist: watchdog (>=3.0.0,<4.0.0) ; extra == "watch"
32
+ Requires-Dist: websockets (>=13.0.1,<14.0.0)
33
+ Project-URL: Repository, https://github.com/LuscasLeo/jararaca
34
+ Description-Content-Type: text/markdown
35
+
36
+ <img src="https://raw.githubusercontent.com/LuscasLeo/jararaca/main/docs/assets/_f04774c9-7e05-4da4-8b17-8be23f6a1475.jpeg" alt="Jararaca Logo" width="250" float="right">
37
+
38
+ # Jararaca Microservice Framework
39
+
40
+ ## Overview
41
+
42
+ Jararaca is an async-first microservice framework designed to simplify the development of distributed systems. It provides a comprehensive set of tools for building robust, scalable, and maintainable microservices with a focus on developer experience and type safety.
43
+
44
+ ## Key Features
45
+
46
+ ### REST API Development
47
+ - Easy-to-use interfaces for building REST APIs
48
+ - Automatic request/response validation
49
+ - Type-safe endpoints with FastAPI integration
50
+ - Automatic OpenAPI documentation generation
51
+
52
+ ### Message Bus Integration
53
+ - Topic-based message bus for event-driven architecture
54
+ - Support for both worker and publisher patterns
55
+ - Built-in message serialization and deserialization
56
+ - Easy integration with AIO Pika for RabbitMQ
57
+
58
+ ### Distributed WebSocket
59
+ - Room-based WebSocket communication
60
+ - Distributed broadcasting across multiple backend instances
61
+ - Automatic message synchronization between instances
62
+ - Built-in connection management and room handling
63
+
64
+ ### Task Scheduling
65
+ - Cron-based task scheduling
66
+ - Support for overlapping and non-overlapping tasks
67
+ - Distributed task execution
68
+ - Easy integration with message bus for task distribution
69
+
70
+ ### TypeScript Integration
71
+ - Automatic TypeScript interface generation
72
+ - Command-line tool for generating TypeScript types
73
+ - Support for REST endpoints, WebSocket events, and message bus payloads
74
+ - Type-safe frontend-backend communication
75
+
76
+ ### Hexagonal Architecture
77
+ - Clear separation of concerns
78
+ - Business logic isolation from infrastructure
79
+ - Easy testing and maintainability
80
+ - Dependency injection for flexible component management
81
+
82
+ ### Observability
83
+ - Built-in OpenTelemetry integration
84
+ - Distributed tracing support
85
+ - Logging and metrics collection
86
+ - Performance monitoring capabilities
87
+
88
+ ## Quick Start
89
+
90
+ ### Installation
91
+
92
+ ```bash
93
+ pip install jararaca
94
+ ```
95
+
96
+ ### Basic Usage
97
+
98
+ ```python
99
+ from jararaca import Microservice, create_http_server
100
+ from jararaca.presentation.http_microservice import HttpMicroservice
101
+
102
+ # Define your microservice
103
+ app = Microservice(
104
+ providers=[
105
+ # Add your providers here
106
+ ],
107
+ controllers=[
108
+ # Add your controllers here
109
+ ],
110
+ interceptors=[
111
+ # Add your interceptors here
112
+ ],
113
+ )
114
+
115
+ # Create HTTP server
116
+ http_app = HttpMicroservice(app)
117
+ web_app = create_http_server(app)
118
+ ```
119
+
120
+ ### Running the Service
121
+
122
+ ```bash
123
+ # Run as HTTP server
124
+ jararaca server app:http_app
125
+
126
+ # Run as message bus worker
127
+ jararaca worker app:app
128
+
129
+ # Run as scheduler
130
+ jararaca scheduler app:app
131
+
132
+ # Generate TypeScript interfaces
133
+ jararaca gen-tsi app.main:app app.ts
134
+ ```
135
+
136
+ ## Documentation
137
+
138
+ For detailed documentation, please visit our [documentation site](https://luscasleo.github.io/jararaca/).
139
+
140
+ ## Examples
141
+
142
+ Check out the [examples directory](examples/) for complete working examples of:
143
+ - REST API implementation
144
+ - WebSocket usage
145
+ - Message bus integration
146
+ - Task scheduling
147
+ - TypeScript interface generation
148
+
149
+ ## Contributing
150
+
151
+ Contributions are welcome! Please read our [contributing guidelines](.github/CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
152
+
153
+ ## License
154
+
155
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
156
+