From f5e10c4a5f1dcdcebd96290e0fead83caccadacb Mon Sep 17 00:00:00 2001 From: Pere Miquel Brull Date: Mon, 9 Oct 2023 07:05:05 +0200 Subject: [PATCH] Fix #7272 - BaseWorkflow docs and cleanup (#13471) * DQ BaseWorkflow * Test suite runner * test Suite workflow * Refactor DQ for BaseWorkflow * Lint * Fix source * Fix source * Fix source * Fix source * Fix test * Prepare docs * Clean sink * Clean legacy classes * typo * ProcessorStatus --- ingestion/src/metadata/cli/insight.py | 4 +- .../processor/reports/data_processor.py | 3 +- .../web_analytic_report_data_processor.py | 3 +- .../src/metadata/ingestion/api/processor.py | 86 --------- ingestion/src/metadata/ingestion/api/sink.py | 65 ------- ingestion/src/metadata/ingestion/api/stage.py | 52 ------ .../profiler/interface/profiler_interface.py | 23 ++- ingestion/src/metadata/utils/importer.py | 4 +- ingestion/src/metadata/workflow/README.md | 135 +++++++++++++++ .../workflow/workflow_output_handler.py | 163 +----------------- .../tests/cli_e2e/base/test_cli_dashboard.py | 7 +- ingestion/tests/cli_e2e/base/test_cli_db.py | 19 +- ingestion/tests/cli_e2e/base/test_cli_dbt.py | 5 +- ingestion/tests/cli_e2e/test_cli_snowflake.py | 3 +- .../ingestion/base-workflow.steps.drawio.png | Bin 0 -> 5074 bytes .../base-workflow.workflow.drawio.png | Bin 0 -> 26288 bytes 16 files changed, 177 insertions(+), 395 deletions(-) delete mode 100644 ingestion/src/metadata/ingestion/api/processor.py delete mode 100644 ingestion/src/metadata/ingestion/api/sink.py delete mode 100644 ingestion/src/metadata/ingestion/api/stage.py create mode 100644 ingestion/src/metadata/workflow/README.md create mode 100644 openmetadata-docs/images/readme/ingestion/base-workflow.steps.drawio.png create mode 100644 openmetadata-docs/images/readme/ingestion/base-workflow.workflow.drawio.png diff --git a/ingestion/src/metadata/cli/insight.py b/ingestion/src/metadata/cli/insight.py index ff87bd045a4..2ed508d594d 100644 --- a/ingestion/src/metadata/cli/insight.py +++ b/ingestion/src/metadata/cli/insight.py @@ -21,8 +21,8 @@ from metadata.utils.logger import cli_logger from metadata.workflow.data_insight import DataInsightWorkflow from metadata.workflow.workflow_output_handler import ( WorkflowType, - print_data_insight_status, print_init_error, + print_status, ) logger = cli_logger() @@ -48,5 +48,5 @@ def run_insight(config_path: str) -> None: workflow.execute() workflow.stop() - print_data_insight_status(workflow) + print_status(workflow) workflow.raise_from_status() diff --git a/ingestion/src/metadata/data_insight/processor/reports/data_processor.py b/ingestion/src/metadata/data_insight/processor/reports/data_processor.py index 8dbd29a6da9..eb869844bf4 100644 --- a/ingestion/src/metadata/data_insight/processor/reports/data_processor.py +++ b/ingestion/src/metadata/data_insight/processor/reports/data_processor.py @@ -20,7 +20,6 @@ from datetime import datetime, timezone from typing import Callable, Iterable, Optional from metadata.generated.schema.analytics.reportData import ReportData -from metadata.ingestion.api.processor import ProcessorStatus from metadata.ingestion.api.status import Status from metadata.ingestion.ometa.ometa_api import OpenMetadata @@ -68,5 +67,5 @@ class DataProcessor(abc.ABC): raise NotImplementedError @abc.abstractmethod - def get_status(self) -> ProcessorStatus: + def get_status(self) -> Status: raise NotImplementedError diff --git a/ingestion/src/metadata/data_insight/processor/reports/web_analytic_report_data_processor.py b/ingestion/src/metadata/data_insight/processor/reports/web_analytic_report_data_processor.py index 7e8d49cbac2..12c288a3e2c 100644 --- a/ingestion/src/metadata/data_insight/processor/reports/web_analytic_report_data_processor.py +++ b/ingestion/src/metadata/data_insight/processor/reports/web_analytic_report_data_processor.py @@ -41,7 +41,6 @@ from metadata.generated.schema.entity.data import ( topic, ) from metadata.generated.schema.entity.teams.user import User -from metadata.ingestion.api.processor import ProcessorStatus from metadata.ingestion.api.status import Status from metadata.ingestion.ometa.ometa_api import OpenMetadata from metadata.utils.helpers import get_entity_tier_from_tags @@ -370,5 +369,5 @@ class WebAnalyticUserActivityReportDataProcessor(DataProcessor): """Refine data""" self._refined_data = self.refine_user_event.send(entity) - def get_status(self) -> ProcessorStatus: + def get_status(self) -> Status: return self.processor_status diff --git a/ingestion/src/metadata/ingestion/api/processor.py b/ingestion/src/metadata/ingestion/api/processor.py deleted file mode 100644 index e1703507abf..00000000000 --- a/ingestion/src/metadata/ingestion/api/processor.py +++ /dev/null @@ -1,86 +0,0 @@ -# Copyright 2021 Collate -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -""" -Abstract Processor definition to build a Workflow -""" -from abc import ABCMeta, abstractmethod -from dataclasses import dataclass -from typing import Any, Generic, List, Optional - -from pydantic import Field - -from metadata.generated.schema.entity.services.connections.metadata.openMetadataConnection import ( - OpenMetadataConnection, -) -from metadata.ingestion.api.closeable import Closeable -from metadata.ingestion.api.common import Entity -from metadata.ingestion.api.models import StackTraceError -from metadata.ingestion.api.status import Status -from metadata.utils.logger import ingestion_logger - -logger = ingestion_logger() - - -class ProcessorStatus(Status): - records: List[str] = Field(default_factory=list) - - def processed(self, record: Any): - self.records.append(record) - - # disabling pylint until we remove this - def warning(self, info: Any) -> None: # pylint: disable=W0221 - self.warnings.append(info) - - -class ProfilerProcessorStatus(Status): - entity: Optional[str] = None - - def scanned(self, record: Any) -> None: - self.records.append(record) - - def failed_profiler(self, error: str, stack_trace: Optional[str] = None) -> None: - self.failed( - StackTraceError( - name=self.entity if self.entity else "", - error=error, - stack_trace=stack_trace, - ) - ) - - -@dataclass -class Processor(Closeable, Generic[Entity], metaclass=ABCMeta): - """ - Processor class - """ - - status: ProcessorStatus - - def __init__(self): - self.status = ProcessorStatus() - - @classmethod - @abstractmethod - def create( - cls, config_dict: dict, metadata_config: OpenMetadataConnection, **kwargs - ) -> "Processor": - pass - - @abstractmethod - def process(self, *args, **kwargs) -> Entity: - pass - - def get_status(self) -> ProcessorStatus: - return self.status - - @abstractmethod - def close(self) -> None: - pass diff --git a/ingestion/src/metadata/ingestion/api/sink.py b/ingestion/src/metadata/ingestion/api/sink.py deleted file mode 100644 index 6fe0cbcd9e2..00000000000 --- a/ingestion/src/metadata/ingestion/api/sink.py +++ /dev/null @@ -1,65 +0,0 @@ -# Copyright 2021 Collate -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -""" -Abstract Sink definition to build a Workflow -""" -from abc import ABCMeta, abstractmethod -from dataclasses import dataclass -from typing import Any, Generic, List - -from pydantic import Field - -from metadata.generated.schema.entity.services.connections.metadata.openMetadataConnection import ( - OpenMetadataConnection, -) -from metadata.ingestion.api.closeable import Closeable -from metadata.ingestion.api.common import Entity -from metadata.ingestion.api.status import Status - - -class SinkStatus(Status): - records: List[str] = Field(default_factory=list) - - def records_written(self, record: str) -> None: - self.records.append(record) - - # Disable pylint until this is removed - def warning(self, info: Any) -> None: # pylint: disable=W0221 - self.warnings.append(info) - - -@dataclass # type: ignore[misc] -class Sink(Closeable, Generic[Entity], metaclass=ABCMeta): - """All Sinks must inherit this base class.""" - - status: SinkStatus - - def __init__(self): - self.status = SinkStatus() - - @classmethod - @abstractmethod - def create( - cls, config_dict: dict, metadata_config: OpenMetadataConnection - ) -> "Sink": - pass - - @abstractmethod - def write_record(self, record: Entity) -> None: - # must call callback when done. - pass - - def get_status(self) -> SinkStatus: - return self.status - - @abstractmethod - def close(self) -> None: - pass diff --git a/ingestion/src/metadata/ingestion/api/stage.py b/ingestion/src/metadata/ingestion/api/stage.py deleted file mode 100644 index 65234b3a031..00000000000 --- a/ingestion/src/metadata/ingestion/api/stage.py +++ /dev/null @@ -1,52 +0,0 @@ -# Copyright 2021 Collate -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -""" -Abstract Stage definition to build a Workflow -""" -from abc import ABCMeta, abstractmethod -from dataclasses import dataclass -from typing import Generic - -from metadata.ingestion.api.closeable import Closeable -from metadata.ingestion.api.common import Entity -from metadata.ingestion.api.status import Status - - -class StageStatus(Status): - pass - - -@dataclass # type: ignore[misc] -class Stage(Closeable, Generic[Entity], metaclass=ABCMeta): - """ - Stage class - """ - - status: StageStatus - - def __init__(self): - self.status = StageStatus() - - @classmethod - @abstractmethod - def create(cls, config_dict: dict, metadata_config: dict) -> "Stage": - pass - - @abstractmethod - def stage_record(self, record: Entity): - pass - - def get_status(self) -> StageStatus: - return self.status - - @abstractmethod - def close(self) -> None: - pass diff --git a/ingestion/src/metadata/profiler/interface/profiler_interface.py b/ingestion/src/metadata/profiler/interface/profiler_interface.py index b55e8c45b8c..b7c1755c98e 100644 --- a/ingestion/src/metadata/profiler/interface/profiler_interface.py +++ b/ingestion/src/metadata/profiler/interface/profiler_interface.py @@ -15,7 +15,7 @@ supporting sqlalchemy abstraction layer """ from abc import ABC, abstractmethod -from typing import Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Union from sqlalchemy import Column from typing_extensions import Self @@ -32,7 +32,8 @@ from metadata.generated.schema.entity.services.databaseService import DatabaseCo from metadata.generated.schema.metadataIngestion.databaseServiceProfilerPipeline import ( DatabaseServiceProfilerPipeline, ) -from metadata.ingestion.api.processor import ProfilerProcessorStatus +from metadata.ingestion.api.models import StackTraceError +from metadata.ingestion.api.status import Status from metadata.ingestion.ometa.ometa_api import OpenMetadata from metadata.ingestion.source.connections import get_connection from metadata.profiler.api.models import ProfileSampleConfig, TableConfig @@ -42,6 +43,24 @@ from metadata.profiler.processor.runner import QueryRunner from metadata.utils.partition import get_partition_details +class ProfilerProcessorStatus(Status): + """Keep track of the entity being processed""" + + entity: Optional[str] = None + + def scanned(self, record: Any) -> None: + self.records.append(record) + + def failed_profiler(self, error: str, stack_trace: Optional[str] = None) -> None: + self.failed( + StackTraceError( + name=self.entity if self.entity else "", + error=error, + stack_trace=stack_trace, + ) + ) + + class ProfilerInterface(ABC): """Protocol interface for the profiler processor""" diff --git a/ingestion/src/metadata/utils/importer.py b/ingestion/src/metadata/utils/importer.py index 008c303404f..da3c5ecbf16 100644 --- a/ingestion/src/metadata/utils/importer.py +++ b/ingestion/src/metadata/utils/importer.py @@ -23,10 +23,8 @@ from metadata.generated.schema.entity.services.connections.metadata.openMetadata ) from metadata.generated.schema.entity.services.serviceType import ServiceType from metadata.generated.schema.metadataIngestion.workflow import Sink as WorkflowSink -from metadata.ingestion.api.processor import Processor -from metadata.ingestion.api.stage import Stage from metadata.ingestion.api.step import Step -from metadata.ingestion.api.steps import BulkSink, Sink, Source +from metadata.ingestion.api.steps import BulkSink, Processor, Sink, Source, Stage from metadata.utils.class_helper import get_service_type_from_source_type from metadata.utils.logger import utils_logger diff --git a/ingestion/src/metadata/workflow/README.md b/ingestion/src/metadata/workflow/README.md new file mode 100644 index 00000000000..24feccab97b --- /dev/null +++ b/ingestion/src/metadata/workflow/README.md @@ -0,0 +1,135 @@ +# Base Workflow + +The goal of the `BaseWorkflow` is to define a unique class that that controls the logic flow of executions. This means: +- Having a consensus on how our executions are organized (steps) +- Centralizing in a single place all the exception management. We don't want individual - and wrongly uncaught - exceptions + to blow up the full executions. +- Centralizing the `Status` handling: how do we report processed assets & failures and send them back to the `IngestionPipeline`. + +## Steps + +Each `Workflow` can be built by using `Steps` as lego pieces. Each of these pieces - steps - are a generic abstraction +on which operations we can expect to happen inside. Currently, the `BaseWorkflow` accepts any number of sequential `Steps`, +each of them taking care of a specific part of the business logic. + +![base-workflow.steps.drawio.png](../../../../openmetadata-docs/images/readme/ingestion/base-workflow.steps.drawio.png) + +We mainly have four types of steps, iterative steps and return steps: + +1. `IterStep`s are in charge of starting the workflow. They will read data from the external world and `yield` the elements + that need to be processed down the pipeline. +2. `ReturnStep`s accept one input, which they will further process and return one output. +3. `StageStep`s accept one input, and they will write - stage - them somewhere. They are expected to be used together with the `BulkStep`. +4. `BulkStep`s iterate over an input - produced by the `StageStep` - and will return nothing. + +These names might be explanatory, but harder to imagine/read. Therefore, we have specific classes based on these steps +that help us discuss better on the Workflow structure: + +1. `IterStep` -> `Source` +2. `ReturnStep` -> `Processor` & `Sink` +3. `StageStep` -> `Step` +4. `BulkStep` -> `BulkSink` + +When developing each of this steps, we'll just need to implement their execution method (either `_iter` or `_run`), where in +the `IterStep` the method is expected to `yield` results, and the rest to `return`. + +We'll explain specific examples of these `Step`s in the next section. + +## Workflows + +Now that we have our pieces, we can define the `Workflow` structures. While the `Steps` could be joined together +somewhat arbitrarily, there are specific recipes that we follow depending on our goals. + +Each `Workflow` then can be build by defining its steps (starting with a `Source`, adding `Processor`s, etc.) and +registering the steps in the `BaseWorkflow.set_steps` method. + +The `BaseWorkflow` will take care of common logic, such as initializing the `metadata` object, the `timer` logger and +sending the status to the `IngestionPipeline` when needed. + +A couple of examples: + +### Metadata Ingestion + +Here we have two steps: +- `Source`: that will list the metadata of the origin (Dashboards, Tables, Pipelines,...), and translate them to the OpenMetadata + standard. +- `REST Sink`: that will pick up the Create Requests of the above entities and send them to the OpenMetadata server. + +What does the workflow do here? Group together the steps and streamline the execution. The workflow itself is the one +that will know how to get each of the elements produced on the `Source` and pass them to the `Sink`. + +### Profiler Ingestion + +In this case we have 4 steps: +- `Source`: that will pick up the tables from the OpenMetadata API that need to be profiled. +- `Profiler Processor`: to execute the metrics and gather the results for each table. +- `PII Processor`: that will get the result of the profiler, and add any classification that needs to be applied to the tables using NLP models. +- `REST Sink`: to send the results to the OpenMetadata API. + +Here again, the `Workflow` class will move the elements from `Source` -> `Profiler Processor` -> `PII processor` -> `REST Sink`. + +## Status & Exceptions + +While the `Workflow` controls the execution flow, the most important part is in terms of status handling & exception management. + +### Status + +Each `Step` has its own `Status`, storing what has been processed and what has failed. The overall `Workflow` Status is based +on the statuses of the individual steps. + +### Exceptions + +To ensure that all the exception are caught, each `Step` executes its `run` methods of inside a `try/catch` block. It will +only blow things up if we encounter a `WorkflowFatalError`. Any other exception will just be logged. + +However, how do we want to handle exceptions that can happen in every different component? By treating exceptions as data. + +Each `Step` will `yield` or `return` an `Either` object, meaning that processing a single element can either be `right` - +and contain the expected results - or `left` - and contain the raised exception. + +This consensus helps us ensure that we are keeping notes of the logged exceptions in the `Status` of each `Step`, so that +all the errors can properly be logged at the end of the execution. + +For example, this is the `run` method of the `IterStep`: + +```python +def run(self) -> Iterable[Optional[Entity]]: + """ + Run the step and handle the status and exceptions + + Note that we are overwriting the default run implementation + in order to create a generator with `yield`. + """ + try: + for result in self._iter(): + if result.left is not None: + self.status.failed(result.left) + yield None + + if result.right is not None: + self.status.scanned(result.right) + yield result.right + except WorkflowFatalError as err: + logger.error(f"Fatal error running step [{self}]: [{err}]") + raise err + except Exception as exc: + error = f"Encountered exception running step [{self}]: [{exc}]" + logger.warning(error) + self.status.failed( + StackTraceError( + name="Unhandled", error=error, stack_trace=traceback.format_exc() + ) + ) +``` + +By tracking `Unhandled` exceptions, we then know which pieces of the code need to be treated more carefully to control +scenarios that we might not be aware of. + +Then each `Step` control its own `Status` and exceptions (wrapped in the `Either`), and just push down the workflow +the actual `right` response. + +> OBS: We can think of this `Workflow` execution as a `flatMap` implementation. + +![base-workflow.workflow.drawio.png](../../../../openmetadata-docs/images/readme/ingestion/base-workflow.workflow.drawio.png) + +Note how in theory, we can keep building the steps together. diff --git a/ingestion/src/metadata/workflow/workflow_output_handler.py b/ingestion/src/metadata/workflow/workflow_output_handler.py index 5444739b188..302316d8b02 100644 --- a/ingestion/src/metadata/workflow/workflow_output_handler.py +++ b/ingestion/src/metadata/workflow/workflow_output_handler.py @@ -18,7 +18,7 @@ import traceback from enum import Enum from logging import Logger from pathlib import Path -from typing import Dict, List, Optional, Type, Union +from typing import Dict, List, Type, Union from pydantic import BaseModel from tabulate import tabulate @@ -230,55 +230,6 @@ def print_status(workflow: "BaseWorkflow") -> None: ) -def print_test_suite_status(workflow) -> None: - """ - Print the test suite workflow results - """ - print_workflow_summary_legacy( - workflow, processor=True, processor_status=workflow.status - ) - - if workflow.result_status() == 1: - log_ansi_encoded_string( - color=ANSI.BRIGHT_RED, bold=True, message=WORKFLOW_FAILURE_MESSAGE - ) - else: - log_ansi_encoded_string( - color=ANSI.GREEN, bold=True, message=WORKFLOW_SUCCESS_MESSAGE - ) - - -def print_data_insight_status(workflow) -> None: - """ - Print the test suite workflow results - Args: - workflow (DataInsightWorkflow): workflow object - """ - # TODO: fixme - print_workflow_summary_legacy( - workflow, - processor=True, - processor_status=workflow.source.get_status(), - ) - - if workflow.source.get_status().source_start_time: - log_ansi_encoded_string( - message=f"Workflow finished in time {pretty_print_time_duration(time.time()-workflow.source.get_status().source_start_time)} ", # pylint: disable=line-too-long - ) - - if workflow.result_status() == 1: - log_ansi_encoded_string(message=WORKFLOW_FAILURE_MESSAGE) - elif workflow.source.get_status().warnings or ( - hasattr(workflow, "sink") and workflow.sink.get_status().warnings - ): - log_ansi_encoded_string(message=WORKFLOW_WARNING_MESSAGE) - else: - log_ansi_encoded_string(message=WORKFLOW_SUCCESS_MESSAGE) - log_ansi_encoded_string( - color=ANSI.GREEN, bold=True, message=WORKFLOW_SUCCESS_MESSAGE - ) - - def is_debug_enabled(workflow) -> bool: return ( hasattr(workflow, "config") @@ -357,118 +308,6 @@ def print_workflow_status_debug(workflow: "BaseWorkflow") -> None: log_ansi_encoded_string(message=step.get_status().as_string()) -def get_source_status(workflow, source_status: Status) -> Optional[Status]: - if hasattr(workflow, "source"): - return source_status if source_status else workflow.source.get_status() - return source_status - - -def get_processor_status(workflow, processor_status: Status) -> Optional[Status]: - if hasattr(workflow, "processor"): - return processor_status if processor_status else workflow.processor.get_status() - return processor_status - - -def print_workflow_summary_legacy( - workflow, - source: bool = False, - stage: bool = False, - bulk_sink: bool = False, - processor: bool = False, - source_status: Status = None, - processor_status: Status = None, -): - """ - To be removed. All workflows should use the new `print_workflow_summary` - after making the transition to the BaseWorkflow steps with common Status. - """ - source_status = get_source_status(workflow, source_status) - processor_status = get_processor_status(workflow, processor_status) - if is_debug_enabled(workflow): - print_workflow_status_debug_legacy( - workflow, - bulk_sink, - stage, - source_status, - processor_status, - ) - summary = Summary() - failures = [] - if source_status and source: - summary += get_summary(source_status) - failures.append(Failure(name="Source", failures=source_status.failures)) - if hasattr(workflow, "stage") and stage: - summary += get_summary(workflow.stage.get_status()) - failures.append( - Failure(name="Stage", failures=workflow.stage.get_status().failures) - ) - if hasattr(workflow, "sink"): - summary += get_summary(workflow.sink.get_status()) - failures.append( - Failure(name="Sink", failures=workflow.sink.get_status().failures) - ) - if hasattr(workflow, "bulk_sink") and bulk_sink: - summary += get_summary(workflow.bulk_sink.get_status()) - failures.append( - Failure(name="Bulk Sink", failures=workflow.bulk_sink.get_status().failures) - ) - if processor_status and processor: - summary += get_summary(processor_status) - failures.append(Failure(name="Processor", failures=processor_status.failures)) - - print_failures_if_apply(failures) - - log_ansi_encoded_string(bold=True, message="Workflow Summary:") - log_ansi_encoded_string(message=f"Total processed records: {summary.records}") - log_ansi_encoded_string(message=f"Total warnings: {summary.warnings}") - log_ansi_encoded_string(message=f"Total filtered: {summary.filtered}") - log_ansi_encoded_string(message=f"Total errors: {summary.errors}") - - total_success = max(summary.records, 1) - log_ansi_encoded_string( - color=ANSI.BRIGHT_CYAN, - bold=True, - message=f"Success %: " - f"{round(total_success * 100 / (total_success + summary.errors), 2)}", - ) - - -def print_workflow_status_debug_legacy( - workflow, - bulk_sink: bool = False, - stage: bool = False, - source_status: Status = None, - processor_status: Status = None, -) -> None: - """ - Args: - workflow: the workflow status to be printed - bulk_sink: if bull_sink status must be printed - stage: if stage status must be printed - source_status: source status to be printed - processor_status: processor status to be printed - - Returns: - Print Workflow status when the workflow logger level is DEBUG - """ - log_ansi_encoded_string(bold=True, message="Statuses detailed info:") - if source_status: - log_ansi_encoded_string(bold=True, message="Source Status:") - log_ansi_encoded_string(message=source_status.as_string()) - if hasattr(workflow, "stage") and stage: - log_ansi_encoded_string(bold=True, message="Stage Status:") - log_ansi_encoded_string(message=workflow.stage.get_status().as_string()) - if hasattr(workflow, "sink"): - log_ansi_encoded_string(bold=True, message="Sink Status:") - log_ansi_encoded_string(message=workflow.sink.get_status().as_string()) - if hasattr(workflow, "bulk_sink") and bulk_sink: - log_ansi_encoded_string(bold=True, message="Bulk Sink Status:") - log_ansi_encoded_string(message=workflow.bulk_sink.get_status().as_string()) - if processor_status: - log_ansi_encoded_string(bold=True, message="Processor Status:") - log_ansi_encoded_string(message=processor_status.as_string()) - - def get_summary(status: Status) -> Summary: records = len(status.records) warnings = len(status.warnings) diff --git a/ingestion/tests/cli_e2e/base/test_cli_dashboard.py b/ingestion/tests/cli_e2e/base/test_cli_dashboard.py index e4066834af2..e265ac4ffb7 100644 --- a/ingestion/tests/cli_e2e/base/test_cli_dashboard.py +++ b/ingestion/tests/cli_e2e/base/test_cli_dashboard.py @@ -18,7 +18,6 @@ from unittest import TestCase import pytest -from metadata.ingestion.api.sink import SinkStatus from metadata.ingestion.api.status import Status from .e2e_types import E2EType @@ -95,17 +94,17 @@ class CliDashboardBase(TestCase): raise NotImplementedError() @abstractmethod - def assert_not_including(self, source_status: Status, sink_status: SinkStatus): + def assert_not_including(self, source_status: Status, sink_status: Status): raise NotImplementedError() @abstractmethod def assert_for_vanilla_ingestion( - self, source_status: Status, sink_status: SinkStatus + self, source_status: Status, sink_status: Status ) -> None: raise NotImplementedError() @abstractmethod - def assert_filtered_mix(self, source_status: Status, sink_status: SinkStatus): + def assert_filtered_mix(self, source_status: Status, sink_status: Status): raise NotImplementedError() @staticmethod diff --git a/ingestion/tests/cli_e2e/base/test_cli_db.py b/ingestion/tests/cli_e2e/base/test_cli_db.py index 0a70d1574fc..7b694405a02 100644 --- a/ingestion/tests/cli_e2e/base/test_cli_db.py +++ b/ingestion/tests/cli_e2e/base/test_cli_db.py @@ -19,7 +19,6 @@ from unittest import TestCase import pytest from metadata.generated.schema.entity.data.table import Table -from metadata.ingestion.api.sink import SinkStatus from metadata.ingestion.api.status import Status from .e2e_types import E2EType @@ -243,54 +242,54 @@ class CliDBBase(TestCase): @abstractmethod def assert_for_vanilla_ingestion( - self, source_status: Status, sink_status: SinkStatus + self, source_status: Status, sink_status: Status ) -> None: raise NotImplementedError() @abstractmethod def assert_for_table_with_profiler( - self, source_status: Status, sink_status: SinkStatus + self, source_status: Status, sink_status: Status ): raise NotImplementedError() @abstractmethod def assert_for_table_with_profiler_time_partition( - self, source_status: Status, sink_status: SinkStatus + self, source_status: Status, sink_status: Status ): raise NotImplementedError() @abstractmethod def assert_for_delete_table_is_marked_as_deleted( - self, source_status: Status, sink_status: SinkStatus + self, source_status: Status, sink_status: Status ): raise NotImplementedError() @abstractmethod def assert_filtered_schemas_includes( - self, source_status: Status, sink_status: SinkStatus + self, source_status: Status, sink_status: Status ): raise NotImplementedError() @abstractmethod def assert_filtered_schemas_excludes( - self, source_status: Status, sink_status: SinkStatus + self, source_status: Status, sink_status: Status ): raise NotImplementedError() @abstractmethod def assert_filtered_tables_includes( - self, source_status: Status, sink_status: SinkStatus + self, source_status: Status, sink_status: Status ): raise NotImplementedError() @abstractmethod def assert_filtered_tables_excludes( - self, source_status: Status, sink_status: SinkStatus + self, source_status: Status, sink_status: Status ): raise NotImplementedError() @abstractmethod - def assert_filtered_mix(self, source_status: Status, sink_status: SinkStatus): + def assert_filtered_mix(self, source_status: Status, sink_status: Status): raise NotImplementedError() @staticmethod diff --git a/ingestion/tests/cli_e2e/base/test_cli_dbt.py b/ingestion/tests/cli_e2e/base/test_cli_dbt.py index 85f1037db7b..709a6d83789 100644 --- a/ingestion/tests/cli_e2e/base/test_cli_dbt.py +++ b/ingestion/tests/cli_e2e/base/test_cli_dbt.py @@ -20,7 +20,6 @@ import pytest from metadata.generated.schema.entity.data.table import Table from metadata.generated.schema.tests.testDefinition import TestDefinition, TestPlatform -from metadata.ingestion.api.sink import SinkStatus from metadata.ingestion.api.status import Status from .test_cli import CliBase @@ -107,12 +106,12 @@ class CliDBTBase(TestCase): @abstractmethod def assert_for_vanilla_ingestion( - self, source_status: Status, sink_status: SinkStatus + self, source_status: Status, sink_status: Status ) -> None: raise NotImplementedError() @abstractmethod def assert_for_dbt_ingestion( - self, source_status: Status, sink_status: SinkStatus + self, source_status: Status, sink_status: Status ) -> None: raise NotImplementedError() diff --git a/ingestion/tests/cli_e2e/test_cli_snowflake.py b/ingestion/tests/cli_e2e/test_cli_snowflake.py index 01d85337053..61071a85b7b 100644 --- a/ingestion/tests/cli_e2e/test_cli_snowflake.py +++ b/ingestion/tests/cli_e2e/test_cli_snowflake.py @@ -16,7 +16,6 @@ from typing import List import pytest -from metadata.ingestion.api.sink import SinkStatus from metadata.ingestion.api.status import Status from .base.e2e_types import E2EType @@ -79,7 +78,7 @@ class SnowflakeCliTest(CliCommonDB.TestSuite, SQACommonMethods): return "snowflake" def assert_for_vanilla_ingestion( - self, source_status: Status, sink_status: SinkStatus + self, source_status: Status, sink_status: Status ) -> None: self.assertTrue(len(source_status.failures) == 0) self.assertTrue(len(source_status.warnings) == 0) diff --git a/openmetadata-docs/images/readme/ingestion/base-workflow.steps.drawio.png b/openmetadata-docs/images/readme/ingestion/base-workflow.steps.drawio.png new file mode 100644 index 0000000000000000000000000000000000000000..7d82bd30f12f05c52d7712103770c7913d58af15 GIT binary patch literal 5074 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 literal 0 HcmV?d00001 diff --git a/openmetadata-docs/images/readme/ingestion/base-workflow.workflow.drawio.png b/openmetadata-docs/images/readme/ingestion/base-workflow.workflow.drawio.png new file mode 100644 index 0000000000000000000000000000000000000000..f1319f3ff12ed51a9e7e9c4ad2241612f119eb0f GIT binary patch literal 26288 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