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