chalkpy 2.89.22__py3-none-any.whl → 2.95.3__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.
- chalk/__init__.py +2 -1
- chalk/_gen/chalk/arrow/v1/arrow_pb2.py +7 -5
- chalk/_gen/chalk/arrow/v1/arrow_pb2.pyi +6 -0
- chalk/_gen/chalk/artifacts/v1/chart_pb2.py +36 -33
- chalk/_gen/chalk/artifacts/v1/chart_pb2.pyi +41 -1
- chalk/_gen/chalk/artifacts/v1/cron_query_pb2.py +8 -7
- chalk/_gen/chalk/artifacts/v1/cron_query_pb2.pyi +5 -0
- chalk/_gen/chalk/common/v1/offline_query_pb2.py +19 -13
- chalk/_gen/chalk/common/v1/offline_query_pb2.pyi +37 -0
- chalk/_gen/chalk/common/v1/online_query_pb2.py +54 -54
- chalk/_gen/chalk/common/v1/online_query_pb2.pyi +13 -1
- chalk/_gen/chalk/common/v1/script_task_pb2.py +13 -11
- chalk/_gen/chalk/common/v1/script_task_pb2.pyi +19 -1
- chalk/_gen/chalk/dataframe/__init__.py +0 -0
- chalk/_gen/chalk/dataframe/v1/__init__.py +0 -0
- chalk/_gen/chalk/dataframe/v1/dataframe_pb2.py +48 -0
- chalk/_gen/chalk/dataframe/v1/dataframe_pb2.pyi +123 -0
- chalk/_gen/chalk/dataframe/v1/dataframe_pb2_grpc.py +4 -0
- chalk/_gen/chalk/dataframe/v1/dataframe_pb2_grpc.pyi +4 -0
- chalk/_gen/chalk/graph/v1/graph_pb2.py +150 -149
- chalk/_gen/chalk/graph/v1/graph_pb2.pyi +25 -0
- chalk/_gen/chalk/graph/v1/sources_pb2.py +94 -84
- chalk/_gen/chalk/graph/v1/sources_pb2.pyi +56 -0
- chalk/_gen/chalk/kubernetes/v1/horizontalpodautoscaler_pb2.py +79 -0
- chalk/_gen/chalk/kubernetes/v1/horizontalpodautoscaler_pb2.pyi +377 -0
- chalk/_gen/chalk/kubernetes/v1/horizontalpodautoscaler_pb2_grpc.py +4 -0
- chalk/_gen/chalk/kubernetes/v1/horizontalpodautoscaler_pb2_grpc.pyi +4 -0
- chalk/_gen/chalk/kubernetes/v1/scaledobject_pb2.py +43 -7
- chalk/_gen/chalk/kubernetes/v1/scaledobject_pb2.pyi +252 -2
- chalk/_gen/chalk/protosql/v1/sql_service_pb2.py +54 -27
- chalk/_gen/chalk/protosql/v1/sql_service_pb2.pyi +131 -3
- chalk/_gen/chalk/protosql/v1/sql_service_pb2_grpc.py +45 -0
- chalk/_gen/chalk/protosql/v1/sql_service_pb2_grpc.pyi +14 -0
- chalk/_gen/chalk/python/v1/types_pb2.py +14 -14
- chalk/_gen/chalk/python/v1/types_pb2.pyi +8 -0
- chalk/_gen/chalk/server/v1/benchmark_pb2.py +76 -0
- chalk/_gen/chalk/server/v1/benchmark_pb2.pyi +156 -0
- chalk/_gen/chalk/server/v1/benchmark_pb2_grpc.py +258 -0
- chalk/_gen/chalk/server/v1/benchmark_pb2_grpc.pyi +84 -0
- chalk/_gen/chalk/server/v1/billing_pb2.py +40 -38
- chalk/_gen/chalk/server/v1/billing_pb2.pyi +17 -1
- chalk/_gen/chalk/server/v1/branches_pb2.py +45 -0
- chalk/_gen/chalk/server/v1/branches_pb2.pyi +80 -0
- chalk/_gen/chalk/server/v1/branches_pb2_grpc.pyi +36 -0
- chalk/_gen/chalk/server/v1/builder_pb2.py +372 -272
- chalk/_gen/chalk/server/v1/builder_pb2.pyi +479 -12
- chalk/_gen/chalk/server/v1/builder_pb2_grpc.py +360 -0
- chalk/_gen/chalk/server/v1/builder_pb2_grpc.pyi +96 -0
- chalk/_gen/chalk/server/v1/chart_pb2.py +10 -10
- chalk/_gen/chalk/server/v1/chart_pb2.pyi +18 -2
- chalk/_gen/chalk/server/v1/clickhouse_pb2.py +42 -0
- chalk/_gen/chalk/server/v1/clickhouse_pb2.pyi +17 -0
- chalk/_gen/chalk/server/v1/clickhouse_pb2_grpc.py +78 -0
- chalk/_gen/chalk/server/v1/clickhouse_pb2_grpc.pyi +38 -0
- chalk/_gen/chalk/server/v1/cloud_components_pb2.py +153 -107
- chalk/_gen/chalk/server/v1/cloud_components_pb2.pyi +146 -4
- chalk/_gen/chalk/server/v1/cloud_components_pb2_grpc.py +180 -0
- chalk/_gen/chalk/server/v1/cloud_components_pb2_grpc.pyi +48 -0
- chalk/_gen/chalk/server/v1/cloud_credentials_pb2.py +11 -3
- chalk/_gen/chalk/server/v1/cloud_credentials_pb2.pyi +20 -0
- chalk/_gen/chalk/server/v1/cloud_credentials_pb2_grpc.py +45 -0
- chalk/_gen/chalk/server/v1/cloud_credentials_pb2_grpc.pyi +12 -0
- chalk/_gen/chalk/server/v1/dataplanejobqueue_pb2.py +59 -35
- chalk/_gen/chalk/server/v1/dataplanejobqueue_pb2.pyi +127 -1
- chalk/_gen/chalk/server/v1/dataplanejobqueue_pb2_grpc.py +135 -0
- chalk/_gen/chalk/server/v1/dataplanejobqueue_pb2_grpc.pyi +36 -0
- chalk/_gen/chalk/server/v1/dataplaneworkflows_pb2.py +90 -0
- chalk/_gen/chalk/server/v1/dataplaneworkflows_pb2.pyi +264 -0
- chalk/_gen/chalk/server/v1/dataplaneworkflows_pb2_grpc.py +170 -0
- chalk/_gen/chalk/server/v1/dataplaneworkflows_pb2_grpc.pyi +62 -0
- chalk/_gen/chalk/server/v1/datasets_pb2.py +36 -24
- chalk/_gen/chalk/server/v1/datasets_pb2.pyi +71 -2
- chalk/_gen/chalk/server/v1/datasets_pb2_grpc.py +45 -0
- chalk/_gen/chalk/server/v1/datasets_pb2_grpc.pyi +12 -0
- chalk/_gen/chalk/server/v1/deploy_pb2.py +9 -3
- chalk/_gen/chalk/server/v1/deploy_pb2.pyi +12 -0
- chalk/_gen/chalk/server/v1/deploy_pb2_grpc.py +45 -0
- chalk/_gen/chalk/server/v1/deploy_pb2_grpc.pyi +12 -0
- chalk/_gen/chalk/server/v1/deployment_pb2.py +20 -15
- chalk/_gen/chalk/server/v1/deployment_pb2.pyi +25 -0
- chalk/_gen/chalk/server/v1/environment_pb2.py +25 -15
- chalk/_gen/chalk/server/v1/environment_pb2.pyi +93 -1
- chalk/_gen/chalk/server/v1/eventbus_pb2.py +44 -0
- chalk/_gen/chalk/server/v1/eventbus_pb2.pyi +64 -0
- chalk/_gen/chalk/server/v1/eventbus_pb2_grpc.py +4 -0
- chalk/_gen/chalk/server/v1/eventbus_pb2_grpc.pyi +4 -0
- chalk/_gen/chalk/server/v1/files_pb2.py +65 -0
- chalk/_gen/chalk/server/v1/files_pb2.pyi +167 -0
- chalk/_gen/chalk/server/v1/files_pb2_grpc.py +4 -0
- chalk/_gen/chalk/server/v1/files_pb2_grpc.pyi +4 -0
- chalk/_gen/chalk/server/v1/graph_pb2.py +41 -3
- chalk/_gen/chalk/server/v1/graph_pb2.pyi +191 -0
- chalk/_gen/chalk/server/v1/graph_pb2_grpc.py +92 -0
- chalk/_gen/chalk/server/v1/graph_pb2_grpc.pyi +32 -0
- chalk/_gen/chalk/server/v1/incident_pb2.py +57 -0
- chalk/_gen/chalk/server/v1/incident_pb2.pyi +165 -0
- chalk/_gen/chalk/server/v1/incident_pb2_grpc.py +4 -0
- chalk/_gen/chalk/server/v1/incident_pb2_grpc.pyi +4 -0
- chalk/_gen/chalk/server/v1/indexing_job_pb2.py +44 -0
- chalk/_gen/chalk/server/v1/indexing_job_pb2.pyi +38 -0
- chalk/_gen/chalk/server/v1/indexing_job_pb2_grpc.py +78 -0
- chalk/_gen/chalk/server/v1/indexing_job_pb2_grpc.pyi +38 -0
- chalk/_gen/chalk/server/v1/integrations_pb2.py +11 -9
- chalk/_gen/chalk/server/v1/integrations_pb2.pyi +34 -2
- chalk/_gen/chalk/server/v1/kube_pb2.py +29 -19
- chalk/_gen/chalk/server/v1/kube_pb2.pyi +28 -0
- chalk/_gen/chalk/server/v1/kube_pb2_grpc.py +45 -0
- chalk/_gen/chalk/server/v1/kube_pb2_grpc.pyi +12 -0
- chalk/_gen/chalk/server/v1/log_pb2.py +21 -3
- chalk/_gen/chalk/server/v1/log_pb2.pyi +68 -0
- chalk/_gen/chalk/server/v1/log_pb2_grpc.py +90 -0
- chalk/_gen/chalk/server/v1/log_pb2_grpc.pyi +24 -0
- chalk/_gen/chalk/server/v1/metadataplanejobqueue_pb2.py +73 -0
- chalk/_gen/chalk/server/v1/metadataplanejobqueue_pb2.pyi +212 -0
- chalk/_gen/chalk/server/v1/metadataplanejobqueue_pb2_grpc.py +217 -0
- chalk/_gen/chalk/server/v1/metadataplanejobqueue_pb2_grpc.pyi +74 -0
- chalk/_gen/chalk/server/v1/model_registry_pb2.py +10 -10
- chalk/_gen/chalk/server/v1/model_registry_pb2.pyi +4 -1
- chalk/_gen/chalk/server/v1/monitoring_pb2.py +84 -75
- chalk/_gen/chalk/server/v1/monitoring_pb2.pyi +1 -0
- chalk/_gen/chalk/server/v1/monitoring_pb2_grpc.py +136 -0
- chalk/_gen/chalk/server/v1/monitoring_pb2_grpc.pyi +38 -0
- chalk/_gen/chalk/server/v1/offline_queries_pb2.py +32 -10
- chalk/_gen/chalk/server/v1/offline_queries_pb2.pyi +73 -0
- chalk/_gen/chalk/server/v1/offline_queries_pb2_grpc.py +90 -0
- chalk/_gen/chalk/server/v1/offline_queries_pb2_grpc.pyi +24 -0
- chalk/_gen/chalk/server/v1/plandebug_pb2.py +53 -0
- chalk/_gen/chalk/server/v1/plandebug_pb2.pyi +86 -0
- chalk/_gen/chalk/server/v1/plandebug_pb2_grpc.py +168 -0
- chalk/_gen/chalk/server/v1/plandebug_pb2_grpc.pyi +60 -0
- chalk/_gen/chalk/server/v1/queries_pb2.py +76 -48
- chalk/_gen/chalk/server/v1/queries_pb2.pyi +155 -2
- chalk/_gen/chalk/server/v1/queries_pb2_grpc.py +180 -0
- chalk/_gen/chalk/server/v1/queries_pb2_grpc.pyi +48 -0
- chalk/_gen/chalk/server/v1/scheduled_query_pb2.py +4 -2
- chalk/_gen/chalk/server/v1/scheduled_query_pb2_grpc.py +45 -0
- chalk/_gen/chalk/server/v1/scheduled_query_pb2_grpc.pyi +12 -0
- chalk/_gen/chalk/server/v1/scheduled_query_run_pb2.py +12 -6
- chalk/_gen/chalk/server/v1/scheduled_query_run_pb2.pyi +75 -2
- chalk/_gen/chalk/server/v1/scheduler_pb2.py +24 -12
- chalk/_gen/chalk/server/v1/scheduler_pb2.pyi +61 -1
- chalk/_gen/chalk/server/v1/scheduler_pb2_grpc.py +90 -0
- chalk/_gen/chalk/server/v1/scheduler_pb2_grpc.pyi +24 -0
- chalk/_gen/chalk/server/v1/script_tasks_pb2.py +26 -14
- chalk/_gen/chalk/server/v1/script_tasks_pb2.pyi +33 -3
- chalk/_gen/chalk/server/v1/script_tasks_pb2_grpc.py +90 -0
- chalk/_gen/chalk/server/v1/script_tasks_pb2_grpc.pyi +24 -0
- chalk/_gen/chalk/server/v1/sql_interface_pb2.py +75 -0
- chalk/_gen/chalk/server/v1/sql_interface_pb2.pyi +142 -0
- chalk/_gen/chalk/server/v1/sql_interface_pb2_grpc.py +349 -0
- chalk/_gen/chalk/server/v1/sql_interface_pb2_grpc.pyi +114 -0
- chalk/_gen/chalk/server/v1/sql_queries_pb2.py +48 -0
- chalk/_gen/chalk/server/v1/sql_queries_pb2.pyi +150 -0
- chalk/_gen/chalk/server/v1/sql_queries_pb2_grpc.py +123 -0
- chalk/_gen/chalk/server/v1/sql_queries_pb2_grpc.pyi +52 -0
- chalk/_gen/chalk/server/v1/team_pb2.py +156 -137
- chalk/_gen/chalk/server/v1/team_pb2.pyi +56 -10
- chalk/_gen/chalk/server/v1/team_pb2_grpc.py +90 -0
- chalk/_gen/chalk/server/v1/team_pb2_grpc.pyi +24 -0
- chalk/_gen/chalk/server/v1/topic_pb2.py +5 -3
- chalk/_gen/chalk/server/v1/topic_pb2.pyi +10 -1
- chalk/_gen/chalk/server/v1/trace_pb2.py +50 -28
- chalk/_gen/chalk/server/v1/trace_pb2.pyi +121 -0
- chalk/_gen/chalk/server/v1/trace_pb2_grpc.py +135 -0
- chalk/_gen/chalk/server/v1/trace_pb2_grpc.pyi +42 -0
- chalk/_gen/chalk/server/v1/webhook_pb2.py +9 -3
- chalk/_gen/chalk/server/v1/webhook_pb2.pyi +18 -0
- chalk/_gen/chalk/server/v1/webhook_pb2_grpc.py +45 -0
- chalk/_gen/chalk/server/v1/webhook_pb2_grpc.pyi +12 -0
- chalk/_gen/chalk/streaming/v1/debug_service_pb2.py +62 -0
- chalk/_gen/chalk/streaming/v1/debug_service_pb2.pyi +75 -0
- chalk/_gen/chalk/streaming/v1/debug_service_pb2_grpc.py +221 -0
- chalk/_gen/chalk/streaming/v1/debug_service_pb2_grpc.pyi +88 -0
- chalk/_gen/chalk/streaming/v1/simple_streaming_service_pb2.py +19 -7
- chalk/_gen/chalk/streaming/v1/simple_streaming_service_pb2.pyi +96 -3
- chalk/_gen/chalk/streaming/v1/simple_streaming_service_pb2_grpc.py +48 -0
- chalk/_gen/chalk/streaming/v1/simple_streaming_service_pb2_grpc.pyi +20 -0
- chalk/_gen/chalk/utils/v1/field_change_pb2.py +32 -0
- chalk/_gen/chalk/utils/v1/field_change_pb2.pyi +42 -0
- chalk/_gen/chalk/utils/v1/field_change_pb2_grpc.py +4 -0
- chalk/_gen/chalk/utils/v1/field_change_pb2_grpc.pyi +4 -0
- chalk/_lsp/error_builder.py +11 -0
- chalk/_monitoring/Chart.py +1 -3
- chalk/_version.py +1 -1
- chalk/cli.py +5 -10
- chalk/client/client.py +178 -64
- chalk/client/client_async.py +154 -0
- chalk/client/client_async_impl.py +22 -0
- chalk/client/client_grpc.py +738 -112
- chalk/client/client_impl.py +541 -136
- chalk/client/dataset.py +27 -6
- chalk/client/models.py +99 -2
- chalk/client/serialization/model_serialization.py +126 -10
- chalk/config/project_config.py +1 -1
- chalk/df/LazyFramePlaceholder.py +1154 -0
- chalk/df/ast_parser.py +2 -10
- chalk/features/_class_property.py +7 -0
- chalk/features/_embedding/embedding.py +1 -0
- chalk/features/_embedding/sentence_transformer.py +1 -1
- chalk/features/_encoding/converter.py +83 -2
- chalk/features/_encoding/pyarrow.py +20 -4
- chalk/features/_encoding/rich.py +1 -3
- chalk/features/_tensor.py +1 -2
- chalk/features/dataframe/_filters.py +14 -5
- chalk/features/dataframe/_impl.py +91 -36
- chalk/features/dataframe/_validation.py +11 -7
- chalk/features/feature_field.py +40 -30
- chalk/features/feature_set.py +1 -2
- chalk/features/feature_set_decorator.py +1 -0
- chalk/features/feature_wrapper.py +42 -3
- chalk/features/hooks.py +81 -12
- chalk/features/inference.py +65 -10
- chalk/features/resolver.py +338 -56
- chalk/features/tag.py +1 -3
- chalk/features/underscore_features.py +2 -1
- chalk/functions/__init__.py +456 -21
- chalk/functions/holidays.py +1 -3
- chalk/gitignore/gitignore_parser.py +5 -1
- chalk/importer.py +186 -74
- chalk/ml/__init__.py +6 -2
- chalk/ml/model_hooks.py +368 -51
- chalk/ml/model_reference.py +68 -10
- chalk/ml/model_version.py +34 -21
- chalk/ml/utils.py +143 -40
- chalk/operators/_utils.py +14 -3
- chalk/parsed/_proto/export.py +22 -0
- chalk/parsed/duplicate_input_gql.py +4 -0
- chalk/parsed/expressions.py +1 -3
- chalk/parsed/json_conversions.py +21 -14
- chalk/parsed/to_proto.py +16 -4
- chalk/parsed/user_types_to_json.py +31 -10
- chalk/parsed/validation_from_registries.py +182 -0
- chalk/queries/named_query.py +16 -6
- chalk/queries/scheduled_query.py +13 -1
- chalk/serialization/parsed_annotation.py +25 -12
- chalk/sql/__init__.py +221 -0
- chalk/sql/_internal/integrations/athena.py +6 -1
- chalk/sql/_internal/integrations/bigquery.py +22 -2
- chalk/sql/_internal/integrations/databricks.py +61 -18
- chalk/sql/_internal/integrations/mssql.py +281 -0
- chalk/sql/_internal/integrations/postgres.py +11 -3
- chalk/sql/_internal/integrations/redshift.py +4 -0
- chalk/sql/_internal/integrations/snowflake.py +11 -2
- chalk/sql/_internal/integrations/util.py +2 -1
- chalk/sql/_internal/sql_file_resolver.py +55 -10
- chalk/sql/_internal/sql_source.py +36 -2
- chalk/streams/__init__.py +1 -3
- chalk/streams/_kafka_source.py +5 -1
- chalk/streams/_windows.py +16 -4
- chalk/streams/types.py +1 -2
- chalk/utils/__init__.py +1 -3
- chalk/utils/_otel_version.py +13 -0
- chalk/utils/async_helpers.py +14 -5
- chalk/utils/df_utils.py +2 -2
- chalk/utils/duration.py +1 -3
- chalk/utils/job_log_display.py +538 -0
- chalk/utils/missing_dependency.py +5 -4
- chalk/utils/notebook.py +255 -2
- chalk/utils/pl_helpers.py +190 -37
- chalk/utils/pydanticutil/pydantic_compat.py +1 -2
- chalk/utils/storage_client.py +246 -0
- chalk/utils/threading.py +1 -3
- chalk/utils/tracing.py +194 -86
- {chalkpy-2.89.22.dist-info → chalkpy-2.95.3.dist-info}/METADATA +53 -21
- {chalkpy-2.89.22.dist-info → chalkpy-2.95.3.dist-info}/RECORD +268 -198
- {chalkpy-2.89.22.dist-info → chalkpy-2.95.3.dist-info}/WHEEL +0 -0
- {chalkpy-2.89.22.dist-info → chalkpy-2.95.3.dist-info}/entry_points.txt +0 -0
- {chalkpy-2.89.22.dist-info → chalkpy-2.95.3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
"""Job log display and state management for monitoring background jobs."""
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
from typing import TYPE_CHECKING, Callable, Optional
|
|
7
|
+
|
|
8
|
+
from google.protobuf import timestamp_pb2
|
|
9
|
+
from rich.columns import Columns
|
|
10
|
+
from rich.console import Console, Group
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.spinner import Spinner
|
|
13
|
+
from rich.style import Style
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
from chalk._gen.chalk.server.v1.dataplanejobqueue_pb2 import GetJobQueueOperationSummaryResponse, JobQueueState
|
|
18
|
+
from chalk._gen.chalk.server.v1.log_pb2 import SearchLogEntriesRequest
|
|
19
|
+
from chalk._reporting.rich.color import (
|
|
20
|
+
CITRUSY_YELLOW,
|
|
21
|
+
GRASSY_GREEN,
|
|
22
|
+
SERENDIPITOUS_PURPLE,
|
|
23
|
+
SHADOWY_LAVENDER,
|
|
24
|
+
SHY_RED,
|
|
25
|
+
UNDERLYING_CYAN,
|
|
26
|
+
)
|
|
27
|
+
from chalk.utils.collections import FrozenOrderedSet
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from chalk._gen.chalk.server.v1.log_pb2_grpc import LogSearchServiceStub
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class JobLogDisplay:
|
|
34
|
+
"""Manages the display and state tracking for job monitoring.
|
|
35
|
+
|
|
36
|
+
This class provides a generic interface for monitoring background jobs with
|
|
37
|
+
status updates and log streaming. It can be used for any job type that uses
|
|
38
|
+
the job queue system (training jobs, data processing jobs, etc.).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, max_logs_display: int = 10, title: str = "Jobs"):
|
|
42
|
+
"""Initialize the job log display.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
max_logs_display
|
|
47
|
+
Maximum number of recent logs to display
|
|
48
|
+
title
|
|
49
|
+
Title to display in the status table (e.g., "Model Training Jobs", "Processing Jobs")
|
|
50
|
+
"""
|
|
51
|
+
super().__init__()
|
|
52
|
+
self.job_states: dict[int, tuple[str, JobQueueState]] = {}
|
|
53
|
+
self.recent_logs: list[tuple[str, str]] = []
|
|
54
|
+
self.seen_log_content: set[tuple[str, str]] = set()
|
|
55
|
+
self.max_logs_display = max_logs_display
|
|
56
|
+
self.animation_frame = 0
|
|
57
|
+
self.start_time = time.time()
|
|
58
|
+
self.console = Console()
|
|
59
|
+
self.title = title
|
|
60
|
+
|
|
61
|
+
# Log following state
|
|
62
|
+
self.latest_timestamp: Optional[timestamp_pb2.Timestamp] = None
|
|
63
|
+
self.seen_log_ids: dict[str, bool] = {}
|
|
64
|
+
|
|
65
|
+
# Terminal states that indicate the job has finished
|
|
66
|
+
self.terminal_states = FrozenOrderedSet(
|
|
67
|
+
{
|
|
68
|
+
JobQueueState.JOB_QUEUE_STATE_COMPLETED,
|
|
69
|
+
JobQueueState.JOB_QUEUE_STATE_FAILED,
|
|
70
|
+
JobQueueState.JOB_QUEUE_STATE_CANCELED,
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def update_job_state(self, job_idx: int, state_name: str, state: JobQueueState) -> None:
|
|
75
|
+
"""Update the state of a specific job.
|
|
76
|
+
|
|
77
|
+
Parameters
|
|
78
|
+
----------
|
|
79
|
+
job_idx
|
|
80
|
+
Index of the job
|
|
81
|
+
state_name
|
|
82
|
+
Human-readable name of the state
|
|
83
|
+
state
|
|
84
|
+
The JobQueueState enum value
|
|
85
|
+
"""
|
|
86
|
+
self.job_states[job_idx] = (state_name, state)
|
|
87
|
+
|
|
88
|
+
def add_log(self, timestamp: str, message: str) -> None:
|
|
89
|
+
"""Add a log entry to the recent logs.
|
|
90
|
+
|
|
91
|
+
Parameters
|
|
92
|
+
----------
|
|
93
|
+
timestamp
|
|
94
|
+
Formatted timestamp string
|
|
95
|
+
message
|
|
96
|
+
Log message
|
|
97
|
+
"""
|
|
98
|
+
self.recent_logs.append((timestamp, message))
|
|
99
|
+
|
|
100
|
+
def is_all_terminal(self) -> bool:
|
|
101
|
+
"""Check if all jobs have reached a terminal state.
|
|
102
|
+
|
|
103
|
+
Returns
|
|
104
|
+
-------
|
|
105
|
+
bool
|
|
106
|
+
True if all jobs are in a terminal state
|
|
107
|
+
"""
|
|
108
|
+
if not self.job_states:
|
|
109
|
+
return False
|
|
110
|
+
return all(state in self.terminal_states for _, state in self.job_states.values())
|
|
111
|
+
|
|
112
|
+
def is_successful(self) -> bool:
|
|
113
|
+
"""Check if all jobs completed successfully.
|
|
114
|
+
|
|
115
|
+
Returns
|
|
116
|
+
-------
|
|
117
|
+
bool
|
|
118
|
+
True if all jobs completed successfully
|
|
119
|
+
"""
|
|
120
|
+
return all(state == JobQueueState.JOB_QUEUE_STATE_COMPLETED for _, state in self.job_states.values())
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def clean_log_message(message: str) -> str:
|
|
124
|
+
"""Remove job metadata from log message.
|
|
125
|
+
|
|
126
|
+
Parameters
|
|
127
|
+
----------
|
|
128
|
+
message
|
|
129
|
+
Raw log message
|
|
130
|
+
|
|
131
|
+
Returns
|
|
132
|
+
-------
|
|
133
|
+
str
|
|
134
|
+
Cleaned log message
|
|
135
|
+
"""
|
|
136
|
+
# Remove patterns like "job(id=1, ... attempt_idx=1)"
|
|
137
|
+
cleaned = re.sub(r"job\(id=\d+.*?attempt_idx=\d+\)\s*", "", message)
|
|
138
|
+
return cleaned.strip()
|
|
139
|
+
|
|
140
|
+
def get_status_renderable(self, state: JobQueueState, state_name: str):
|
|
141
|
+
"""Return the renderable (spinner or text) for a given job state.
|
|
142
|
+
|
|
143
|
+
Parameters
|
|
144
|
+
----------
|
|
145
|
+
state
|
|
146
|
+
The JobQueueState enum value
|
|
147
|
+
state_name
|
|
148
|
+
Human-readable name of the state
|
|
149
|
+
|
|
150
|
+
Returns
|
|
151
|
+
-------
|
|
152
|
+
Text or Columns
|
|
153
|
+
Rich renderable for the status
|
|
154
|
+
"""
|
|
155
|
+
display_name = state_name.replace("JOB_QUEUE_STATE_", "").replace("_", " ").title()
|
|
156
|
+
|
|
157
|
+
if state == JobQueueState.JOB_QUEUE_STATE_COMPLETED:
|
|
158
|
+
return Text(f"✓ {display_name}", style=Style(color=GRASSY_GREEN, bold=True))
|
|
159
|
+
elif state == JobQueueState.JOB_QUEUE_STATE_FAILED:
|
|
160
|
+
return Text(f"✗ {display_name}", style=Style(color=SHY_RED, bold=True))
|
|
161
|
+
elif state == JobQueueState.JOB_QUEUE_STATE_CANCELED:
|
|
162
|
+
return Text(f"⊗ {display_name}", style=Style(color=SHADOWY_LAVENDER))
|
|
163
|
+
elif "RUNNING" in state_name:
|
|
164
|
+
return Columns(
|
|
165
|
+
[
|
|
166
|
+
Spinner("dots", style=Style(color=UNDERLYING_CYAN)),
|
|
167
|
+
Text(f"{display_name}", style=Style(color=UNDERLYING_CYAN, bold=True)),
|
|
168
|
+
],
|
|
169
|
+
expand=False,
|
|
170
|
+
)
|
|
171
|
+
elif "PENDING" in state_name or "QUEUED" in state_name:
|
|
172
|
+
return Columns(
|
|
173
|
+
[
|
|
174
|
+
Spinner("dots2", style=Style(color=CITRUSY_YELLOW)),
|
|
175
|
+
Text(f"{display_name}", style=Style(color=CITRUSY_YELLOW)),
|
|
176
|
+
],
|
|
177
|
+
expand=False,
|
|
178
|
+
)
|
|
179
|
+
else:
|
|
180
|
+
return Text(f"◐ {display_name}", style=Style(color=SERENDIPITOUS_PURPLE))
|
|
181
|
+
|
|
182
|
+
def format_elapsed_time(self) -> str:
|
|
183
|
+
"""Format elapsed time since job started.
|
|
184
|
+
|
|
185
|
+
Returns
|
|
186
|
+
-------
|
|
187
|
+
str
|
|
188
|
+
Formatted time string (HH:MM:SS or MM:SS)
|
|
189
|
+
"""
|
|
190
|
+
elapsed = int(time.time() - self.start_time)
|
|
191
|
+
minutes, seconds = divmod(elapsed, 60)
|
|
192
|
+
hours, minutes = divmod(minutes, 60)
|
|
193
|
+
if hours > 0:
|
|
194
|
+
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
195
|
+
return f"{minutes:02d}:{seconds:02d}"
|
|
196
|
+
|
|
197
|
+
def create_status_table(self) -> Table:
|
|
198
|
+
"""Create a status table showing current job states.
|
|
199
|
+
|
|
200
|
+
Returns
|
|
201
|
+
-------
|
|
202
|
+
Table
|
|
203
|
+
Rich table with job statuses
|
|
204
|
+
"""
|
|
205
|
+
elapsed_str = self.format_elapsed_time()
|
|
206
|
+
title_text = Text(self.title, style=Style(color=UNDERLYING_CYAN, bold=True))
|
|
207
|
+
title_text.append(f" [{elapsed_str}]", style=Style(color=SHADOWY_LAVENDER, dim=True))
|
|
208
|
+
|
|
209
|
+
table = Table(
|
|
210
|
+
title=title_text,
|
|
211
|
+
title_justify="left",
|
|
212
|
+
box=None,
|
|
213
|
+
show_header=True,
|
|
214
|
+
header_style=Style(color=SHADOWY_LAVENDER, bold=True),
|
|
215
|
+
)
|
|
216
|
+
table.add_column("Job", style=Style(color=SHADOWY_LAVENDER))
|
|
217
|
+
table.add_column("Status")
|
|
218
|
+
|
|
219
|
+
if not self.job_states:
|
|
220
|
+
waiting_status = Columns(
|
|
221
|
+
[
|
|
222
|
+
Spinner("dots", style=Style(color=CITRUSY_YELLOW)),
|
|
223
|
+
Text("Waiting for jobs...", style=Style(color=CITRUSY_YELLOW, italic=True)),
|
|
224
|
+
],
|
|
225
|
+
expand=False,
|
|
226
|
+
)
|
|
227
|
+
table.add_row("", waiting_status)
|
|
228
|
+
else:
|
|
229
|
+
for job_idx in sorted(self.job_states.keys()):
|
|
230
|
+
state_name, state = self.job_states[job_idx]
|
|
231
|
+
status_renderable = self.get_status_renderable(state, state_name)
|
|
232
|
+
table.add_row(f"Job {job_idx}", status_renderable)
|
|
233
|
+
|
|
234
|
+
return table
|
|
235
|
+
|
|
236
|
+
def create_logs_panel(self) -> Panel:
|
|
237
|
+
"""Create a panel showing recent logs.
|
|
238
|
+
|
|
239
|
+
Returns
|
|
240
|
+
-------
|
|
241
|
+
Panel
|
|
242
|
+
Rich panel with recent log entries
|
|
243
|
+
"""
|
|
244
|
+
if not self.recent_logs:
|
|
245
|
+
num_dots = (self.animation_frame // 2) % 4
|
|
246
|
+
dots = "." * num_dots
|
|
247
|
+
log_content = Text(f"Waiting for logs{dots:<3}", style=Style(color=SHADOWY_LAVENDER, italic=True))
|
|
248
|
+
else:
|
|
249
|
+
log_lines: list[Text] = []
|
|
250
|
+
for timestamp, message in self.recent_logs[-self.max_logs_display :]:
|
|
251
|
+
line = Text()
|
|
252
|
+
line.append(timestamp, style=Style(color=SERENDIPITOUS_PURPLE, bold=True))
|
|
253
|
+
line.append(" ")
|
|
254
|
+
cleaned_message = self.clean_log_message(message)
|
|
255
|
+
line.append(cleaned_message, style=Style(color="white"))
|
|
256
|
+
log_lines.append(line)
|
|
257
|
+
log_content = Text("\n").join(log_lines)
|
|
258
|
+
|
|
259
|
+
return Panel(
|
|
260
|
+
log_content,
|
|
261
|
+
title="Recent Logs",
|
|
262
|
+
title_align="left",
|
|
263
|
+
border_style=Style(color=SERENDIPITOUS_PURPLE),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def create_display(self) -> Group:
|
|
267
|
+
"""Create the full display with status and logs.
|
|
268
|
+
|
|
269
|
+
Returns
|
|
270
|
+
-------
|
|
271
|
+
Group
|
|
272
|
+
Rich group containing status table and logs panel
|
|
273
|
+
"""
|
|
274
|
+
return Group(self.create_status_table(), Text(""), self.create_logs_panel())
|
|
275
|
+
|
|
276
|
+
def increment_animation(self) -> None:
|
|
277
|
+
"""Increment the animation frame counter."""
|
|
278
|
+
self.animation_frame += 1
|
|
279
|
+
|
|
280
|
+
def print_final_summary(self) -> None:
|
|
281
|
+
"""Print the final summary of the job."""
|
|
282
|
+
if self.is_successful():
|
|
283
|
+
self.console.print(Text("✓ Job completed successfully", style=Style(color=GRASSY_GREEN, bold=True)))
|
|
284
|
+
else:
|
|
285
|
+
self.console.print(Text("✗ Job failed or was canceled", style=Style(color=SHY_RED, bold=True)))
|
|
286
|
+
|
|
287
|
+
def poll_logs(
|
|
288
|
+
self,
|
|
289
|
+
log_stub: "LogSearchServiceStub",
|
|
290
|
+
query: str,
|
|
291
|
+
poll_interval: float,
|
|
292
|
+
should_stop_callback: Callable[[], bool],
|
|
293
|
+
output_callback: Optional[Callable[[str, str], None]] = None,
|
|
294
|
+
) -> None:
|
|
295
|
+
"""Poll for new logs and display them.
|
|
296
|
+
|
|
297
|
+
Parameters
|
|
298
|
+
----------
|
|
299
|
+
log_stub
|
|
300
|
+
The gRPC stub for log search service
|
|
301
|
+
query
|
|
302
|
+
The search query to filter logs
|
|
303
|
+
poll_interval
|
|
304
|
+
Time in seconds between polling for new logs
|
|
305
|
+
should_stop_callback
|
|
306
|
+
Callback that returns True when polling should stop
|
|
307
|
+
output_callback
|
|
308
|
+
Optional callback function that receives (timestamp, message) for each log entry.
|
|
309
|
+
If None, logs are added to the display.
|
|
310
|
+
"""
|
|
311
|
+
try:
|
|
312
|
+
while not should_stop_callback():
|
|
313
|
+
# Fetch logs starting from the latest timestamp we've seen
|
|
314
|
+
req = SearchLogEntriesRequest(query=query)
|
|
315
|
+
|
|
316
|
+
if self.latest_timestamp is not None:
|
|
317
|
+
req.start_time.CopyFrom(self.latest_timestamp)
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
resp = log_stub.SearchLogEntries(req)
|
|
321
|
+
except Exception as e:
|
|
322
|
+
if output_callback:
|
|
323
|
+
output_callback("", f"[LOG ERROR] {e}")
|
|
324
|
+
else:
|
|
325
|
+
self.add_log("", f"[LOG ERROR] {e}")
|
|
326
|
+
time.sleep(poll_interval)
|
|
327
|
+
continue
|
|
328
|
+
|
|
329
|
+
# Sort logs by timestamp (oldest first)
|
|
330
|
+
sorted_logs = sorted(
|
|
331
|
+
resp.log_entries, key=lambda log: log.timestamp.seconds + log.timestamp.nanos / 1e9
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# Display new logs and track the latest timestamp
|
|
335
|
+
max_timestamp = self.latest_timestamp
|
|
336
|
+
for log in sorted_logs:
|
|
337
|
+
if log.id not in self.seen_log_ids:
|
|
338
|
+
formatted_time = self._format_timestamp(log.timestamp)
|
|
339
|
+
formatted_message = log.message.replace("\n", " ")
|
|
340
|
+
|
|
341
|
+
if output_callback:
|
|
342
|
+
output_callback(formatted_time, formatted_message)
|
|
343
|
+
else:
|
|
344
|
+
self.add_log(formatted_time, formatted_message)
|
|
345
|
+
|
|
346
|
+
self.seen_log_ids[log.id] = True
|
|
347
|
+
|
|
348
|
+
# Track the maximum timestamp we've seen
|
|
349
|
+
if max_timestamp is None or not self._is_timestamp_after(max_timestamp, log.timestamp):
|
|
350
|
+
max_timestamp = log.timestamp
|
|
351
|
+
|
|
352
|
+
# Update latest_timestamp after processing all logs in this batch
|
|
353
|
+
if max_timestamp is not None and (
|
|
354
|
+
self.latest_timestamp is None
|
|
355
|
+
or max_timestamp.seconds > self.latest_timestamp.seconds
|
|
356
|
+
or (
|
|
357
|
+
max_timestamp.seconds == self.latest_timestamp.seconds
|
|
358
|
+
and max_timestamp.nanos > self.latest_timestamp.nanos
|
|
359
|
+
)
|
|
360
|
+
):
|
|
361
|
+
# Advance by 1 full second since server filters at second-level precision (RFC3339)
|
|
362
|
+
# Using nanosecond precision would cause the same logs to be re-fetched
|
|
363
|
+
self.latest_timestamp = timestamp_pb2.Timestamp(seconds=max_timestamp.seconds + 1, nanos=0)
|
|
364
|
+
|
|
365
|
+
# Wait before next poll
|
|
366
|
+
time.sleep(poll_interval)
|
|
367
|
+
except KeyboardInterrupt:
|
|
368
|
+
pass
|
|
369
|
+
|
|
370
|
+
@staticmethod
|
|
371
|
+
def _format_timestamp(timestamp: timestamp_pb2.Timestamp) -> str:
|
|
372
|
+
"""Format a protobuf timestamp for display.
|
|
373
|
+
|
|
374
|
+
Parameters
|
|
375
|
+
----------
|
|
376
|
+
timestamp
|
|
377
|
+
The protobuf timestamp to format
|
|
378
|
+
|
|
379
|
+
Returns
|
|
380
|
+
-------
|
|
381
|
+
str
|
|
382
|
+
Formatted timestamp string
|
|
383
|
+
"""
|
|
384
|
+
dt_obj = dt.datetime.fromtimestamp(timestamp.seconds + timestamp.nanos / 1e9, tz=dt.timezone.utc)
|
|
385
|
+
return dt_obj.strftime("%Y-%m-%d %H:%M:%S")
|
|
386
|
+
|
|
387
|
+
@staticmethod
|
|
388
|
+
def _is_timestamp_after(ts1: timestamp_pb2.Timestamp, ts2: Optional[timestamp_pb2.Timestamp]) -> bool:
|
|
389
|
+
"""Check if ts1 is after ts2.
|
|
390
|
+
|
|
391
|
+
Parameters
|
|
392
|
+
----------
|
|
393
|
+
ts1
|
|
394
|
+
First timestamp
|
|
395
|
+
ts2
|
|
396
|
+
Second timestamp
|
|
397
|
+
|
|
398
|
+
Returns
|
|
399
|
+
-------
|
|
400
|
+
bool
|
|
401
|
+
True if ts1 is after ts2
|
|
402
|
+
"""
|
|
403
|
+
if ts2 is None:
|
|
404
|
+
return True
|
|
405
|
+
if ts1.seconds > ts2.seconds:
|
|
406
|
+
return True
|
|
407
|
+
if ts1.seconds == ts2.seconds and ts1.nanos > ts2.nanos:
|
|
408
|
+
return True
|
|
409
|
+
return False
|
|
410
|
+
|
|
411
|
+
def poll_job_status(
|
|
412
|
+
self,
|
|
413
|
+
get_status_callback: Callable[[], "GetJobQueueOperationSummaryResponse"],
|
|
414
|
+
poll_interval: float,
|
|
415
|
+
should_stop_callback: Callable[[], bool],
|
|
416
|
+
) -> None:
|
|
417
|
+
"""Poll for job status updates.
|
|
418
|
+
|
|
419
|
+
Parameters
|
|
420
|
+
----------
|
|
421
|
+
get_status_callback
|
|
422
|
+
Callback function that returns the job queue operation summary
|
|
423
|
+
poll_interval
|
|
424
|
+
Time in seconds between polling for status
|
|
425
|
+
should_stop_callback
|
|
426
|
+
Callback that returns True when polling should stop
|
|
427
|
+
"""
|
|
428
|
+
try:
|
|
429
|
+
while not should_stop_callback():
|
|
430
|
+
try:
|
|
431
|
+
response = get_status_callback()
|
|
432
|
+
|
|
433
|
+
if response.HasField("summary"):
|
|
434
|
+
# Update job states
|
|
435
|
+
for row_summary in response.summary.indexed_row_summaries.values():
|
|
436
|
+
job_idx = row_summary.job_idx if row_summary.HasField("job_idx") else 0
|
|
437
|
+
state_name = JobQueueState.Name(row_summary.state)
|
|
438
|
+
self.update_job_state(job_idx, state_name, row_summary.state)
|
|
439
|
+
|
|
440
|
+
# Stop when all jobs reach terminal state
|
|
441
|
+
if self.is_all_terminal():
|
|
442
|
+
return
|
|
443
|
+
|
|
444
|
+
except Exception as e:
|
|
445
|
+
# Add error to logs
|
|
446
|
+
self.add_log("", f"[STATUS ERROR] {e}")
|
|
447
|
+
|
|
448
|
+
# Wait before next poll
|
|
449
|
+
time.sleep(poll_interval)
|
|
450
|
+
except KeyboardInterrupt:
|
|
451
|
+
pass
|
|
452
|
+
|
|
453
|
+
def follow_job(
|
|
454
|
+
self,
|
|
455
|
+
get_status_callback: Callable[[], "GetJobQueueOperationSummaryResponse"],
|
|
456
|
+
log_stub: "LogSearchServiceStub",
|
|
457
|
+
log_query: str,
|
|
458
|
+
poll_interval: float = 2.0,
|
|
459
|
+
output_callback: Optional[Callable[[str, str], None]] = None,
|
|
460
|
+
) -> None:
|
|
461
|
+
"""Follow a job, displaying both status and logs.
|
|
462
|
+
|
|
463
|
+
This method handles all the threading coordination and display logic
|
|
464
|
+
for following a job in real-time.
|
|
465
|
+
|
|
466
|
+
Parameters
|
|
467
|
+
----------
|
|
468
|
+
get_status_callback
|
|
469
|
+
Callback function that returns the job queue operation summary
|
|
470
|
+
log_stub
|
|
471
|
+
The gRPC stub for log search service
|
|
472
|
+
log_query
|
|
473
|
+
The search query to filter logs
|
|
474
|
+
poll_interval
|
|
475
|
+
Time in seconds between polling for status and logs. Defaults to 2.0 seconds.
|
|
476
|
+
output_callback
|
|
477
|
+
Optional callback function that receives (timestamp, message) for each log entry.
|
|
478
|
+
If None, logs are displayed using Rich live display.
|
|
479
|
+
"""
|
|
480
|
+
import threading
|
|
481
|
+
|
|
482
|
+
from rich.live import Live
|
|
483
|
+
|
|
484
|
+
# Flag to coordinate between threads
|
|
485
|
+
should_stop = threading.Event()
|
|
486
|
+
|
|
487
|
+
def status_done_callback():
|
|
488
|
+
should_stop.set()
|
|
489
|
+
|
|
490
|
+
def poll_status():
|
|
491
|
+
try:
|
|
492
|
+
self.poll_job_status(
|
|
493
|
+
get_status_callback=get_status_callback,
|
|
494
|
+
poll_interval=poll_interval,
|
|
495
|
+
should_stop_callback=should_stop.is_set,
|
|
496
|
+
)
|
|
497
|
+
status_done_callback()
|
|
498
|
+
except KeyboardInterrupt:
|
|
499
|
+
should_stop.set()
|
|
500
|
+
|
|
501
|
+
def poll_logs_thread():
|
|
502
|
+
self.poll_logs(
|
|
503
|
+
log_stub=log_stub,
|
|
504
|
+
query=log_query,
|
|
505
|
+
poll_interval=poll_interval,
|
|
506
|
+
should_stop_callback=should_stop.is_set,
|
|
507
|
+
output_callback=output_callback,
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
# Start both polling threads
|
|
511
|
+
status_thread = threading.Thread(target=poll_status, daemon=True)
|
|
512
|
+
log_thread = threading.Thread(target=poll_logs_thread, daemon=True)
|
|
513
|
+
|
|
514
|
+
status_thread.start()
|
|
515
|
+
log_thread.start()
|
|
516
|
+
|
|
517
|
+
# Use Live display if no callback is provided
|
|
518
|
+
if output_callback is None:
|
|
519
|
+
with Live(self.create_display(), console=self.console, refresh_per_second=4) as live:
|
|
520
|
+
try:
|
|
521
|
+
while not should_stop.is_set():
|
|
522
|
+
live.update(self.create_display())
|
|
523
|
+
self.increment_animation()
|
|
524
|
+
time.sleep(0.25)
|
|
525
|
+
except KeyboardInterrupt:
|
|
526
|
+
should_stop.set()
|
|
527
|
+
|
|
528
|
+
# Print final summary
|
|
529
|
+
self.print_final_summary()
|
|
530
|
+
else:
|
|
531
|
+
# When using callback, just wait for completion
|
|
532
|
+
try:
|
|
533
|
+
status_thread.join()
|
|
534
|
+
log_thread.join()
|
|
535
|
+
except KeyboardInterrupt:
|
|
536
|
+
should_stop.set()
|
|
537
|
+
status_thread.join(timeout=1)
|
|
538
|
+
log_thread.join(timeout=1)
|
|
@@ -5,7 +5,8 @@ class MissingDependencyException(ImportError):
|
|
|
5
5
|
...
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
def missing_dependency_exception(name: str):
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
def missing_dependency_exception(name: str, original_error: Exception | None = None):
|
|
9
|
+
msg = f"Missing pip dependency '{name}' for chalkpy=={chalk.__version__}. Please add this to your requirements.txt file and pip install."
|
|
10
|
+
if original_error:
|
|
11
|
+
msg += f"\n\n{original_error}"
|
|
12
|
+
return MissingDependencyException(msg)
|