mobuchowski commented on code in PR #74805:
URL: https://github.com/apache/airflow/pull/74805#discussion_r4380439896


##########
providers/common/sql/tests/unit/common/sql/operators/test_sql_execute.py:
##########
@@ -368,6 +368,70 @@ def get_db_hook(self):
     )
 
 
+def test_openlineage_hook_without_database_info_method_returns_empty_lineage():

Review Comment:
   This test only checks `on_start() == OperatorLineage()`. It would still pass 
if the `getattr` check were removed, because the new `except Exception` would 
catch the missing-method `AttributeError` and return the same empty lineage. 
The only difference would be a spurious WARNING. Asserting 
`mock_log.warning.assert_not_called()` would make it actually guard the 
"missing method is not a failure" behaviour, like the matching tests in 
`test_sql_hook_lineage.py` and the compat tests.
   
   Small one in the same file: the two other new tests patch the logger with 
`with mock.patch.object(SQLExecuteQueryOperatorForTest, "log", autospec=True)` 
(L404, L428). The repo guidelines prefer the decorator form, e.g. 
`@mock.patch.object(SQLExecuteQueryOperator, "log", autospec=True)`, which the 
other new tests in this PR already use. Optional.



##########
providers/openlineage/src/airflow/providers/openlineage/sqlparser.py:
##########
@@ -489,23 +489,29 @@ def get_openlineage_facets_with_sql(
         connection = hook.get_connection(conn_id)
 
     if database_info is None:
-        try:
-            database_info = hook.get_openlineage_database_info(connection)
-        except AttributeError:
-            database_info = None
+        get_database_info = getattr(hook, "get_openlineage_database_info", 
None)
+        if get_database_info is not None:
+            try:
+                database_info = get_database_info(connection)
+            except Exception as e:
+                log.warning("Failed to get OpenLineage database info for %s: 
%s", hook, e)
+                log.debug("OpenLineage failure details:", exc_info=True)
 
     if database_info is None:
         log.debug("%s has no database info provided", hook)
         return None
 
-    try:
-        sql_parser = SQLParser(
-            dialect=hook.get_openlineage_database_dialect(connection),
-            default_schema=hook.get_openlineage_default_schema(),
-        )
-    except AttributeError:
+    get_dialect = getattr(hook, "get_openlineage_database_dialect", None)
+    get_default_schema = getattr(hook, "get_openlineage_default_schema", None)
+    if get_dialect is None or get_default_schema is None:
         log.debug("%s failed to get database dialect", hook)
         return None
+    try:
+        sql_parser = SQLParser(dialect=get_dialect(connection), 
default_schema=get_default_schema())
+    except Exception as e:
+        log.warning("Failed to get OpenLineage database dialect for %s: %s", 
hook, e)

Review Comment:
   Two small things about the new warnings, which are now the main way users 
will diagnose these failures:
   
   1. The `try` covers `get_dialect(connection)`, `get_default_schema()` and 
the `SQLParser(...)` constructor, but the message always says "database 
dialect". For example, `SnowflakeHook.get_openlineage_default_schema` calls 
`_get_conn_params()`. If that fails, the user is pointed at the dialect method, 
which isn't the one that broke. Something like "Failed to get OpenLineage 
database dialect or default schema" would be accurate.
   2. The warnings print the hook object, which renders as 
`<airflow.providers.snowflake.hooks.snowflake.SnowflakeHook object at 0x7f…>`. 
With several connections you can't tell which one failed. 
`sql_hook_lineage._get_database_info` already logs the `conn_id`, and using it 
here would make the messages consistent.
   
   The same applies to the copies in `operators/sql.py` (L306, L321, L354) and 
in `common/compat/.../openlineage/utils/sql.py`.



##########
providers/common/compat/tests/unit/common/compat/openlineage/utils/test_sql.py:
##########
@@ -16,8 +16,67 @@
 # under the License.
 from __future__ import annotations
 
+import importlib
+import sys
+import types
+from unittest import mock
+
+import pytest
+
+_MODULE = "airflow.providers.common.compat.openlineage.utils.sql"
+
 
 def test_import():
     from airflow.providers.common.compat.openlineage.utils.sql import 
get_openlineage_facets_with_sql
 
     assert get_openlineage_facets_with_sql is not None
+
+
[email protected]
+def fallback_get_openlineage_facets_with_sql():
+    """Load the module as if the OpenLineage provider was too old to ship 
``get_openlineage_facets_with_sql``."""
+    old_sqlparser = types.ModuleType("airflow.providers.openlineage.sqlparser")
+    old_sqlparser.SQLParser = mock.MagicMock(name="SQLParser")
+    with mock.patch.dict(sys.modules, 
{"airflow.providers.openlineage.sqlparser": old_sqlparser}):

Review Comment:
   When `patch.dict(sys.modules, …)` exits, it restores `sys.modules` to its 
exact previous contents, so it also **removes** any module first imported 
inside the block. The fallback function imports 
`airflow.providers.openlineage.utils.utils` (for 
`should_use_external_connection`), and that module pulls in 
`openlineage.plugins.facets`, `selective_enable` and others. If none of them 
were imported before this test, they're imported during it and then dropped 
from `sys.modules`. Later tests then get a second copy, so class identity and 
`mock.patch` string targets no longer match what earlier imports hold. That 
causes failures that depend on test order. I couldn't confirm whether these 
modules are already loaded when this test runs. Can you check, e.g. by running 
this file alone followed by another openlineage-dependent compat test?
   
   A smaller-blast-radius way to force the fallback is to hide only the 
attribute, so `from … import get_openlineage_facets_with_sql` raises 
`ImportError`:
   ```python
   @pytest.fixture
   def fallback_get_openlineage_facets_with_sql(monkeypatch):
       from airflow.providers.openlineage import sqlparser
   
       monkeypatch.delattr(sqlparser, "get_openlineage_facets_with_sql")
       module = importlib.reload(importlib.import_module(_MODULE))
       yield module.get_openlineage_facets_with_sql
       monkeypatch.undo()
       importlib.reload(module)
   ```
   This also lets you use the real `SQLParser`, or patch it with `autospec`, 
instead of a `MagicMock` with no spec.



##########
providers/common/sql/src/airflow/providers/common/sql/operators/sql.py:
##########
@@ -337,11 +343,16 @@ def get_openlineage_facets_on_complete(self, 
task_instance) -> OperatorLineage |
 
         operator_lineage = self.get_openlineage_facets_on_start() or 
OperatorLineage()

Review Comment:
   One path can still throw away the lineage this PR now preserves. In 
`get_openlineage_facets_on_start`, `hook.get_sqlalchemy_engine()` (L330) is 
passed straight into `generate_openlineage_metadata_from_sql` with no guard. If 
it raises, the exception leaves `on_start`, and `on_complete` (here) never 
reaches `get_openlineage_database_specific_lineage` or `_attach_check_facets`. 
`DefaultExtractor` then returns `None` for the whole task.
   
   `get_sqlalchemy_engine` raises in at least two situations:
   - `AirflowOptionalProviderFeatureException` when SQLAlchemy isn't installed 
(it's an optional extra of common-sql now).
   - An error from `create_engine` when no SQLAlchemy dialect is installed for 
the connection's URI scheme.
   
   `sqlparser.get_openlineage_facets_with_sql` already wraps the same call 
(L517–521). It only builds the engine when `use_connection` is set and falls 
back to `None` on failure. Suggest the same here:
   ```python
   sqlalchemy_engine = None
   if use_external_connection:
       try:
           sqlalchemy_engine = hook.get_sqlalchemy_engine()
       except Exception as e:
           self.log.debug("Failed to get sql alchemy engine: %s", e)
   ```
   Alternatively, `on_start` could call `get_openlineage_facets_with_sql` (from 
`common.compat`, which common-sql already depends on) instead of keeping a 
third copy of this lookup logic. That would fix this gap and stop the copies 
drifting apart.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to