karton-core 5.6.1__py3-none-any.whl → 5.8.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.
@@ -1 +1 @@
1
- __version__ = "5.6.1"
1
+ __version__ = "5.8.0"
@@ -0,0 +1,21 @@
1
+ import sys
2
+
3
+ if sys.version_info < (3, 11, 0):
4
+ raise ImportError("karton.core.asyncio is only compatible with Python 3.11+")
5
+
6
+ from karton.core.config import Config
7
+ from karton.core.task import Task
8
+
9
+ from .karton import Consumer, Karton, Producer
10
+ from .resource import LocalResource, RemoteResource, Resource
11
+
12
+ __all__ = [
13
+ "Karton",
14
+ "Producer",
15
+ "Consumer",
16
+ "Task",
17
+ "Config",
18
+ "LocalResource",
19
+ "Resource",
20
+ "RemoteResource",
21
+ ]
@@ -0,0 +1,370 @@
1
+ import json
2
+ import logging
3
+ import time
4
+ from typing import IO, Any, Dict, List, Optional, Tuple, Union
5
+
6
+ import aioboto3
7
+ from aiobotocore.credentials import ContainerProvider, InstanceMetadataProvider
8
+ from aiobotocore.session import ClientCreatorContext, get_session
9
+ from aiobotocore.utils import InstanceMetadataFetcher
10
+ from redis.asyncio import Redis
11
+ from redis.asyncio.client import Pipeline
12
+ from redis.exceptions import AuthenticationError
13
+
14
+ from karton.core import Config, Task
15
+ from karton.core.asyncio.resource import RemoteResource
16
+ from karton.core.backend import (
17
+ KARTON_BINDS_HSET,
18
+ KARTON_TASK_NAMESPACE,
19
+ KARTON_TASKS_QUEUE,
20
+ KartonBackendBase,
21
+ KartonBind,
22
+ KartonMetrics,
23
+ KartonServiceInfo,
24
+ )
25
+ from karton.core.task import TaskState
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class KartonAsyncBackend(KartonBackendBase):
31
+ def __init__(
32
+ self,
33
+ config: Config,
34
+ identity: Optional[str] = None,
35
+ service_info: Optional[KartonServiceInfo] = None,
36
+ ) -> None:
37
+ super().__init__(config, identity, service_info)
38
+ self._redis: Optional[Redis] = None
39
+ self._s3_session: Optional[aioboto3.Session] = None
40
+ self._s3_iam_auth = False
41
+
42
+ @property
43
+ def redis(self) -> Redis:
44
+ if not self._redis:
45
+ raise RuntimeError("Call connect() first before using KartonAsyncBackend")
46
+ return self._redis
47
+
48
+ @property
49
+ def s3(self) -> ClientCreatorContext:
50
+ if not self._s3_session:
51
+ raise RuntimeError("Call connect() first before using KartonAsyncBackend")
52
+ endpoint = self.config.get("s3", "address")
53
+ if self._s3_iam_auth:
54
+ return self._s3_session.client(
55
+ "s3",
56
+ endpoint_url=endpoint,
57
+ )
58
+ else:
59
+ access_key = self.config.get("s3", "access_key")
60
+ secret_key = self.config.get("s3", "secret_key")
61
+ return self._s3_session.client(
62
+ "s3",
63
+ endpoint_url=endpoint,
64
+ aws_access_key_id=access_key,
65
+ aws_secret_access_key=secret_key,
66
+ )
67
+
68
+ async def connect(self):
69
+ if self._redis is not None or self._s3_session is not None:
70
+ # Already connected
71
+ return
72
+ self._redis = await self.make_redis(
73
+ self.config, identity=self.identity, service_info=self.service_info
74
+ )
75
+
76
+ endpoint = self.config.get("s3", "address")
77
+ access_key = self.config.get("s3", "access_key")
78
+ secret_key = self.config.get("s3", "secret_key")
79
+ iam_auth = self.config.getboolean("s3", "iam_auth")
80
+
81
+ if not endpoint:
82
+ raise RuntimeError("Attempting to get S3 client without an endpoint set")
83
+
84
+ if access_key and secret_key and iam_auth:
85
+ logger.warning(
86
+ "Warning: iam is turned on and both S3 access key and secret key are"
87
+ " provided"
88
+ )
89
+
90
+ if iam_auth:
91
+ s3_client_creator = await self.iam_auth_s3()
92
+ if s3_client_creator:
93
+ self._s3_iam_auth = True
94
+ self._s3_session = s3_client_creator
95
+ return
96
+
97
+ if access_key is None or secret_key is None:
98
+ raise RuntimeError(
99
+ "Attempting to get S3 client without an access_key/secret_key set"
100
+ )
101
+
102
+ session = aioboto3.Session()
103
+ self._s3_session = session
104
+
105
+ async def iam_auth_s3(self):
106
+ boto_session = get_session()
107
+ iam_providers = [
108
+ ContainerProvider(),
109
+ InstanceMetadataProvider(
110
+ iam_role_fetcher=InstanceMetadataFetcher(timeout=1000, num_attempts=2)
111
+ ),
112
+ ]
113
+
114
+ for provider in iam_providers:
115
+ creds = await provider.load()
116
+ if creds:
117
+ boto_session._credentials = creds # type: ignore
118
+ return aioboto3.Session(botocore_session=boto_session)
119
+
120
+ @staticmethod
121
+ async def make_redis(
122
+ config,
123
+ identity: Optional[str] = None,
124
+ service_info: Optional[KartonServiceInfo] = None,
125
+ ) -> Redis:
126
+ """
127
+ Create and test a Redis connection.
128
+
129
+ :param config: The karton configuration
130
+ :param identity: Karton service identity
131
+ :param service_info: Additional service identity metadata
132
+ :return: Redis connection
133
+ """
134
+ if service_info is not None:
135
+ client_name: Optional[str] = service_info.make_client_name()
136
+ else:
137
+ client_name = identity
138
+
139
+ redis_args = {
140
+ "host": config["redis"]["host"],
141
+ "port": config.getint("redis", "port", 6379),
142
+ "db": config.getint("redis", "db", 0),
143
+ "username": config.get("redis", "username"),
144
+ "password": config.get("redis", "password"),
145
+ "client_name": client_name,
146
+ # set socket_timeout to None if set to 0
147
+ "socket_timeout": config.getint("redis", "socket_timeout", 30) or None,
148
+ "decode_responses": True,
149
+ }
150
+ try:
151
+ rs = Redis(**redis_args)
152
+ await rs.ping()
153
+ except AuthenticationError:
154
+ # Maybe we've sent a wrong password.
155
+ # Or maybe the server is not (yet) password protected
156
+ # To make smooth transition possible, try to login insecurely
157
+ del redis_args["password"]
158
+ rs = Redis(**redis_args)
159
+ await rs.ping()
160
+ return rs
161
+
162
+ def unserialize_resource(self, resource_spec: Dict[str, Any]) -> RemoteResource:
163
+ """
164
+ Unserializes resource into a RemoteResource object bound with current backend
165
+
166
+ :param resource_spec: Resource specification
167
+ :return: RemoteResource object
168
+ """
169
+ return RemoteResource.from_dict(resource_spec, backend=self)
170
+
171
+ async def register_task(self, task: Task, pipe: Optional[Pipeline] = None) -> None:
172
+ """
173
+ Register or update task in Redis.
174
+
175
+ :param task: Task object
176
+ :param pipe: Optional pipeline object if operation is a part of pipeline
177
+ """
178
+ rs = pipe or self.redis
179
+ await rs.set(f"{KARTON_TASK_NAMESPACE}:{task.uid}", task.serialize())
180
+
181
+ async def set_task_status(
182
+ self, task: Task, status: TaskState, pipe: Optional[Pipeline] = None
183
+ ) -> None:
184
+ """
185
+ Request task status change to be applied by karton-system
186
+
187
+ :param task: Task object
188
+ :param status: New task status (TaskState)
189
+ :param pipe: Optional pipeline object if operation is a part of pipeline
190
+ """
191
+ if task.status == status:
192
+ return
193
+ task.status = status
194
+ task.last_update = time.time()
195
+ await self.register_task(task, pipe=pipe)
196
+
197
+ async def register_bind(self, bind: KartonBind) -> Optional[KartonBind]:
198
+ """
199
+ Register bind for Karton service and return the old one
200
+
201
+ :param bind: KartonBind object with bind definition
202
+ :return: Old KartonBind that was registered under this identity
203
+ """
204
+ async with self.redis.pipeline(transaction=True) as pipe:
205
+ await pipe.hget(KARTON_BINDS_HSET, bind.identity)
206
+ await pipe.hset(KARTON_BINDS_HSET, bind.identity, self.serialize_bind(bind))
207
+ old_serialized_bind, _ = await pipe.execute()
208
+
209
+ if old_serialized_bind:
210
+ return self.unserialize_bind(bind.identity, old_serialized_bind)
211
+ else:
212
+ return None
213
+
214
+ async def get_bind(self, identity: str) -> KartonBind:
215
+ """
216
+ Get bind object for given identity
217
+
218
+ :param identity: Karton service identity
219
+ :return: KartonBind object
220
+ """
221
+ return self.unserialize_bind(
222
+ identity, await self.redis.hget(KARTON_BINDS_HSET, identity)
223
+ )
224
+
225
+ async def produce_unrouted_task(self, task: Task) -> None:
226
+ """
227
+ Add given task to unrouted task (``karton.tasks``) queue
228
+
229
+ Task must be registered before with :py:meth:`register_task`
230
+
231
+ :param task: Task object
232
+ """
233
+ await self.redis.rpush(KARTON_TASKS_QUEUE, task.uid)
234
+
235
+ async def consume_queues(
236
+ self, queues: Union[str, List[str]], timeout: int = 0
237
+ ) -> Optional[Tuple[str, str]]:
238
+ """
239
+ Get item from queues (ordered from the most to the least prioritized)
240
+ If there are no items, wait until one appear.
241
+
242
+ :param queues: Redis queue name or list of names
243
+ :param timeout: Waiting for item timeout (default: 0 = wait forever)
244
+ :return: Tuple of [queue_name, item] objects or None if timeout has been reached
245
+ """
246
+ return await self.redis.blpop(queues, timeout=timeout)
247
+
248
+ async def get_task(self, task_uid: str) -> Optional[Task]:
249
+ """
250
+ Get task object with given identifier
251
+
252
+ :param task_uid: Task identifier
253
+ :return: Task object
254
+ """
255
+ task_data = await self.redis.get(f"{KARTON_TASK_NAMESPACE}:{task_uid}")
256
+ if not task_data:
257
+ return None
258
+ return Task.unserialize(
259
+ task_data, resource_unserializer=self.unserialize_resource
260
+ )
261
+
262
+ async def consume_routed_task(
263
+ self, identity: str, timeout: int = 5
264
+ ) -> Optional[Task]:
265
+ """
266
+ Get routed task for given consumer identity.
267
+
268
+ If there are no tasks, blocks until new one appears or timeout is reached.
269
+
270
+ :param identity: Karton service identity
271
+ :param timeout: Waiting for task timeout (default: 5)
272
+ :return: Task object
273
+ """
274
+ item = await self.consume_queues(
275
+ self.get_queue_names(identity),
276
+ timeout=timeout,
277
+ )
278
+ if not item:
279
+ return None
280
+ queue, data = item
281
+ return await self.get_task(data)
282
+
283
+ async def increment_metrics(
284
+ self, metric: KartonMetrics, identity: str, pipe: Optional[Pipeline] = None
285
+ ) -> None:
286
+ """
287
+ Increments metrics for given operation type and identity
288
+
289
+ :param metric: Operation metric type
290
+ :param identity: Related Karton service identity
291
+ :param pipe: Optional pipeline object if operation is a part of pipeline
292
+ """
293
+ rs = pipe or self.redis
294
+ await rs.hincrby(metric.value, identity, 1)
295
+
296
+ async def upload_object(
297
+ self,
298
+ bucket: str,
299
+ object_uid: str,
300
+ content: Union[bytes, IO[bytes]],
301
+ ) -> None:
302
+ """
303
+ Upload resource object to underlying object storage (S3)
304
+
305
+ :param bucket: Bucket name
306
+ :param object_uid: Object identifier
307
+ :param content: Object content as bytes or file-like stream
308
+ """
309
+ async with self.s3 as client:
310
+ await client.put_object(Bucket=bucket, Key=object_uid, Body=content)
311
+
312
+ async def upload_object_from_file(
313
+ self, bucket: str, object_uid: str, path: str
314
+ ) -> None:
315
+ """
316
+ Upload resource object file to underlying object storage
317
+
318
+ :param bucket: Bucket name
319
+ :param object_uid: Object identifier
320
+ :param path: Path to the object content
321
+ """
322
+ async with self.s3 as client:
323
+ with open(path, "rb") as f:
324
+ await client.put_object(Bucket=bucket, Key=object_uid, Body=f)
325
+
326
+ async def download_object(self, bucket: str, object_uid: str) -> bytes:
327
+ """
328
+ Download resource object from object storage.
329
+
330
+ :param bucket: Bucket name
331
+ :param object_uid: Object identifier
332
+ :return: Content bytes
333
+ """
334
+ async with self.s3 as client:
335
+ obj = await client.get_object(Bucket=bucket, Key=object_uid)
336
+ return await obj["Body"].read()
337
+
338
+ async def download_object_to_file(
339
+ self, bucket: str, object_uid: str, path: str
340
+ ) -> None:
341
+ """
342
+ Download resource object from object storage to file
343
+
344
+ :param bucket: Bucket name
345
+ :param object_uid: Object identifier
346
+ :param path: Target file path
347
+ """
348
+ async with self.s3 as client:
349
+ await client.download_file(Bucket=bucket, Key=object_uid, Filename=path)
350
+
351
+ async def produce_log(
352
+ self,
353
+ log_record: Dict[str, Any],
354
+ logger_name: str,
355
+ level: str,
356
+ ) -> bool:
357
+ """
358
+ Push new log record to the logs channel
359
+
360
+ :param log_record: Dict with log record
361
+ :param logger_name: Logger name
362
+ :param level: Log level
363
+ :return: True if any active log consumer received log record
364
+ """
365
+ return (
366
+ await self.redis.publish(
367
+ self._log_channel(logger_name, level), json.dumps(log_record)
368
+ )
369
+ > 0
370
+ )
@@ -0,0 +1,133 @@
1
+ import abc
2
+ import asyncio
3
+ import signal
4
+ from asyncio import CancelledError
5
+ from typing import Optional
6
+
7
+ from karton.core import Task
8
+ from karton.core.__version__ import __version__
9
+ from karton.core.backend import KartonServiceInfo
10
+ from karton.core.base import ConfigMixin, LoggingMixin
11
+ from karton.core.config import Config
12
+ from karton.core.task import get_current_task, set_current_task
13
+ from karton.core.utils import StrictClassMethod
14
+
15
+ from .backend import KartonAsyncBackend
16
+ from .logger import KartonAsyncLogHandler
17
+
18
+
19
+ class KartonAsyncBase(abc.ABC, ConfigMixin, LoggingMixin):
20
+ """
21
+ Base class for all Karton services
22
+
23
+ You can set an informative version information by setting the ``version`` class
24
+ attribute.
25
+ """
26
+
27
+ #: Karton service identity
28
+ identity: str = ""
29
+ #: Karton service version
30
+ version: Optional[str] = None
31
+ #: Include extended service information for non-consumer services
32
+ with_service_info: bool = False
33
+
34
+ def __init__(
35
+ self,
36
+ config: Optional[Config] = None,
37
+ identity: Optional[str] = None,
38
+ backend: Optional[KartonAsyncBackend] = None,
39
+ ) -> None:
40
+ ConfigMixin.__init__(self, config, identity)
41
+
42
+ self.service_info = None
43
+ if self.identity is not None and self.with_service_info:
44
+ self.service_info = KartonServiceInfo(
45
+ identity=self.identity,
46
+ karton_version=__version__,
47
+ service_version=self.version,
48
+ )
49
+
50
+ self.backend = backend or KartonAsyncBackend(
51
+ self.config, identity=self.identity, service_info=self.service_info
52
+ )
53
+
54
+ log_handler = KartonAsyncLogHandler(backend=self.backend, channel=self.identity)
55
+ LoggingMixin.__init__(
56
+ self,
57
+ log_handler,
58
+ log_format="[%(asctime)s][%(levelname)s][%(task_id)s] %(message)s",
59
+ )
60
+
61
+ async def connect(self) -> None:
62
+ await self.backend.connect()
63
+
64
+ @property
65
+ def current_task(self) -> Optional[Task]:
66
+ return get_current_task()
67
+
68
+ @current_task.setter
69
+ def current_task(self, task: Optional[Task]):
70
+ set_current_task(task)
71
+
72
+
73
+ class KartonAsyncServiceBase(KartonAsyncBase):
74
+ """
75
+ Karton base class for looping services.
76
+
77
+ You can set an informative version information by setting the ``version`` class
78
+ attribute
79
+
80
+ :param config: Karton config to use for service configuration
81
+ :param identity: Karton service identity to use
82
+ :param backend: Karton backend to use
83
+ """
84
+
85
+ def __init__(
86
+ self,
87
+ config: Optional[Config] = None,
88
+ identity: Optional[str] = None,
89
+ backend: Optional[KartonAsyncBackend] = None,
90
+ ) -> None:
91
+ super().__init__(
92
+ config=config,
93
+ identity=identity,
94
+ backend=backend,
95
+ )
96
+ self.setup_logger()
97
+ self._loop_coro: Optional[asyncio.Task] = None
98
+
99
+ def _do_shutdown(self) -> None:
100
+ self.log.info("Got signal, shutting down...")
101
+ if self._loop_coro is not None:
102
+ self._loop_coro.cancel()
103
+
104
+ @abc.abstractmethod
105
+ async def _loop(self) -> None:
106
+ raise NotImplementedError
107
+
108
+ # Base class for Karton services
109
+ async def loop(self) -> None:
110
+ if self.enable_publish_log and hasattr(self.log_handler, "start_consuming"):
111
+ self.log_handler.start_consuming()
112
+ await self.connect()
113
+ event_loop = asyncio.get_event_loop()
114
+ for sig in (signal.SIGTERM, signal.SIGINT):
115
+ event_loop.add_signal_handler(sig, self._do_shutdown)
116
+ self._loop_coro = asyncio.create_task(self._loop())
117
+ try:
118
+ await self._loop_coro
119
+ finally:
120
+ for sig in (signal.SIGTERM, signal.SIGINT):
121
+ event_loop.remove_signal_handler(sig)
122
+ if self.enable_publish_log and hasattr(self.log_handler, "stop_consuming"):
123
+ await self.log_handler.stop_consuming()
124
+
125
+ @StrictClassMethod
126
+ def main(cls) -> None:
127
+ """Main method invoked from CLI."""
128
+ service = cls.karton_from_args()
129
+ try:
130
+ asyncio.run(service.loop())
131
+ except CancelledError:
132
+ # Swallow cancellation, we're done!
133
+ pass