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.
karton/core/base.py CHANGED
@@ -9,32 +9,16 @@ from typing import Optional, Union, cast
9
9
  from .__version__ import __version__
10
10
  from .backend import KartonBackend, KartonServiceInfo
11
11
  from .config import Config
12
- from .logger import KartonLogHandler
13
- from .task import Task
12
+ from .logger import KartonLogHandler, TaskContextFilter
13
+ from .task import Task, get_current_task, set_current_task
14
14
  from .utils import HardShutdownInterrupt, StrictClassMethod, graceful_killer
15
15
 
16
16
 
17
- class KartonBase(abc.ABC):
18
- """
19
- Base class for all Karton services
20
-
21
- You can set an informative version information by setting the ``version`` class
22
- attribute.
23
- """
24
-
25
- #: Karton service identity
26
- identity: str = ""
27
- #: Karton service version
28
- version: Optional[str] = None
29
- #: Include extended service information for non-consumer services
30
- with_service_info: bool = False
17
+ class ConfigMixin:
18
+ identity: Optional[str]
19
+ version: Optional[str]
31
20
 
32
- def __init__(
33
- self,
34
- config: Optional[Config] = None,
35
- identity: Optional[str] = None,
36
- backend: Optional[KartonBackend] = None,
37
- ) -> None:
21
+ def __init__(self, config: Optional[Config] = None, identity: Optional[str] = None):
38
22
  self.config = config or Config()
39
23
  self.enable_publish_log = self.config.getboolean(
40
24
  "logging", "enable_publish", True
@@ -50,25 +34,81 @@ class KartonBase(abc.ABC):
50
34
 
51
35
  self.debug = self.config.getboolean("karton", "debug", False)
52
36
 
53
- if self.debug:
37
+ if self.debug and self.identity:
54
38
  self.identity += "-" + os.urandom(4).hex() + "-dev"
55
39
 
56
- self.service_info = None
57
- if self.identity is not None and self.with_service_info:
58
- self.service_info = KartonServiceInfo(
59
- identity=self.identity,
60
- karton_version=__version__,
61
- service_version=self.version,
62
- )
40
+ @classmethod
41
+ def args_description(cls) -> str:
42
+ """Return short description for argument parser."""
43
+ if not cls.__doc__:
44
+ return ""
45
+ return textwrap.dedent(cls.__doc__).strip().splitlines()[0]
63
46
 
64
- self.backend = backend or KartonBackend(
65
- self.config, identity=self.identity, service_info=self.service_info
47
+ @classmethod
48
+ def args_parser(cls) -> argparse.ArgumentParser:
49
+ """
50
+ Return ArgumentParser for main() class method.
51
+
52
+ This method should be overridden and call super methods
53
+ if you want to add more arguments.
54
+ """
55
+ parser = argparse.ArgumentParser(description=cls.args_description())
56
+ parser.add_argument(
57
+ "--version", action="version", version=cast(str, cls.version)
58
+ )
59
+ parser.add_argument("--config-file", help="Alternative configuration path")
60
+ parser.add_argument(
61
+ "--identity", help="Alternative identity for Karton service"
66
62
  )
63
+ parser.add_argument("--log-level", help="Logging level of Karton logger")
64
+ parser.add_argument(
65
+ "--debug", help="Enable debugging mode", action="store_true", default=None
66
+ )
67
+ return parser
68
+
69
+ @classmethod
70
+ def config_from_args(cls, config: Config, args: argparse.Namespace) -> None:
71
+ """
72
+ Updates configuration with settings from arguments
67
73
 
68
- self._log_handler = KartonLogHandler(
69
- backend=self.backend, channel=self.identity
74
+ This method should be overridden and call super methods
75
+ if you want to add more arguments.
76
+ """
77
+ config.load_from_dict(
78
+ {
79
+ "karton": {
80
+ "identity": args.identity,
81
+ "debug": args.debug,
82
+ },
83
+ "logging": {"level": args.log_level},
84
+ }
70
85
  )
71
- self.current_task: Optional[Task] = None
86
+
87
+ @classmethod
88
+ def karton_from_args(cls, args: Optional[argparse.Namespace] = None):
89
+ """
90
+ Returns Karton instance configured using configuration files
91
+ and provided arguments
92
+
93
+ Used by :py:meth:`KartonServiceBase.main` method
94
+ """
95
+ if args is None:
96
+ parser = cls.args_parser()
97
+ args = parser.parse_args()
98
+ config = Config(path=args.config_file)
99
+ cls.config_from_args(config, args)
100
+ return cls(config=config)
101
+
102
+
103
+ class LoggingMixin:
104
+ config: Config
105
+ identity: Optional[str]
106
+ debug: bool
107
+ enable_publish_log: bool
108
+
109
+ def __init__(self, log_handler: logging.Handler, log_format: str) -> None:
110
+ self._log_handler = log_handler
111
+ self._log_format = log_format
72
112
 
73
113
  def setup_logger(self, level: Optional[Union[str, int]] = None) -> None:
74
114
  """
@@ -91,10 +131,11 @@ class KartonBase(abc.ABC):
91
131
  if not self.identity:
92
132
  raise ValueError("Can't setup logger without identity")
93
133
 
134
+ task_context_filter = TaskContextFilter()
94
135
  self._log_handler.setFormatter(logging.Formatter())
136
+ self._log_handler.addFilter(task_context_filter)
95
137
 
96
138
  logger = logging.getLogger(self.identity)
97
-
98
139
  if logger.handlers:
99
140
  # If logger already have handlers set: clear them
100
141
  logger.handlers.clear()
@@ -106,16 +147,15 @@ class KartonBase(abc.ABC):
106
147
 
107
148
  logger.setLevel(log_level)
108
149
  stream_handler = logging.StreamHandler()
109
- stream_handler.setFormatter(
110
- logging.Formatter("[%(asctime)s][%(levelname)s] %(message)s")
111
- )
150
+ stream_handler.setFormatter(logging.Formatter(self._log_format))
151
+ stream_handler.addFilter(task_context_filter)
112
152
  logger.addHandler(stream_handler)
113
153
 
114
154
  if not self.debug and self.enable_publish_log:
115
155
  logger.addHandler(self._log_handler)
116
156
 
117
157
  @property
118
- def log_handler(self) -> KartonLogHandler:
158
+ def log_handler(self) -> logging.Handler:
119
159
  """
120
160
  Return KartonLogHandler bound to this Karton service.
121
161
 
@@ -141,67 +181,54 @@ class KartonBase(abc.ABC):
141
181
  """
142
182
  return logging.getLogger(self.identity)
143
183
 
144
- @classmethod
145
- def args_description(cls) -> str:
146
- """Return short description for argument parser."""
147
- if not cls.__doc__:
148
- return ""
149
- return textwrap.dedent(cls.__doc__).strip().splitlines()[0]
150
184
 
151
- @classmethod
152
- def args_parser(cls) -> argparse.ArgumentParser:
153
- """
154
- Return ArgumentParser for main() class method.
185
+ class KartonBase(abc.ABC, ConfigMixin, LoggingMixin):
186
+ """
187
+ Base class for all Karton services
155
188
 
156
- This method should be overridden and call super methods
157
- if you want to add more arguments.
158
- """
159
- parser = argparse.ArgumentParser(description=cls.args_description())
160
- parser.add_argument(
161
- "--version", action="version", version=cast(str, cls.version)
162
- )
163
- parser.add_argument("--config-file", help="Alternative configuration path")
164
- parser.add_argument(
165
- "--identity", help="Alternative identity for Karton service"
166
- )
167
- parser.add_argument("--log-level", help="Logging level of Karton logger")
168
- parser.add_argument(
169
- "--debug", help="Enable debugging mode", action="store_true", default=None
170
- )
171
- return parser
189
+ You can set an informative version information by setting the ``version`` class
190
+ attribute.
191
+ """
172
192
 
173
- @classmethod
174
- def config_from_args(cls, config: Config, args: argparse.Namespace) -> None:
175
- """
176
- Updates configuration with settings from arguments
193
+ #: Karton service identity
194
+ identity: str = ""
195
+ #: Karton service version
196
+ version: Optional[str] = None
197
+ #: Include extended service information for non-consumer services
198
+ with_service_info: bool = False
177
199
 
178
- This method should be overridden and call super methods
179
- if you want to add more arguments.
180
- """
181
- config.load_from_dict(
182
- {
183
- "karton": {
184
- "identity": args.identity,
185
- "debug": args.debug,
186
- },
187
- "logging": {"level": args.log_level},
188
- }
200
+ def __init__(
201
+ self,
202
+ config: Optional[Config] = None,
203
+ identity: Optional[str] = None,
204
+ backend: Optional[KartonBackend] = None,
205
+ ) -> None:
206
+ ConfigMixin.__init__(self, config, identity)
207
+
208
+ self.service_info = None
209
+ if self.identity is not None and self.with_service_info:
210
+ self.service_info = KartonServiceInfo(
211
+ identity=self.identity,
212
+ karton_version=__version__,
213
+ service_version=self.version,
214
+ )
215
+
216
+ self.backend = backend or KartonBackend(
217
+ self.config, identity=self.identity, service_info=self.service_info
189
218
  )
190
219
 
191
- @classmethod
192
- def karton_from_args(cls, args: Optional[argparse.Namespace] = None):
193
- """
194
- Returns Karton instance configured using configuration files
195
- and provided arguments
220
+ log_handler = KartonLogHandler(backend=self.backend, channel=self.identity)
221
+ LoggingMixin.__init__(
222
+ self, log_handler, log_format="[%(asctime)s][%(levelname)s] %(message)s"
223
+ )
196
224
 
197
- Used by :py:meth:`KartonServiceBase.main` method
198
- """
199
- if args is None:
200
- parser = cls.args_parser()
201
- args = parser.parse_args()
202
- config = Config(path=args.config_file)
203
- cls.config_from_args(config, args)
204
- return cls(config=config)
225
+ @property
226
+ def current_task(self) -> Optional[Task]:
227
+ return get_current_task()
228
+
229
+ @current_task.setter
230
+ def current_task(self, task: Optional[Task]):
231
+ set_current_task(task)
205
232
 
206
233
 
207
234
  class KartonServiceBase(KartonBase):
karton/core/config.py CHANGED
@@ -14,6 +14,7 @@ class Config(object):
14
14
  - ``/etc/karton/karton.ini`` (global)
15
15
  - ``~/.config/karton/karton.ini`` (user local)
16
16
  - ``./karton.ini`` (subsystem local)
17
+ - path from ``KARTON_CONFIG_FILE`` environment variable
17
18
  - ``<path>`` optional, additional path provided in arguments
18
19
 
19
20
  It is also possible to pass configuration via environment variables.
@@ -38,6 +39,12 @@ class Config(object):
38
39
  ) -> None:
39
40
  self._config: Dict[str, Dict[str, Any]] = {}
40
41
 
42
+ path_from_env = os.getenv("KARTON_CONFIG_FILE")
43
+ if path_from_env:
44
+ if not os.path.isfile(path_from_env):
45
+ raise IOError(f"Configuration file not found in {path_from_env}")
46
+ self.SEARCH_PATHS = self.SEARCH_PATHS + [path_from_env]
47
+
41
48
  if path is not None:
42
49
  if not os.path.isfile(path):
43
50
  raise IOError("Configuration file not found in " + path)
@@ -116,6 +123,11 @@ class Config(object):
116
123
  @overload
117
124
  def getint(self, section_name: str, option_name: str) -> Optional[int]: ...
118
125
 
126
+ @overload
127
+ def getint(
128
+ self, section_name: str, option_name: str, fallback: Optional[int]
129
+ ) -> Optional[int]: ...
130
+
119
131
  def getint(
120
132
  self, section_name: str, option_name: str, fallback: Optional[int] = None
121
133
  ) -> Optional[int]:
@@ -217,7 +229,7 @@ class Config(object):
217
229
  for name, value in os.environ.items():
218
230
  # Load env variables named KARTON_[section]_[key]
219
231
  # to match ConfigParser structure
220
- result = re.fullmatch(r"KARTON_([A-Z0-9-]+)_([A-Z0-9_]+)", name)
232
+ result = re.fullmatch(r"KARTON_([A-Z0-9\-\.]+)_([A-Z0-9_]+)", name)
221
233
 
222
234
  if not result:
223
235
  continue
karton/core/karton.py CHANGED
@@ -80,13 +80,8 @@ class Producer(KartonBase):
80
80
  task.last_update = time.time()
81
81
  task.headers.update({"origin": self.identity})
82
82
 
83
- # Ensure all local resources have good buckets
84
- for resource in task.iterate_resources():
85
- if isinstance(resource, LocalResource) and not resource.bucket:
86
- resource.bucket = self.backend.default_bucket_name
87
-
88
83
  # Register new task
89
- self.backend.register_task(task)
84
+ self.backend.declare_task(task)
90
85
 
91
86
  # Upload local resources
92
87
  for resource in task.iterate_resources():
@@ -137,7 +132,7 @@ class Consumer(KartonServiceBase):
137
132
  )
138
133
  if self.task_timeout is None:
139
134
  self.task_timeout = self.config.getint("karton", "task_timeout")
140
- self.current_task: Optional[Task] = None
135
+
141
136
  self._pre_hooks: List[Tuple[Optional[str], Callable[[Task], None]]] = []
142
137
  self._post_hooks: List[
143
138
  Tuple[
@@ -170,19 +165,22 @@ class Consumer(KartonServiceBase):
170
165
  """
171
166
 
172
167
  self.current_task = task
173
- self.log_handler.set_task(self.current_task)
174
168
 
175
- if not self.current_task.matches_filters(self.filters):
176
- self.log.info("Task rejected because binds are no longer valid.")
177
- self.backend.set_task_status(self.current_task, TaskState.FINISHED)
169
+ if not task.matches_filters(self.filters):
170
+ self.log.info(
171
+ "Task rejected because binds are no longer valid. "
172
+ "Rejected ask headers: %s",
173
+ task.headers,
174
+ )
175
+ self.backend.set_task_status(task, TaskState.FINISHED)
178
176
  # Task rejected: end of processing
179
177
  return
180
178
 
181
179
  exception_str = None
182
180
 
183
181
  try:
184
- self.log.info("Received new task - %s", self.current_task.uid)
185
- self.backend.set_task_status(self.current_task, TaskState.STARTED)
182
+ self.log.info("Received new task - %s", task.uid)
183
+ self.backend.set_task_status(task, TaskState.STARTED)
186
184
 
187
185
  self._run_pre_hooks()
188
186
 
@@ -190,22 +188,22 @@ class Consumer(KartonServiceBase):
190
188
  try:
191
189
  if self.task_timeout:
192
190
  with timeout(self.task_timeout):
193
- self.process(self.current_task)
191
+ self.process(task)
194
192
  else:
195
- self.process(self.current_task)
193
+ self.process(task)
196
194
  except (Exception, TaskTimeoutError) as exc:
197
195
  saved_exception = exc
198
196
  raise
199
197
  finally:
200
198
  self._run_post_hooks(saved_exception)
201
199
 
202
- self.log.info("Task done - %s", self.current_task.uid)
200
+ self.log.info("Task done - %s", task.uid)
203
201
  except (Exception, TaskTimeoutError):
204
202
  exc_info = sys.exc_info()
205
203
  exception_str = traceback.format_exception(*exc_info)
206
204
 
207
205
  self.backend.increment_metrics(KartonMetrics.TASK_CRASHED, self.identity)
208
- self.log.exception("Failed to process task - %s", self.current_task.uid)
206
+ self.log.exception("Failed to process task - %s", task.uid)
209
207
  finally:
210
208
  self.backend.increment_metrics(KartonMetrics.TASK_CONSUMED, self.identity)
211
209
 
@@ -215,9 +213,10 @@ class Consumer(KartonServiceBase):
215
213
  # if an exception was caught while processing
216
214
  if exception_str is not None:
217
215
  task_state = TaskState.CRASHED
218
- self.current_task.error = exception_str
216
+ task.error = exception_str
219
217
 
220
- self.backend.set_task_status(self.current_task, task_state)
218
+ self.backend.set_task_status(task, task_state)
219
+ self.current_task = None
221
220
 
222
221
  @property
223
222
  def _bind(self) -> KartonBind:
@@ -228,6 +227,7 @@ class Consumer(KartonServiceBase):
228
227
  filters=self.filters,
229
228
  persistent=self.persistent,
230
229
  service_version=self.__class__.version,
230
+ is_async=False,
231
231
  )
232
232
 
233
233
  @classmethod
@@ -338,15 +338,28 @@ class Consumer(KartonServiceBase):
338
338
  if not old_bind:
339
339
  self.log.info("Service binds created.")
340
340
  elif old_bind != self._bind:
341
- self.log.info("Binds changed, old service instances should exit soon.")
341
+ self.log.info(
342
+ "Binds changed, old service instances should exit soon. "
343
+ "Old binds: %s "
344
+ "New binds: %s",
345
+ old_bind,
346
+ self._bind,
347
+ )
342
348
 
343
349
  for task_filter in self.filters:
344
350
  self.log.info("Binding on: %s", task_filter)
345
351
 
346
352
  with self.graceful_killer():
347
353
  while not self.shutdown:
348
- if self.backend.get_bind(self.identity) != self._bind:
349
- self.log.info("Binds changed, shutting down.")
354
+ current_bind = self.backend.get_bind(self.identity)
355
+ if current_bind != self._bind:
356
+ self.log.info(
357
+ "Binds changed, shutting down. "
358
+ "Old binds: %s "
359
+ "New binds: %s",
360
+ self._bind,
361
+ current_bind,
362
+ )
350
363
  break
351
364
  task = self.backend.consume_routed_task(self.identity)
352
365
  if task:
karton/core/logger.py CHANGED
@@ -2,30 +2,32 @@ import logging
2
2
  import platform
3
3
  import traceback
4
4
  import warnings
5
- from typing import Optional
5
+ from typing import Any, Callable, Dict
6
6
 
7
7
  from .backend import KartonBackend
8
- from .task import Task
8
+ from .task import get_current_task
9
9
 
10
10
  HOSTNAME = platform.node()
11
11
 
12
12
 
13
- class KartonLogHandler(logging.Handler):
13
+ class TaskContextFilter(logging.Filter):
14
14
  """
15
- logging.Handler that passes logs to the Karton backend.
15
+ This is a filter which injects information about current task ID to the log.
16
16
  """
17
17
 
18
- def __init__(self, backend: KartonBackend, channel: str) -> None:
19
- logging.Handler.__init__(self)
20
- self.backend = backend
21
- self.task: Optional[Task] = None
22
- self.is_consumer_active: bool = True
23
- self.channel: str = channel
18
+ def filter(self, record: logging.LogRecord) -> bool:
19
+ current_task = get_current_task()
20
+ if current_task is not None:
21
+ record.task_id = current_task.task_uid
22
+ else:
23
+ record.task_id = "(no task)"
24
+ return True
24
25
 
25
- def set_task(self, task: Task) -> None:
26
- self.task = task
27
26
 
28
- def emit(self, record: logging.LogRecord) -> None:
27
+ class LogLineFormatterMixin:
28
+ format: Callable[[logging.LogRecord], str]
29
+
30
+ def prepare_log_line(self, record: logging.LogRecord) -> Dict[str, Any]:
29
31
  ignore_fields = [
30
32
  "args",
31
33
  "asctime",
@@ -54,11 +56,27 @@ class KartonLogHandler(logging.Handler):
54
56
  log_line["type"] = "log"
55
57
  log_line["message"] = self.format(record)
56
58
 
57
- if self.task is not None:
58
- log_line["task"] = self.task.serialize()
59
+ current_task = get_current_task()
60
+ if current_task is not None:
61
+ log_line["task"] = current_task.serialize()
59
62
 
60
63
  log_line["hostname"] = HOSTNAME
64
+ return log_line
65
+
66
+
67
+ class KartonLogHandler(logging.Handler, LogLineFormatterMixin):
68
+ """
69
+ logging.Handler that passes logs to the Karton backend.
70
+ """
71
+
72
+ def __init__(self, backend: KartonBackend, channel: str) -> None:
73
+ logging.Handler.__init__(self)
74
+ self.backend = backend
75
+ self.is_consumer_active: bool = True
76
+ self.channel: str = channel
61
77
 
78
+ def emit(self, record: logging.LogRecord) -> None:
79
+ log_line = self.prepare_log_line(record)
62
80
  log_consumed = self.backend.produce_log(
63
81
  log_line, logger_name=self.channel, level=record.levelname
64
82
  )
karton/core/main.py CHANGED
@@ -144,10 +144,23 @@ def configuration_wizard(config_filename: str) -> None:
144
144
  log.info("Saved the new configuration file in %s", os.path.abspath(config_filename))
145
145
 
146
146
 
147
- def print_bind_list(config: Config) -> None:
147
+ def print_bind_list(config: Config, output_format: str) -> None:
148
148
  backend = KartonBackend(config=config)
149
- for bind in backend.get_binds():
150
- print(bind)
149
+
150
+ if output_format == "table":
151
+ # Print a human-readable table-like version
152
+ print(f"{'karton name':50} {'version':10} {'karton':10}")
153
+ print("-" * 72)
154
+ for bind in backend.get_binds():
155
+ print(
156
+ f"{bind.identity:50} {bind.service_version or "-":10} {bind.version:10}"
157
+ )
158
+ elif output_format == "json":
159
+ # Use JSONL, each line is a JSON representing next bind
160
+ for bind in backend.get_binds():
161
+ print(backend.serialize_bind(bind))
162
+ else:
163
+ raise RuntimeError(f"Invalid output format: {output_format}")
151
164
 
152
165
 
153
166
  def delete_bind(config: Config, karton_name: str) -> None:
@@ -180,7 +193,7 @@ def delete_bind(config: Config, karton_name: str) -> None:
180
193
 
181
194
  def main() -> None:
182
195
 
183
- parser = argparse.ArgumentParser(description="Your red pill to the karton-verse")
196
+ parser = argparse.ArgumentParser(description="Karton-core management utility")
184
197
  parser.add_argument("--version", action="version", version=__version__)
185
198
  parser.add_argument("-c", "--config-file", help="Alternative configuration path")
186
199
  parser.add_argument(
@@ -189,7 +202,14 @@ def main() -> None:
189
202
 
190
203
  subparsers = parser.add_subparsers(dest="command", help="sub-command help")
191
204
 
192
- subparsers.add_parser("list", help="List active karton binds")
205
+ list_parser = subparsers.add_parser("list", help="List active karton binds")
206
+ list_parser.add_argument(
207
+ "-o",
208
+ "--output",
209
+ help="Short, human readable output, with names and versions only.",
210
+ default="table",
211
+ choices=("table", "json"),
212
+ )
193
213
 
194
214
  logs_parser = subparsers.add_parser("logs", help="Start streaming logs")
195
215
  logs_parser.add_argument(
@@ -253,7 +273,7 @@ def main() -> None:
253
273
  return
254
274
 
255
275
  if args.command == "list":
256
- print_bind_list(config)
276
+ print_bind_list(config, args.output)
257
277
  elif args.command == "delete":
258
278
  karton_name = args.identity
259
279
  print(
karton/core/resource.py CHANGED
@@ -150,34 +150,7 @@ class ResourceBase(object):
150
150
  }
151
151
 
152
152
 
153
- class LocalResource(ResourceBase):
154
- """
155
- Represents local resource with arbitrary binary data e.g. file contents.
156
-
157
- Local resources will be uploaded to object hub (S3) during
158
- task dispatching.
159
-
160
- .. code-block:: python
161
-
162
- # Creating resource from bytes
163
- sample = Resource("original_name.exe", content=b"X5O!P%@AP[4\\
164
- PZX54(P^)7CC)7}$EICAR-STANDARD-ANT...")
165
-
166
- # Creating resource from path
167
- sample = Resource("original_name.exe", path="sample/original_name.exe")
168
-
169
- :param name: Name of the resource (e.g. name of file)
170
- :param content: Resource content
171
- :param path: Path of file with resource content
172
- :param bucket: Alternative S3 bucket for resource
173
- :param metadata: Resource metadata
174
- :param uid: Alternative S3 resource id
175
- :param sha256: Resource sha256 hash
176
- :param fd: Seekable file descriptor
177
- :param _flags: Resource flags
178
- :param _close_fd: Close file descriptor after upload (default: False)
179
- """
180
-
153
+ class LocalResourceBase(ResourceBase):
181
154
  def __init__(
182
155
  self,
183
156
  name: str,
@@ -194,7 +167,7 @@ class LocalResource(ResourceBase):
194
167
  if len(list(filter(None, [path, content, fd]))) != 1:
195
168
  raise ValueError("You must exclusively provide a path, content or fd")
196
169
 
197
- super(LocalResource, self).__init__(
170
+ super().__init__(
198
171
  name,
199
172
  content=content,
200
173
  path=path,
@@ -247,7 +220,7 @@ class LocalResource(ResourceBase):
247
220
  bucket: Optional[str] = None,
248
221
  metadata: Optional[Dict[str, Any]] = None,
249
222
  uid: Optional[str] = None,
250
- ) -> "LocalResource":
223
+ ) -> "LocalResourceBase":
251
224
  """
252
225
  Resource extension, allowing to pass whole directory as a zipped resource.
253
226
 
@@ -305,6 +278,35 @@ class LocalResource(ResourceBase):
305
278
  _close_fd=True,
306
279
  )
307
280
 
281
+
282
+ class LocalResource(LocalResourceBase):
283
+ """
284
+ Represents local resource with arbitrary binary data e.g. file contents.
285
+
286
+ Local resources will be uploaded to object hub (S3) during
287
+ task dispatching.
288
+
289
+ .. code-block:: python
290
+
291
+ # Creating resource from bytes
292
+ sample = Resource("original_name.exe", content=b"X5O!P%@AP[4\\
293
+ PZX54(P^)7CC)7}$EICAR-STANDARD-ANT...")
294
+
295
+ # Creating resource from path
296
+ sample = Resource("original_name.exe", path="sample/original_name.exe")
297
+
298
+ :param name: Name of the resource (e.g. name of file)
299
+ :param content: Resource content
300
+ :param path: Path of file with resource content
301
+ :param bucket: Alternative S3 bucket for resource
302
+ :param metadata: Resource metadata
303
+ :param uid: Alternative S3 resource id
304
+ :param sha256: Resource sha256 hash
305
+ :param fd: Seekable file descriptor
306
+ :param _flags: Resource flags
307
+ :param _close_fd: Close file descriptor after upload (default: False)
308
+ """
309
+
308
310
  def _upload(self, backend: "KartonBackend") -> None:
309
311
  """Internal function for uploading resources
310
312