openlineage-python 1.22.0__tar.gz → 1.23.0__tar.gz

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.
Files changed (84) hide show
  1. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/PKG-INFO +1 -1
  2. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/client.py +81 -3
  3. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/constants.py +1 -1
  4. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/generated/column_lineage_dataset.py +13 -1
  5. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/generated/datasource_dataset.py +2 -0
  6. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/generated/nominal_time_run.py +2 -0
  7. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/transport/composite.py +7 -2
  8. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/transport/factory.py +6 -1
  9. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/transport/http.py +7 -0
  10. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/transport/kafka.py +3 -0
  11. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/transport/transport.py +1 -0
  12. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/pyproject.toml +1 -1
  13. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tests/test_client.py +194 -1
  14. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tests/test_composite.py +10 -0
  15. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tests/test_facet_v2.py +5 -0
  16. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tests/test_http.py +119 -1
  17. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tests/test_kafka.py +16 -0
  18. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/.gitignore +0 -0
  19. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/README.md +0 -0
  20. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/docs/Makefile +0 -0
  21. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/docs/conf.py +0 -0
  22. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/docs/convert.py +0 -0
  23. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/docs/index.rst +0 -0
  24. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/docs/make.bat +0 -0
  25. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/docs/source/openlineage.client.rst +0 -0
  26. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/__init__.py +0 -0
  27. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/event_v2.py +0 -0
  28. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/facet.py +0 -0
  29. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/facet_v2.py +0 -0
  30. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/filter.py +0 -0
  31. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/generated/base.py +0 -0
  32. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/generated/data_quality_assertions_dataset.py +0 -0
  33. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/generated/data_quality_metrics_input_dataset.py +0 -0
  34. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/generated/dataset_version_dataset.py +0 -0
  35. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/generated/documentation_dataset.py +0 -0
  36. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/generated/documentation_job.py +0 -0
  37. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/generated/error_message_run.py +0 -0
  38. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/generated/external_query_run.py +0 -0
  39. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/generated/extraction_error_run.py +0 -0
  40. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/generated/job_type_job.py +0 -0
  41. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/generated/lifecycle_state_change_dataset.py +0 -0
  42. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/generated/output_statistics_output_dataset.py +0 -0
  43. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/generated/ownership_dataset.py +0 -0
  44. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/generated/ownership_job.py +0 -0
  45. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/generated/parent_run.py +0 -0
  46. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/generated/processing_engine_run.py +0 -0
  47. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/generated/schema_dataset.py +0 -0
  48. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/generated/source_code_job.py +0 -0
  49. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/generated/source_code_location_job.py +0 -0
  50. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/generated/sql_job.py +0 -0
  51. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/generated/storage_dataset.py +0 -0
  52. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/generated/symlinks_dataset.py +0 -0
  53. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/py.typed +0 -0
  54. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/run.py +0 -0
  55. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/serde.py +0 -0
  56. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/transport/__init__.py +0 -0
  57. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/transport/console.py +0 -0
  58. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/transport/file.py +0 -0
  59. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/transport/msk_iam.py +0 -0
  60. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/transport/noop.py +0 -0
  61. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/utils.py +0 -0
  62. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/openlineage/client/uuid.py +0 -0
  63. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/redact_fields.yml +0 -0
  64. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tests/__init__.py +0 -0
  65. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tests/config/config.yml +0 -0
  66. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tests/config/exact_filter.yml +0 -0
  67. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tests/config/http.yml +0 -0
  68. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tests/config/openlineage.yml +0 -0
  69. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tests/config/regex_filter.yml +0 -0
  70. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tests/conftest.py +0 -0
  71. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tests/example_full_event.json +0 -0
  72. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tests/nominal_time_without_end.json +0 -0
  73. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tests/serde_example_dataset_event.json +0 -0
  74. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tests/serde_example_job_event.json +0 -0
  75. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tests/serde_example_run_event.json +0 -0
  76. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tests/test_events.py +0 -0
  77. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tests/test_facet.py +0 -0
  78. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tests/test_factory.py +0 -0
  79. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tests/test_file.py +0 -0
  80. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tests/test_msk_iam.py +0 -0
  81. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tests/test_utils.py +0 -0
  82. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tests/test_uuid.py +0 -0
  83. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tests/transport.py +0 -0
  84. {openlineage_python-1.22.0 → openlineage_python-1.23.0}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: openlineage-python
3
- Version: 1.22.0
3
+ Version: 1.23.0
4
4
  Summary: OpenLineage Python Client
5
5
  Author-email: OpenLineage <info@openlineage.io>
6
6
  Keywords: openlineage
@@ -2,6 +2,7 @@
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import logging
6
7
  import os
7
8
  import warnings
@@ -16,6 +17,8 @@ if TYPE_CHECKING:
16
17
  from requests import Session
17
18
  from requests.adapters import HTTPAdapter
18
19
 
20
+ import contextlib
21
+
19
22
  from openlineage.client import event_v2
20
23
  from openlineage.client.run import DatasetEvent, JobEvent, RunEvent
21
24
  from openlineage.client.transport import Transport, TransportFactory, get_default_factory
@@ -40,6 +43,9 @@ _T = TypeVar("_T", bound="OpenLineageClient")
40
43
 
41
44
 
42
45
  class OpenLineageClient:
46
+ DYNAMIC_ENV_VARS_PREFIX = "OPENLINEAGE__"
47
+ DEFAULT_URL_TRANSPORT_NAME = "default_http"
48
+
43
49
  def __init__( # noqa: PLR0913
44
50
  self,
45
51
  url: str | None = None,
@@ -64,6 +70,8 @@ class OpenLineageClient:
64
70
  # reload yaml each time config is referred to.
65
71
  self._config: dict[str, dict[str, str]] | None = None
66
72
 
73
+ self._alias_env_vars()
74
+
67
75
  self.transport = self._resolve_transport(
68
76
  url=url, options=options, session=session, transport=transport, factory=factory
69
77
  )
@@ -131,7 +139,7 @@ class OpenLineageClient:
131
139
  self._config = {}
132
140
  return self._config
133
141
 
134
- def _resolve_transport(self, **kwargs: Any) -> Transport:
142
+ def _resolve_transport(self, **kwargs: Any) -> Transport: # noqa: PLR0911
135
143
  """
136
144
  Resolves the transport mechanism based on the provided arguments or environment settings.
137
145
 
@@ -166,11 +174,16 @@ class OpenLineageClient:
166
174
  url=kwargs["url"], options=kwargs.get("options"), session=kwargs.get("session")
167
175
  )
168
176
 
169
- # 5. Check HTTP transport initialization with env variables
177
+ # 5. Check transport initialization with env variables
178
+ if config := self._load_config_from_env_variables():
179
+ factory = kwargs.get("factory") or get_default_factory()
180
+ return factory.create(config["transport"])
181
+
182
+ # 6. Check HTTP transport initialization with env variables
170
183
  if os.environ.get("OPENLINEAGE_URL"):
171
184
  return self._http_transport_from_env_variables()
172
185
 
173
- # 6. If all else fails, print events to console
186
+ # 7. If all else fails, print events to console
174
187
  from openlineage.client.transport.console import ConsoleConfig, ConsoleTransport
175
188
 
176
189
  log.warning("Couldn't find any OpenLineage transport configuration; will print events to console.")
@@ -245,3 +258,68 @@ class OpenLineageClient:
245
258
  session=session,
246
259
  ),
247
260
  )
261
+
262
+ def _alias_env_vars(self) -> None:
263
+ default_transport_name = self.DEFAULT_URL_TRANSPORT_NAME.upper()
264
+ if url := os.environ.get("OPENLINEAGE_URL"):
265
+ if any(
266
+ k.startswith(f"OPENLINEAGE__TRANSPORT__TRANSPORTS__{default_transport_name}")
267
+ for k in os.environ
268
+ ):
269
+ log.warning(
270
+ "%s already found in environment variables, skipping aliasing OPENLINEAGE_URL",
271
+ default_transport_name,
272
+ )
273
+ return
274
+ os.environ[f"OPENLINEAGE__TRANSPORT__TRANSPORTS__{default_transport_name}__TYPE"] = "http"
275
+ os.environ[f"OPENLINEAGE__TRANSPORT__TRANSPORTS__{default_transport_name}__URL"] = url
276
+ if api_key := os.environ.get("OPENLINEAGE_API_KEY"):
277
+ os.environ[
278
+ f"OPENLINEAGE__TRANSPORT__TRANSPORTS__{default_transport_name}__AUTH"
279
+ ] = json.dumps(
280
+ {
281
+ "type": "api_key",
282
+ "apiKey": api_key,
283
+ }
284
+ )
285
+ if endpoint := os.environ.get("OPENLINEAGE_ENDPOINT"):
286
+ os.environ[
287
+ f"OPENLINEAGE__TRANSPORT__TRANSPORTS__{default_transport_name}__ENDPOINT"
288
+ ] = endpoint
289
+
290
+ @classmethod
291
+ def _load_config_from_env_variables(cls) -> dict[str, Any] | None:
292
+ config: dict[str, Any] = {}
293
+
294
+ # get os.environ.items only starting with OPENLINEAGE_ prefix and reverse sort
295
+ # to make sure that top-level keys have precedence
296
+ env_vars = sorted(
297
+ filter(lambda k: k[0].startswith(cls.DYNAMIC_ENV_VARS_PREFIX), os.environ.items()), reverse=True
298
+ )
299
+
300
+ for env_key, env_value in env_vars:
301
+ keys = env_key[len(cls.DYNAMIC_ENV_VARS_PREFIX) :].split("__")
302
+
303
+ # Parse value (try to parse as JSON, otherwise lowercase the value)
304
+ with contextlib.suppress(json.JSONDecodeError):
305
+ env_value = json.loads(env_value) # noqa: PLW2901
306
+
307
+ cls._insert_into_config(config, keys, env_value)
308
+
309
+ if (transport_config := config.get("transport")) is None or transport_config.get("type") is None:
310
+ return None
311
+
312
+ return config
313
+
314
+ @staticmethod
315
+ def _insert_into_config(config: dict[str, Any], key_path: list[str], value: str) -> None:
316
+ keys = [key.lower() for key in key_path]
317
+
318
+ current = config
319
+ for key in keys[:-1]:
320
+ if key not in current:
321
+ current[key] = {}
322
+ current = current[key]
323
+
324
+ # Overwrite if key already exists
325
+ current[keys[-1]] = value
@@ -2,7 +2,7 @@
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "1.22.0"
5
+ __version__ = "1.23.0"
6
6
 
7
7
  DEFAULT_TIMEOUT_MS = 5000
8
8
  DEFAULT_NAMESPACE_NAME = "default"
@@ -15,9 +15,15 @@ class ColumnLineageDatasetFacet(DatasetFacet):
15
15
  fields: dict[str, Fields]
16
16
  """Column level lineage that maps output fields into input fields used to evaluate them."""
17
17
 
18
+ dataset: list[InputField] | None = attr.field(factory=list)
19
+ """
20
+ Column level lineage that affects the whole dataset. This includes filtering, sorting, grouping
21
+ (aggregates), joining, window functions, etc.
22
+ """
23
+
18
24
  @staticmethod
19
25
  def _get_schema() -> str:
20
- return "https://openlineage.io/spec/facets/1-1-0/ColumnLineageDatasetFacet.json#/$defs/ColumnLineageDatasetFacet"
26
+ return "https://openlineage.io/spec/facets/1-2-0/ColumnLineageDatasetFacet.json#/$defs/ColumnLineageDatasetFacet"
21
27
 
22
28
 
23
29
  @attr.define
@@ -35,6 +41,8 @@ class Fields(RedactMixin):
35
41
 
36
42
  @attr.define
37
43
  class InputField(RedactMixin):
44
+ """Represents a single dependency on some field (column)."""
45
+
38
46
  namespace: str
39
47
  """The input dataset namespace"""
40
48
 
@@ -47,6 +55,10 @@ class InputField(RedactMixin):
47
55
  transformations: list[Transformation] | None = attr.field(factory=list)
48
56
  _skip_redact: ClassVar[list[str]] = ["namespace", "name", "field"]
49
57
 
58
+ @staticmethod
59
+ def _get_schema() -> str:
60
+ return "https://openlineage.io/spec/facets/1-2-0/ColumnLineageDatasetFacet.json#/$defs/InputField"
61
+
50
62
 
51
63
  @attr.define
52
64
  class Transformation(RedactMixin):
@@ -21,6 +21,8 @@ class DatasourceDatasetFacet(DatasetFacet):
21
21
 
22
22
  @uri.validator
23
23
  def uri_check(self, attribute: str, value: str) -> None: # noqa: ARG002
24
+ if value is None:
25
+ return
24
26
  from urllib.parse import urlparse
25
27
 
26
28
  urlparse(value)
@@ -39,6 +39,8 @@ class NominalTimeRunFacet(RunFacet):
39
39
 
40
40
  @nominalEndTime.validator
41
41
  def nominalendtime_check(self, attribute: str, value: str) -> None: # noqa: ARG002
42
+ if value is None:
43
+ return
42
44
  from dateutil import parser
43
45
 
44
46
  parser.isoparse(value)
@@ -35,7 +35,7 @@ class CompositeConfig(Config):
35
35
  transport will halt the emission process for subsequent transports.
36
36
  """
37
37
 
38
- transports: list[dict[str, Any]] = attr.ib()
38
+ transports: list[dict[str, Any]] | dict[str, dict[str, Any]] = attr.ib()
39
39
  continue_on_failure: bool = attr.ib(default=True)
40
40
 
41
41
  @classmethod
@@ -63,7 +63,12 @@ class CompositeTransport(Transport):
63
63
  from openlineage.client.transport import get_default_factory
64
64
 
65
65
  transports = []
66
- for transport_config in self.config.transports:
66
+ config_transports = self.config.transports
67
+ if isinstance(config_transports, dict):
68
+ config_transports = [
69
+ {**config, "name": name} for name, config in config_transports.items() if config
70
+ ]
71
+ for transport_config in config_transports:
67
72
  transports.append(get_default_factory().create(transport_config))
68
73
  return transports
69
74
 
@@ -50,6 +50,8 @@ class DefaultTransportFactory(TransportFactory):
50
50
  msg = "You need to pass transport type in config."
51
51
  raise TypeError(msg) from None
52
52
 
53
+ transport_name = config.pop("name", None)
54
+
53
55
  transport_class_type_or_str = self.transports.get(transport_type, transport_type)
54
56
 
55
57
  if isinstance(transport_class_type_or_str, str):
@@ -68,4 +70,7 @@ class DefaultTransportFactory(TransportFactory):
68
70
  msg = f"Config {config_class} has to be class, and subclass of Config"
69
71
  raise TypeError(msg)
70
72
 
71
- return transport_class(config_class.from_dict(config)) # type: ignore[call-arg]
73
+ transport: Transport = transport_class(config_class.from_dict(config)) # type: ignore[call-arg]
74
+ if transport_name and not transport.name:
75
+ transport.name = transport_name
76
+ return transport
@@ -87,6 +87,8 @@ class HttpConfig(Config):
87
87
  session: Session | None = attr.ib(default=None)
88
88
  # not set by TransportFactory
89
89
  adapter: HTTPAdapter | None = attr.ib(default=None)
90
+ # custom headers support
91
+ custom_headers: dict[str, str] = attr.ib(factory=dict)
90
92
 
91
93
  @classmethod
92
94
  def from_dict(cls, params: dict[str, Any]) -> HttpConfig:
@@ -150,6 +152,7 @@ class HttpTransport(Transport):
150
152
  self.session.headers["Content-Type"] = "application/json"
151
153
  auth_headers = self._auth_headers(config.auth)
152
154
  self.session.headers.update(auth_headers)
155
+ self.session.headers.update(config.custom_headers)
153
156
  self.timeout = config.timeout
154
157
  self.verify = config.verify
155
158
  self.compression = config.compression
@@ -168,6 +171,9 @@ class HttpTransport(Transport):
168
171
  http_client.HTTPConnection.debuglevel = 0
169
172
  body, headers = self._prepare_request(Serde.to_json(event))
170
173
 
174
+ # Update headers with custom headers from the config
175
+ headers.update(self.config.custom_headers)
176
+
171
177
  if self.session:
172
178
  resp = self.session.post(
173
179
  url=urljoin(self.url, self.endpoint),
@@ -179,6 +185,7 @@ class HttpTransport(Transport):
179
185
  else:
180
186
  headers["Content-Type"] = "application/json"
181
187
  headers.update(self._auth_headers(self.config.auth))
188
+ headers.update(self.config.custom_headers)
182
189
  with Session() as session:
183
190
  resp = session.post(
184
191
  url=urljoin(self.url, self.endpoint),
@@ -41,6 +41,9 @@ class KafkaConfig(Config):
41
41
 
42
42
  @classmethod
43
43
  def from_dict(cls: type[_T], params: dict[str, Any]) -> _T:
44
+ # alias message_key to messageKey
45
+ if message_key := params.pop("message_key", None):
46
+ params["messageKey"] = params.get("messageKey") or message_key
44
47
  if "config" not in params:
45
48
  msg = "kafka `config` not passed to KafkaConfig"
46
49
  raise RuntimeError(msg)
@@ -35,6 +35,7 @@ class Config:
35
35
 
36
36
  class Transport:
37
37
  kind: str | None = None
38
+ name: str | None = None
38
39
  config_class: type[Config] = Config
39
40
 
40
41
  def emit(self, event: Event) -> Any:
@@ -6,7 +6,7 @@ requires = [
6
6
 
7
7
  [project]
8
8
  name = "openlineage-python"
9
- version = "1.22.0"
9
+ version = "1.23.0"
10
10
  description = "OpenLineage Python Client"
11
11
  readme = "README.md"
12
12
  keywords = [
@@ -20,13 +20,16 @@ from openlineage.client.run import (
20
20
  RunEvent,
21
21
  RunState,
22
22
  )
23
- from openlineage.client.transport.http import HttpTransport
23
+ from openlineage.client.transport.composite import CompositeTransport
24
+ from openlineage.client.transport.console import ConsoleTransport
25
+ from openlineage.client.transport.http import ApiKeyTokenProvider, HttpTransport, TokenProvider
24
26
  from openlineage.client.transport.noop import NoopTransport
25
27
  from openlineage.client.uuid import generate_new_uuid
26
28
 
27
29
  if TYPE_CHECKING:
28
30
  from pathlib import Path
29
31
 
32
+ from openlineage.client.transport.kafka import KafkaTransport
30
33
  from pytest_mock import MockerFixture
31
34
 
32
35
 
@@ -389,3 +392,193 @@ def test_http_transport_from_url_no_options() -> None:
389
392
  assert transport.timeout == timeout
390
393
  assert transport.verify is True
391
394
  assert transport.config.auth.api_key == "xxx"
395
+
396
+
397
+ @patch.dict(
398
+ os.environ, {"OPENLINEAGE_URL": "http://example.com", "OPENLINEAGE__TRANSPORT__TYPE": "composite"}
399
+ )
400
+ def test_composite_transport_with_aliased_url() -> None:
401
+ transport: CompositeTransport = OpenLineageClient().transport
402
+ assert transport.kind == CompositeTransport.kind
403
+ assert len(transport.transports) == 1
404
+ assert transport.transports[0].kind == HttpTransport.kind
405
+ assert isinstance(transport.transports[0].config.auth, TokenProvider)
406
+
407
+
408
+ @patch.dict(
409
+ os.environ,
410
+ {
411
+ "OPENLINEAGE_URL": "http://example.com",
412
+ "OPENLINEAGE_API_KEY": "random_key",
413
+ "OPENLINEAGE__TRANSPORT__TYPE": "composite",
414
+ },
415
+ )
416
+ def test_composite_transport_with_aliased_url_and_api_key() -> None:
417
+ transport: CompositeTransport = OpenLineageClient().transport
418
+ assert transport.kind == CompositeTransport.kind
419
+ assert len(transport.transports) == 1
420
+ assert transport.transports[0].kind == HttpTransport.kind
421
+ assert isinstance(transport.transports[0].config.auth, ApiKeyTokenProvider)
422
+ assert transport.transports[0].config.auth.api_key == "random_key"
423
+
424
+
425
+ @patch.dict(
426
+ os.environ,
427
+ {
428
+ "OPENLINEAGE_URL": "http://example.com",
429
+ "OPENLINEAGE__TRANSPORT__TYPE": "composite",
430
+ "OPENLINEAGE__TRANSPORT__TRANSPORTS__ANOTHER__TYPE": "console",
431
+ },
432
+ )
433
+ def test_composite_transport_with_aliased_url_and_second_transport() -> None:
434
+ transport: CompositeTransport = OpenLineageClient().transport
435
+ assert transport.kind == CompositeTransport.kind
436
+ assert len(transport.transports) == 2 # noqa: PLR2004
437
+ assert transport.transports[0].kind == HttpTransport.kind
438
+
439
+
440
+ @patch.dict(
441
+ os.environ,
442
+ {
443
+ "OPENLINEAGE_URL": "http://example.com",
444
+ "OPENLINEAGE__TRANSPORT__TYPE": "composite",
445
+ "OPENLINEAGE__TRANSPORT__TRANSPORTS__DEFAULT_HTTP": "{}",
446
+ "OPENLINEAGE__TRANSPORT__TRANSPORTS__ANOTHER__TYPE": "console",
447
+ },
448
+ )
449
+ def test_composite_transport_with_aliased_url_and_overriden_alias() -> None:
450
+ transport: CompositeTransport = OpenLineageClient().transport
451
+ assert transport.kind == CompositeTransport.kind
452
+ assert len(transport.transports) == 1
453
+ assert transport.transports[0].kind == ConsoleTransport.kind
454
+
455
+
456
+ @patch.dict(
457
+ os.environ,
458
+ {
459
+ "OPENLINEAGE_URL": "http://example.com",
460
+ "OPENLINEAGE__TRANSPORT__TYPE": "composite",
461
+ "OPENLINEAGE__TRANSPORT__TRANSPORTS__DEFAULT_HTTP__TYPE": "console",
462
+ "OPENLINEAGE__TRANSPORT__TRANSPORTS__ANOTHER__TYPE": "console",
463
+ },
464
+ )
465
+ def test_openlineage_url_does_not_alias_when_transport_exists() -> None:
466
+ transport: CompositeTransport = OpenLineageClient().transport
467
+ assert transport.kind == CompositeTransport.kind
468
+ assert len(transport.transports) == 2 # noqa: PLR2004
469
+ assert transport.transports[0].kind == transport.transports[1].kind == ConsoleTransport.kind
470
+
471
+
472
+ @patch.dict(
473
+ os.environ,
474
+ {
475
+ "OPENLINEAGE__TRANSPORT__TYPE": "kafka",
476
+ "OPENLINEAGE__TRANSPORT__TOPIC": "my_topic",
477
+ "OPENLINEAGE__TRANSPORT__CONFIG": '{"bootstrap.servers": "localhost:9092,another.host:9092", "acks": "all", "retries": 3}', # noqa: E501
478
+ "OPENLINEAGE__TRANSPORT__FLUSH": "true",
479
+ "OPENLINEAGE__TRANSPORT__MESSAGE_KEY": "some-value",
480
+ },
481
+ )
482
+ def test_kafka_transport_configured_with_aliased_message_key() -> None:
483
+ transport: KafkaTransport = OpenLineageClient().transport
484
+ assert transport.message_key == "some-value"
485
+ assert transport.flush is True
486
+ assert transport.kafka_config.config == {
487
+ "bootstrap.servers": "localhost:9092,another.host:9092",
488
+ "acks": "all",
489
+ "retries": 3,
490
+ }
491
+
492
+
493
+ class TestOpenLineageConfigLoader:
494
+ @pytest.mark.parametrize(
495
+ ("env_vars", "expected_config"),
496
+ [
497
+ (
498
+ {
499
+ "OPENLINEAGE__TRANSPORT__TYPE": "http",
500
+ "OPENLINEAGE__TRANSPORT__URL": "http://localhost:5050",
501
+ "OPENLINEAGE__TRANSPORT__AUTH__API_KEY": "random_token",
502
+ },
503
+ {
504
+ "transport": {
505
+ "type": "http",
506
+ "url": "http://localhost:5050",
507
+ "auth": {"api_key": "random_token"},
508
+ }
509
+ },
510
+ ),
511
+ (
512
+ {
513
+ "OPENLINEAGE__TRANSPORT__TYPE": "composite",
514
+ "OPENLINEAGE__TRANSPORT__TRANSPORTS__FIRST__TYPE": "http",
515
+ "OPENLINEAGE__TRANSPORT__TRANSPORTS__FIRST__URL": "http://localhost:5050",
516
+ "OPENLINEAGE__TRANSPORT__TRANSPORTS__SECOND__TYPE": "console",
517
+ },
518
+ {
519
+ "transport": {
520
+ "type": "composite",
521
+ "transports": {
522
+ "first": {"type": "http", "url": "http://localhost:5050"},
523
+ "second": {"type": "console"},
524
+ },
525
+ }
526
+ },
527
+ ),
528
+ (
529
+ {"OPENLINEAGE__TRANSPORT": '{"type": "console"}', "OPENLINEAGE__TRANSPORT__TYPE": "http"},
530
+ {"transport": {"type": "console"}},
531
+ ),
532
+ (
533
+ {
534
+ "OPENLINEAGE__TRANSPORT__TYPE": "kafka",
535
+ "OPENLINEAGE__TRANSPORT__PROPERTIES": '{"key.serializer": "org.apache.kafka.common.serialization.StringSerializer"}', # noqa: E501
536
+ },
537
+ {
538
+ "transport": {
539
+ "type": "kafka",
540
+ "properties": {
541
+ "key.serializer": "org.apache.kafka.common.serialization.StringSerializer"
542
+ },
543
+ }
544
+ },
545
+ ),
546
+ (
547
+ {
548
+ "OPENLINEAGE__TRANSPORT__TYPE": "test",
549
+ "OPENLINEAGE__TRANSPORT__MY_NAME__LIST": '["first", "SeCond"]',
550
+ },
551
+ {"transport": {"my_name": {"list": ["first", "SeCond"]}, "type": "test"}},
552
+ ),
553
+ (
554
+ {
555
+ "OPENLINEAGE__TRANSPORT__TYPE": "http",
556
+ "OPENLINEAGE__TRANSPORT__URL": "http://localhost:5050",
557
+ "OPENLINEAGE__TRANSPORT__AUTH__API_KEY": "random_token",
558
+ "OPENLINEAGE__TRANSPORT__AUTH__COMPRESSION": "gzip",
559
+ },
560
+ {
561
+ "transport": {
562
+ "type": "http",
563
+ "url": "http://localhost:5050",
564
+ "auth": {"api_key": "random_token", "compression": "gzip"},
565
+ }
566
+ },
567
+ ),
568
+ (
569
+ {
570
+ "OPENLINEAGE__TRANSPORT": '{"type": "console"}',
571
+ "OPENLINEAGE__TRANSPORT__TYPE": "http",
572
+ "OPENLINEAGE__TRANSPORT__URL": "http://localhost:5050",
573
+ "OPENLINEAGE__TRANSPORT__AUTH__API_KEY": "random_token",
574
+ "OPENLINEAGE__TRANSPORT__AUTH__COMPRESSION": "gzip",
575
+ },
576
+ {"transport": {"type": "console"}},
577
+ ),
578
+ ],
579
+ )
580
+ @patch.dict(os.environ, {})
581
+ def test_config_loader(self, env_vars, expected_config):
582
+ with patch.dict(os.environ, env_vars):
583
+ config = OpenLineageClient._load_config_from_env_variables() # noqa: SLF001
584
+ assert config == expected_config
@@ -67,6 +67,16 @@ def test_composite_transport_create_transports():
67
67
  assert transport.transports[0].kind == "fake"
68
68
 
69
69
 
70
+ def test_composite_transport_from_dict_config():
71
+ config = CompositeConfig(
72
+ transports={"fake_trans": {"type": "tests.transport.FakeTransport"}}, continue_on_failure=True
73
+ )
74
+ transport = CompositeTransport(config)
75
+ assert len(transport.transports) == 1
76
+ assert transport.transports[0].kind == "fake"
77
+ assert transport.transports[0].name == "fake_trans"
78
+
79
+
70
80
  @mock.patch("openlineage.client.transport.get_default_factory")
71
81
  def test_transports(mock_factory):
72
82
  mock_transport1 = MagicMock(spec=Transport)
@@ -43,6 +43,11 @@ def test_set_producer_from_argument():
43
43
  assert facet._producer == "http://another.producer" # noqa: SLF001
44
44
 
45
45
 
46
+ def test_optional_attributed_not_validated():
47
+ """Don't pass optional value with validator."""
48
+ nominal_time_run.NominalTimeRunFacet(nominalStartTime="2020-12-17T03:00:00.001Z")
49
+
50
+
46
51
  def test_custom_facet() -> None:
47
52
  session = mock.MagicMock()
48
53
  client = OpenLineageClient(url="http://example.com", session=session)
@@ -4,12 +4,14 @@ from __future__ import annotations
4
4
 
5
5
  import datetime
6
6
  import gzip
7
+ import os
7
8
  from typing import TYPE_CHECKING
9
+ from unittest.mock import MagicMock, patch
8
10
 
9
11
  from openlineage.client import OpenLineageClient
10
12
  from openlineage.client.run import Job, Run, RunEvent, RunState
11
13
  from openlineage.client.serde import Serde
12
- from openlineage.client.transport.http import HttpCompression, HttpConfig, HttpTransport
14
+ from openlineage.client.transport.http import ApiKeyTokenProvider, HttpCompression, HttpConfig, HttpTransport
13
15
  from openlineage.client.uuid import generate_new_uuid
14
16
  from requests import Session
15
17
 
@@ -163,3 +165,119 @@ def test_http_config_configs_session() -> None:
163
165
  assert not hasattr(config.auth, "api_key")
164
166
  assert config.session is s
165
167
  assert config.adapter is None
168
+
169
+
170
+ @patch("requests.Session.post")
171
+ def test_http_transport_custom_headers_applied(mock_post):
172
+ custom_headers = {"X-Custom-Header": "CustomValue", "X-Another-Header": "AnotherValue"}
173
+
174
+ config = HttpConfig(url="http://example.com", custom_headers=custom_headers)
175
+
176
+ transport = HttpTransport(config)
177
+ mock_event = MagicMock()
178
+
179
+ with patch("openlineage.client.serde.Serde.to_json", return_value='{"mock": "event"}'):
180
+ transport.emit(mock_event)
181
+
182
+ mock_post.assert_called_once()
183
+ _, kwargs = mock_post.call_args
184
+ headers = kwargs["headers"]
185
+
186
+ assert headers["X-Custom-Header"] == "CustomValue"
187
+ assert headers["X-Another-Header"] == "AnotherValue"
188
+
189
+
190
+ @patch("requests.Session.post")
191
+ def test_http_transport_auth_and_custom_headers_applied(mock_post):
192
+ custom_headers = {"X-Custom-Header": "CustomValue"}
193
+ auth_token = "Bearer test_token" # noqa: S105
194
+
195
+ # Set up config with an ApiKeyTokenProvider
196
+ config = HttpConfig(
197
+ url="http://example.com",
198
+ auth=ApiKeyTokenProvider({"api_key": "test_token"}),
199
+ custom_headers=custom_headers,
200
+ )
201
+
202
+ transport = HttpTransport(config)
203
+ mock_event = MagicMock()
204
+
205
+ with patch("openlineage.client.serde.Serde.to_json", return_value='{"mock": "event"}'):
206
+ transport.emit(mock_event)
207
+
208
+ mock_post.assert_called_once()
209
+ _, kwargs = mock_post.call_args
210
+ headers = kwargs["headers"]
211
+
212
+ assert headers["Authorization"] == auth_token
213
+ assert headers["X-Custom-Header"] == "CustomValue"
214
+
215
+
216
+ @patch("requests.Session.post")
217
+ def test_http_transport_no_custom_headers(mock_post):
218
+ config = HttpConfig(url="http://example.com")
219
+ transport = HttpTransport(config)
220
+ mock_event = MagicMock()
221
+
222
+ with patch("openlineage.client.serde.Serde.to_json", return_value='{"mock": "event"}'):
223
+ transport.emit(mock_event)
224
+
225
+ mock_post.assert_called_once()
226
+ _, kwargs = mock_post.call_args
227
+ headers = kwargs["headers"]
228
+
229
+ # Only the Content-Type header should be set if no custom headers
230
+ assert headers["Content-Type"] == "application/json"
231
+ assert "X-Custom-Header" not in headers
232
+
233
+
234
+ @patch("requests.Session.post")
235
+ def test_http_transport_compression_with_custom_headers(mock_post):
236
+ custom_headers = {"X-Custom-Header": "CustomValue"}
237
+
238
+ config = HttpConfig(
239
+ url="http://example.com", compression=HttpCompression.GZIP, custom_headers=custom_headers
240
+ )
241
+ transport = HttpTransport(config)
242
+ mock_event = MagicMock()
243
+
244
+ with patch("openlineage.client.serde.Serde.to_json", return_value='{"mock": "event"}'), patch(
245
+ "gzip.compress", return_value=b"compressed_data"
246
+ ):
247
+ transport.emit(mock_event)
248
+
249
+ mock_post.assert_called_once()
250
+ _, kwargs = mock_post.call_args
251
+ headers = kwargs["headers"]
252
+ data = kwargs["data"]
253
+
254
+ assert headers["X-Custom-Header"] == "CustomValue"
255
+ assert headers["Content-Encoding"] == "gzip"
256
+ assert data == b"compressed_data"
257
+
258
+
259
+ @patch("requests.Session.post")
260
+ @patch.dict(
261
+ os.environ,
262
+ {
263
+ "OPENLINEAGE__TRANSPORT__TYPE": "http",
264
+ "OPENLINEAGE__TRANSPORT__URL": "http://example.com",
265
+ "OPENLINEAGE__TRANSPORT__CUSTOM_HEADERS__CUSTOM_HEADER": "FIRST",
266
+ "OPENLINEAGE__TRANSPORT__CUSTOM_HEADERS__ANOTHER_HEADER": "second",
267
+ },
268
+ )
269
+ def test_http_transport_with_custom_headers_from_env_vars(mock_post):
270
+ transport = OpenLineageClient().transport
271
+ mock_event = MagicMock()
272
+
273
+ with patch("openlineage.client.serde.Serde.to_json", return_value='{"mock": "event"}'), patch(
274
+ "gzip.compress", return_value=b"compressed_data"
275
+ ):
276
+ transport.emit(mock_event)
277
+
278
+ mock_post.assert_called_once()
279
+ _, kwargs = mock_post.call_args
280
+ headers = kwargs["headers"]
281
+
282
+ assert headers["custom_header"] == "FIRST"
283
+ assert headers["another_header"] == "second"
@@ -106,6 +106,22 @@ def test_kafka_loads_partial_config_with_defaults() -> None:
106
106
  assert config.flush is True
107
107
 
108
108
 
109
+ def test_kafka_config_accepts_aliased_message_key() -> None:
110
+ config = KafkaConfig.from_dict(
111
+ {
112
+ "type": "kafka",
113
+ "topic": "my_topic",
114
+ "message_key": "some-value",
115
+ "flush": True,
116
+ "config": {"bootstrap.servers": "localhost:9092,another.host:9092", "acks": "all", "retries": 3},
117
+ }
118
+ )
119
+
120
+ assert config.messageKey == "some-value"
121
+ assert config.topic == "my_topic"
122
+ assert config.flush is True
123
+
124
+
109
125
  def test_kafka_load_config_fails_on_no_config() -> None:
110
126
  with pytest.raises(TypeError):
111
127
  KafkaConfig.from_dict(