hcs-core 0.1.283__py3-none-any.whl → 0.1.316__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.
@@ -0,0 +1,145 @@
1
+ import json
2
+ import logging
3
+ import sys
4
+ import time
5
+ from datetime import datetime, timezone
6
+
7
+ import click
8
+ import httpx
9
+ from yumako import env
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+ _record = None
14
+ _enabled = None
15
+ _app_name = ""
16
+
17
+
18
+ def disable():
19
+ global _enabled
20
+ _enabled = False
21
+
22
+
23
+ def _is_disabled():
24
+ global _enabled
25
+ if _enabled is None:
26
+ _enabled = env.bool("HCS_CLI_TELEMETRY", True)
27
+ return not _enabled
28
+
29
+
30
+ def _get_version():
31
+ try:
32
+ from importlib.metadata import version
33
+
34
+ return version("hcs-cli")
35
+ except Exception as e:
36
+ log.debug(f"Failed to get hcs-cli version: {e}")
37
+ return "unknown"
38
+
39
+
40
+ def _get_record():
41
+ global _record
42
+ if _record is None:
43
+ _record = {
44
+ "@timestamp": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
45
+ "app": _app_name,
46
+ "command": None,
47
+ "options": [],
48
+ "return": -1,
49
+ "error": None,
50
+ "time_ms": -1,
51
+ "version": _get_version(),
52
+ "env": {
53
+ "python_version": sys.version,
54
+ "platform": sys.platform,
55
+ "executable": sys.executable,
56
+ },
57
+ }
58
+ return _record
59
+
60
+
61
+ def start(app_name: str = None):
62
+ if _is_disabled():
63
+ return
64
+
65
+ global _app_name
66
+ _app_name = app_name
67
+ _get_record()
68
+
69
+
70
+ def update(cmd_path: str, params: dict):
71
+ if _is_disabled():
72
+ return
73
+
74
+ record = _get_record()
75
+ record["command"] = cmd_path
76
+ record["options"] = [k.replace("_", "-") for k, v in params.items() if v]
77
+
78
+
79
+ def end(return_code: int = 0, error: Exception = None):
80
+ if _is_disabled():
81
+ return
82
+
83
+ record = _get_record()
84
+ if error:
85
+ if isinstance(error, click.exceptions.Exit):
86
+ return_code = error.exit_code
87
+ elif isinstance(error, SystemExit):
88
+ return_code = error.code
89
+ else:
90
+ record["error"] = str(error)
91
+ if return_code == 0:
92
+ return_code = 1
93
+ record["return"] = return_code
94
+ record["time_ms"] = int((time.time() - datetime.fromisoformat(record["@timestamp"]).timestamp()) * 1000)
95
+
96
+ _fix_missing_commands(record)
97
+ _injest(record)
98
+ return record
99
+
100
+
101
+ def _fix_missing_commands(record):
102
+ if record["command"]:
103
+ return
104
+
105
+ args = sys.argv[1:]
106
+
107
+ # this does not work for all cases, but only as best effort.
108
+ options_started = False
109
+ options = record["options"]
110
+ command = [_app_name]
111
+ for arg in args:
112
+ if arg.startswith("-"):
113
+ options_started = True
114
+
115
+ if options_started:
116
+ if arg.startswith("--"):
117
+ options.append(arg[2:])
118
+ elif arg.startswith("-"):
119
+ options.append(arg[1:])
120
+ else:
121
+ # value. For privacy no logging.
122
+ continue
123
+ else:
124
+ command.append(arg)
125
+
126
+ record["command"] = " ".join(command)
127
+
128
+
129
+ def _injest(doc):
130
+
131
+ # print('TELEMETRY end', json.dumps(doc, indent=4), flush=True)
132
+
133
+ try:
134
+ response = httpx.post(
135
+ "https://collie.omnissa.com/es/hcs-cli/_doc",
136
+ auth=("append_user", "public"),
137
+ headers={"Content-Type": "application/json"},
138
+ content=json.dumps(doc),
139
+ timeout=4,
140
+ verify=False,
141
+ )
142
+ response.raise_for_status()
143
+ except Exception as e:
144
+ log.debug(f"Telemetry ingestion failed: {e}", exc_info=True)
145
+ return
hcs_core/ctxp/util.py CHANGED
@@ -13,6 +13,7 @@ See the License for the specific language governing permissions and
13
13
  limitations under the License.
14
14
  """
15
15
 
16
+ import datetime
16
17
  import json
17
18
  import os
18
19
  import re
@@ -26,6 +27,7 @@ import click
26
27
  import httpx
27
28
  import questionary
28
29
  import yaml
30
+ import yumako
29
31
 
30
32
 
31
33
  class CtxpException(Exception):
@@ -57,6 +59,7 @@ def validate_error_return(reason, return_code):
57
59
  def print_output(data: Any, args: dict, file=sys.stdout):
58
60
  output = args.get("output", "json")
59
61
  fields = args.get("field")
62
+ exclude_field = args.get("exclude_field")
60
63
  ids = args.get("ids", False)
61
64
  first = args.get("first", False)
62
65
 
@@ -68,20 +71,25 @@ def print_output(data: Any, args: dict, file=sys.stdout):
68
71
  try:
69
72
  data = _convert_generator(data)
70
73
  if first and isinstance(data, list):
74
+ if len(data) == 0:
75
+ return
71
76
  data = data[0]
72
77
 
73
78
  if ids:
74
79
  if fields:
75
80
  raise CtxpException("--ids and --fields should not be used together.")
76
81
  data = _convert_to_id_only(data)
77
- elif fields:
78
- data = _filter_fields(data, fields)
82
+ else:
83
+ if fields:
84
+ data = _filter_fields(data, fields)
85
+ if exclude_field:
86
+ data = _exclude_fields(data, exclude_field)
79
87
 
80
88
  if output is None or output == "json":
81
89
  text = json.dumps(data, default=vars, indent=4)
82
90
  elif output == "json-compact":
83
91
  text = json.dumps(data, default=vars)
84
- elif output == "yaml":
92
+ elif output == "yaml" or output == "yml":
85
93
  from . import jsondot
86
94
 
87
95
  text = yaml.dump(jsondot.plain(data), sort_keys=False)
@@ -89,7 +97,18 @@ def print_output(data: Any, args: dict, file=sys.stdout):
89
97
  if isinstance(data, list):
90
98
  text = ""
91
99
  for i in data:
92
- line = i if type(i) is str else json.dumps(i)
100
+ t = type(i)
101
+ if t is str:
102
+ line = i
103
+ elif isinstance(i, dict):
104
+ if len(i) == 0:
105
+ continue
106
+ if len(i) == 1:
107
+ line = str(next(iter(i.values())))
108
+ else:
109
+ line = json.dumps(i)
110
+ else:
111
+ line = json.dumps(i)
93
112
  text += line + "\n"
94
113
  elif isinstance(data, dict):
95
114
  text = json.dumps(data, indent=4)
@@ -97,7 +116,7 @@ def print_output(data: Any, args: dict, file=sys.stdout):
97
116
  text = data
98
117
  else:
99
118
  text = json.dumps(data, indent=4)
100
- elif output == "table":
119
+ elif output == "table" or output == "t":
101
120
  formatter = args["format"]
102
121
  text = formatter(data)
103
122
  else:
@@ -110,7 +129,7 @@ def print_output(data: Any, args: dict, file=sys.stdout):
110
129
 
111
130
 
112
131
  def print_error(error):
113
- critical_errors = [KeyError, TypeError]
132
+ critical_errors = [KeyError, TypeError, AttributeError, ValueError, IndentationError, ImportError]
114
133
  for ex in critical_errors:
115
134
  if isinstance(error, ex):
116
135
  traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr)
@@ -157,7 +176,27 @@ def _filter_fields(obj: Any, fields: str):
157
176
  return _filter_obj(obj)
158
177
 
159
178
 
179
+ def _exclude_fields(obj: Any, fields_exclude: str):
180
+ parts = fields_exclude.split(",")
181
+
182
+ def _filter_obj(o):
183
+ if not isinstance(o, dict):
184
+ return o
185
+ for k in list(o.keys()):
186
+ if k in parts:
187
+ del o[k]
188
+ return o
189
+
190
+ if isinstance(obj, list):
191
+ return list(map(_filter_obj, obj))
192
+ return _filter_obj(obj)
193
+
194
+
160
195
  def panic(reason: Any = None, code: int = 1):
196
+ if isinstance(reason, SystemExit):
197
+ os._exit(reason.code)
198
+ if isinstance(reason, click.exceptions.Exit):
199
+ os._exit(reason.exit_code)
161
200
  if isinstance(reason, Exception):
162
201
  text = error_details(reason)
163
202
  else:
@@ -184,7 +223,11 @@ def choose(prompt: str, items: list, fn_get_text: Callable = None, selected=None
184
223
  panic(prompt + " ERROR: no item available.")
185
224
 
186
225
  if fn_get_text is None:
187
- fn_get_text = lambda t: str(t)
226
+
227
+ def _default_fn_get_text(t):
228
+ return str(t)
229
+
230
+ fn_get_text = _default_fn_get_text
188
231
 
189
232
  if select_by_default and len(items) == 1:
190
233
  ret = items[0]
@@ -223,24 +266,37 @@ def input_array(prompt: str, default: list[str] = None):
223
266
  return ret
224
267
 
225
268
 
226
- def error_details(e):
227
- if isinstance(e, Exception):
228
- if isinstance(e, CtxpException):
229
- details = str(e)
230
- else:
231
- details = e.__class__.__name__
232
- msg = str(e)
233
- if msg:
234
- details += ": " + msg
235
- cause = e.__cause__
236
- if cause and cause != e:
237
- details += " | Caused by: " + error_details(cause)
269
+ def error_details(ex):
270
+ if not isinstance(ex, Exception):
271
+ return str(ex)
272
+
273
+ collector = []
238
274
 
275
+ def _collect_details(e):
276
+ if isinstance(e, click.ClickException):
277
+ collector.append(str(e))
278
+ return
279
+
280
+ details = e.__class__.__name__
281
+ msg = str(e)
282
+ if msg:
283
+ details += ": " + msg
239
284
  if isinstance(e, httpx.HTTPStatusError):
240
285
  details += "\n" + e.response.text
241
- return details
242
- else:
243
- return str(e)
286
+ collector.append(details)
287
+
288
+ cause = e.__cause__
289
+ if cause and cause != e:
290
+ _collect_details(cause)
291
+
292
+ _collect_details(ex)
293
+
294
+ # remove_consecutive_duplicates
295
+ result = [collector[0]]
296
+ for item in collector[1:]:
297
+ if item != result[-1]:
298
+ result.append(item)
299
+ return " | Caused by: ".join(result)
244
300
 
245
301
 
246
302
  def avoid_trace_for_ctrl_c():
@@ -309,7 +365,7 @@ def format_table(data: list, fields_mapping: dict, columns_to_sum: list = None):
309
365
  table = [[item.get(field) for field in headers] for item in flattened_data]
310
366
 
311
367
  if columns_to_sum:
312
- columns_to_sum_indices = {col: headers.index(col) for col in columns_to_sum}
368
+ columns_to_sum_indices = {col: headers.index(col) for col in columns_to_sum if col in headers}
313
369
  footer = [""] * len(headers)
314
370
  footer[0] = "Total"
315
371
  for col_name, col_index in columns_to_sum_indices.items():
@@ -319,10 +375,12 @@ def format_table(data: list, fields_mapping: dict, columns_to_sum: list = None):
319
375
  if isinstance(v, str):
320
376
  v = strip_ansi(v)
321
377
  v = int(v)
322
- elif isinstance(v, int):
378
+ elif isinstance(v, int) or isinstance(v, float):
323
379
  pass
380
+ elif v is None:
381
+ continue
324
382
  else:
325
- raise Exception(f"Unexpected cell value type. Type={type(v)}, value={v}")
383
+ raise Exception(f"Unexpected cell value type. Type={type(v)}, value={v}, col={col_name}")
326
384
  total += v
327
385
  footer[col_index] = total
328
386
  separator = ["-" * len(header) for header in headers]
@@ -331,3 +389,78 @@ def format_table(data: list, fields_mapping: dict, columns_to_sum: list = None):
331
389
  traceback.print_exc()
332
390
 
333
391
  return tabulate(table, headers=headers) + "\n"
392
+
393
+
394
+ def colorize(data: dict, name: str, mapping: dict):
395
+ if os.environ.get("TERM_COLOR") == "0":
396
+ return
397
+
398
+ s = data.get(name)
399
+ if not s:
400
+ return
401
+
402
+ if isinstance(mapping, dict):
403
+ c = mapping.get(s)
404
+ if c:
405
+ if isinstance(c, str):
406
+ data[name] = click.style(s, fg=c)
407
+ elif callable(c):
408
+ color = c(data)
409
+ data[name] = click.style(s, fg=color)
410
+ else:
411
+ raise Exception(f"Unexpected color type: {type(c)} {c}")
412
+ elif callable(mapping):
413
+ c = mapping(s)
414
+ if c:
415
+ data[name] = click.style(s, fg=c)
416
+ else:
417
+ raise Exception(f"Unexpected mapping type: {type(mapping)} {mapping}")
418
+
419
+
420
+ def default_table_formatter(data: Any, mapping: dict = None):
421
+ if not isinstance(data, list):
422
+ return data
423
+
424
+ def _restrict_readable_length(data: dict, name: str, length: int):
425
+ text = data.get(name)
426
+ if not text:
427
+ return
428
+ if len(text) > length:
429
+ data[name] = text[: length - 3] + "..."
430
+
431
+ field_mapping = {}
432
+ for d in data:
433
+ if "id" in d:
434
+ field_mapping["id"] = "Id"
435
+ if "name" in d:
436
+ field_mapping["name"] = "Name"
437
+ if "location" in d:
438
+ field_mapping["location"] = "Location"
439
+ if "type" in d:
440
+ field_mapping["type"] = "Type"
441
+ if "status" in d:
442
+ field_mapping["status"] = "Status"
443
+ if "createdAt" in d:
444
+ d["_createdStale"] = yumako.time.stale(d["createdAt"], datetime.timezone.utc)
445
+ field_mapping["_createdStale"] = "Created At"
446
+ if "updatedAt" in d:
447
+ d["_updatedStale"] = yumako.time.stale(d["updatedAt"], datetime.timezone.utc)
448
+ field_mapping["_updatedStale"] = "Updated At"
449
+
450
+ colorize(
451
+ d,
452
+ "status",
453
+ {
454
+ "READY": "green",
455
+ "SUCCESS": "green",
456
+ "ERROR": "red",
457
+ },
458
+ )
459
+ _restrict_readable_length(d, "name", 60)
460
+ if mapping:
461
+ for k, v in mapping.items():
462
+ if v is None:
463
+ field_mapping.pop(k, None)
464
+ else:
465
+ field_mapping[k] = v
466
+ return format_table(data, fields_mapping=field_mapping)
hcs_core/plan/__init__.py CHANGED
@@ -5,6 +5,7 @@ from .core import clear as clear
5
5
  from .core import destroy as destroy
6
6
  from .core import get_deployment_data as get_deployment_data
7
7
  from .core import graph as graph
8
+ from .core import resolve as resolve
8
9
  from .helper import PlanException as PlanException
9
10
  from .helper import PluginException as PluginException
10
11
  from .kop import attach_job_view as attach_job_view
hcs_core/plan/core.py CHANGED
@@ -1,5 +1,5 @@
1
1
  """
2
- Copyright 2023-2023 VMware Inc.
2
+ Copyright 2025-2025 Omnissa Inc.
3
3
  SPDX-License-Identifier: Apache-2.0
4
4
 
5
5
  Licensed under the Apache License, Version 2.0 (the "License");
@@ -41,19 +41,13 @@ def _prepare_data(data: dict, additional_context: dict, target_resource_name: st
41
41
  data.update(additional_context)
42
42
  blueprint, pending = process_template(data)
43
43
 
44
- if (
45
- target_resource_name
46
- and target_resource_name not in blueprint["resource"]
47
- and target_resource_name not in blueprint["runtime"]
48
- ):
44
+ if target_resource_name and target_resource_name not in blueprint["resource"] and target_resource_name not in blueprint["runtime"]:
49
45
  raise PlanException("Target resource or runtime not found: " + target_resource_name)
50
46
 
51
47
  for k, v in pending.items():
52
48
  if v.startswith("default.") or v.startswith("var."):
53
49
  if not k.find(".conditions."):
54
- raise PlanException(
55
- f"Invalid blueprint. Unresolved static references. Variable not found: {v}. Required by {k}"
56
- )
50
+ raise PlanException(f"Invalid blueprint. Unresolved static references. Variable not found: {v}. Required by {k}")
57
51
  deployment_id = blueprint["deploymentId"]
58
52
  state_file = deployment_id + ".state.yml"
59
53
  prev = data_util.load_data_file(state_file, default={})
@@ -93,6 +87,17 @@ def _prepare_data(data: dict, additional_context: dict, target_resource_name: st
93
87
  # return True
94
88
 
95
89
 
90
+ def resolve(data: dict, additional_context: dict = None, target_resource_name: str = None):
91
+ blueprint, state, state_file = _prepare_data(data, additional_context, None)
92
+ if target_resource_name:
93
+ if target_resource_name in blueprint["resource"]:
94
+ return blueprint["resource"][target_resource_name]["data"]
95
+ if target_resource_name in blueprint["runtime"]:
96
+ return blueprint["runtime"][target_resource_name]["data"]
97
+ raise PlanException("Target resource or runtime not found: " + target_resource_name)
98
+ return blueprint
99
+
100
+
96
101
  def apply(
97
102
  data: dict,
98
103
  additional_context: dict = None,
@@ -127,7 +132,7 @@ def apply(
127
132
  dag.process_blueprint(
128
133
  blueprint=blueprint,
129
134
  fn_process_node=process_resource_node,
130
- continue_on_error=False,
135
+ fail_fast=True,
131
136
  reverse=False,
132
137
  concurrency=concurrency,
133
138
  target_node_name=target_resource_name,
@@ -249,7 +254,7 @@ def _deploy_res(name, res, state):
249
254
  action = handler.decide(res_data, res_state)
250
255
 
251
256
  if action == actions.skip:
252
- kop.skip("Already deployed")
257
+ kop.skip(_get_res_text(handler, res_state))
253
258
  return
254
259
 
255
260
  if action == actions.recreate:
@@ -280,9 +285,7 @@ def _deploy_res(name, res, state):
280
285
  else:
281
286
  new_state = handler.update(res_data, res_state)
282
287
  else:
283
- raise PlanException(
284
- f"Unknown action. This is a problem of the concrete plugin.decide function. Plugin={name}, action={action}"
285
- )
288
+ raise PlanException(f"Unknown action. This is a problem of the concrete plugin.decide function. Plugin={name}, action={action}")
286
289
 
287
290
  if new_state:
288
291
  fn_set_state(new_state)
@@ -524,7 +527,7 @@ def _destroy_res(name, res_node, state, force):
524
527
 
525
528
  def destroy(
526
529
  data,
527
- force: bool,
530
+ fail_fast: bool,
528
531
  target_resource_name: str = None,
529
532
  include_dependencies: bool = True,
530
533
  concurrency: int = 4,
@@ -542,7 +545,7 @@ def destroy(
542
545
  if not node:
543
546
  return dag.walker.next
544
547
 
545
- _destroy_res(node_name, node, state, force)
548
+ _destroy_res(node_name, node, state, fail_fast)
546
549
  data_util.save_data_file(state, state_file)
547
550
  return dag.walker.next
548
551
 
@@ -550,7 +553,7 @@ def destroy(
550
553
  dag.process_blueprint(
551
554
  blueprint=blueprint,
552
555
  fn_process_node=destroy_resource,
553
- continue_on_error=force,
556
+ fail_fast=fail_fast,
554
557
  reverse=True,
555
558
  concurrency=concurrency,
556
559
  target_node_name=target_resource_name,
hcs_core/plan/dag.py CHANGED
@@ -15,7 +15,6 @@ limitations under the License.
15
15
 
16
16
  import threading
17
17
  import time
18
- import traceback
19
18
  from concurrent.futures import ThreadPoolExecutor
20
19
  from copy import deepcopy
21
20
  from graphlib import TopologicalSorter
@@ -72,7 +71,7 @@ class DAG:
72
71
  def process_blueprint(
73
72
  blueprint,
74
73
  fn_process_node: Callable,
75
- continue_on_error: bool = False,
74
+ fail_fast: bool = False,
76
75
  reverse: bool = False,
77
76
  concurrency: int = 3,
78
77
  target_node_name: str = None,
@@ -88,7 +87,7 @@ def process_blueprint(
88
87
  return walker.next
89
88
  return fn_process_node(name)
90
89
 
91
- return _walkthrough(dag, fn_process_node_impl, continue_on_error, concurrency)
90
+ return _walkthrough(dag, fn_process_node_impl, fail_fast, concurrency)
92
91
 
93
92
 
94
93
  def _filter_dag_by_target_node(dag: DAG, target_node_name, include_dependencies):
@@ -225,7 +224,7 @@ def _get_provider_id(node_data):
225
224
  return provider_type
226
225
 
227
226
 
228
- def _walkthrough(dag: DAG, fn_process_node: Callable, continue_on_error: bool, concurrency: int):
227
+ def _walkthrough(dag: DAG, fn_process_node: Callable, fail_fast: bool, concurrency: int):
229
228
  topological_sorter = TopologicalSorter(dag.graph)
230
229
  topological_sorter.prepare()
231
230
  lock = threading.Lock()
@@ -250,10 +249,10 @@ def _walkthrough(dag: DAG, fn_process_node: Callable, continue_on_error: bool, c
250
249
  except Exception as e:
251
250
  with lock:
252
251
  flags["err"] = e
253
- if continue_on_error:
254
- topological_sorter.done(node_id)
255
- else:
252
+ if fail_fast:
256
253
  flag_stop.set()
254
+ else:
255
+ topological_sorter.done(node_id)
257
256
  except SystemExit as e:
258
257
  with lock:
259
258
  flags["err"] = e
@@ -292,7 +291,7 @@ def _walkthrough(dag: DAG, fn_process_node: Callable, continue_on_error: bool, c
292
291
  if flag_stop.is_set():
293
292
  break
294
293
  with lock:
295
- if not continue_on_error and flags["err"]:
294
+ if fail_fast and flags["err"]:
296
295
  break
297
296
  read_nodes = topological_sorter.get_ready()
298
297
  if not len(read_nodes):
hcs_core/plan/kop.py CHANGED
@@ -1,5 +1,5 @@
1
1
  """
2
- Copyright 2023-2023 VMware Inc.
2
+ Copyright 2025-2025 Omnissa Inc.
3
3
  SPDX-License-Identifier: Apache-2.0
4
4
 
5
5
  Licensed under the Apache License, Version 2.0 (the "License");
@@ -85,7 +85,8 @@ class KOP:
85
85
  self._started = False
86
86
  self._closed = False
87
87
  job_id = kind + "/" + name
88
- _job_view.add(job_id, job_id)
88
+ if _job_view:
89
+ _job_view.add(job_id, job_id)
89
90
 
90
91
  def _job_id(self):
91
92
  return self._kind + "/" + self._name
@@ -103,7 +104,8 @@ class KOP:
103
104
 
104
105
  def id(self, res_id: str):
105
106
  self._id = res_id
106
- _job_view.update(self._job_id(), res_id)
107
+ if _job_view:
108
+ _job_view.update(self._job_id(), res_id)
107
109
 
108
110
  def start(self, mode: str = None, eta: str = None):
109
111
  if self._started:
@@ -114,7 +116,8 @@ class KOP:
114
116
  self._mode = mode
115
117
  self._started = True
116
118
  self._add_log(KopAction.start)
117
- _job_view.start(self._job_id(), eta)
119
+ if _job_view:
120
+ _job_view.start(self._job_id(), eta)
118
121
 
119
122
  def _success(self):
120
123
  if not self._started:
@@ -125,7 +128,8 @@ class KOP:
125
128
  raise KopException("Kop already closed. This is a framework logging issue.")
126
129
  self._add_log(KopAction.success)
127
130
  self._closed = True
128
- _job_view.success(self._job_id())
131
+ if _job_view:
132
+ _job_view.success(self._job_id())
129
133
 
130
134
  def error(self, err):
131
135
  if isinstance(err, Exception):
@@ -136,12 +140,14 @@ class KOP:
136
140
  details = str(err)
137
141
  self._add_log(KopAction.error, details)
138
142
  self._closed = True
139
- _job_view.error(self._job_id(), details)
143
+ if _job_view:
144
+ _job_view.error(self._job_id(), details)
140
145
 
141
146
  def skip(self, reason):
142
147
  self._add_log(KopAction.skip, reason)
143
148
  self._closed = True
144
- _job_view.skip(self._job_id(), reason)
149
+ if _job_view:
150
+ _job_view.skip(self._job_id(), reason)
145
151
 
146
152
  def _add_log(self, action: str, details: str = None):
147
153
  labels = {
@@ -174,6 +180,12 @@ class KOP:
174
180
 
175
181
  if _job_view == _DummyJobView:
176
182
  log.info(msg)
183
+ elif _job_view is None:
184
+ # explicitly set by user to disable log
185
+ pass
186
+ else:
187
+ # custom job view
188
+ pass
177
189
  entry = {"name": self._name, "time": int(time.time()), "action": action, "id": self._id}
178
190
  if details:
179
191
  entry["details"] = details