Skip to content

Commit 71d69ef

Browse files
committed
Implement testing for Opralog extract_and_load script
1 parent 95bb6b7 commit 71d69ef

19 files changed

Lines changed: 612 additions & 565 deletions

File tree

elt-common/pyproject.toml

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,28 @@ readme = "README.md"
1010
requires-python = ">=3.13"
1111
dependencies = ["dlt[parquet,s3]~=1.15.0", "pyiceberg~=0.9.1"]
1212

13+
1314
[project.optional-dependencies]
1415
iceberg-maintenance = ["click~=8.2.1", "sqlalchemy~=2.0.43", "trino~=0.336.0"]
15-
m365 = [
16-
"authlib~=1.6.1",
17-
"httpx~=0.28.1",
18-
"tenacity~=9.1.2",
19-
]
16+
m365 = ["authlib~=1.6.1", "httpx~=0.28.1", "tenacity~=9.1.2"]
17+
18+
[project.entry-points.pytest11]
19+
elt_common = "elt_common.testing.fixtures"
2020

2121
[project.scripts]
2222
iceberg-maintenance = "elt_common.iceberg.maintenance:cli"
2323

2424
[dependency-groups]
2525
dev = [
26-
"minio~=7.2.16",
27-
"prek>=0.1.6",
28-
"pydantic-settings~=2.10.1",
29-
"pytest~=8.3.5",
30-
"pytest-httpx~=0.35.0",
31-
"pytest-mock~=3.14.1",
32-
"requests~=2.32.3",
33-
"requests-mock~=1.12.1",
34-
"ruff~=0.11.11",
26+
"minio~=7.2.16",
27+
"prek>=0.1.6",
28+
"pydantic-settings~=2.10.1",
29+
"pytest~=8.3.5",
30+
"pytest-httpx~=0.35.0",
31+
"pytest-mock~=3.14.1",
32+
"requests~=2.32.3",
33+
"requests-mock~=1.12.1",
34+
"ruff~=0.11.11",
3535
]
3636

3737

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from contextlib import contextmanager
2+
from typing import cast
3+
4+
import dlt
5+
6+
from .pyiceberg import PyIcebergClient
7+
8+
9+
@contextmanager
10+
def iceberg_catalog(pipeline: dlt.Pipeline):
11+
"""Given a pipeline return the Iceberg catalog client for this pipeline"""
12+
with pipeline.destination_client() as client:
13+
assert isinstance(client, PyIcebergClient)
14+
yield cast(PyIcebergClient, client).iceberg_catalog
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import dataclasses
2+
import urllib.parse
3+
from typing import Any, Dict
4+
5+
from pydantic_settings import BaseSettings, SettingsConfigDict
6+
import tenacity
7+
8+
DEFAULT_RETRY_ARGS = {
9+
"wait": tenacity.wait_exponential(max=10),
10+
"stop": tenacity.stop_after_attempt(5),
11+
"reraise": True,
12+
}
13+
14+
15+
@dataclasses.dataclass
16+
class Endpoint:
17+
raw_value: str
18+
internal_netloc: str
19+
20+
def __add__(self, path: str) -> "Endpoint":
21+
return Endpoint(self.raw_value + path, self.internal_netloc)
22+
23+
def __str__(self) -> str:
24+
return self.raw_value
25+
26+
def value(self, *, use_internal_netloc: bool) -> str:
27+
if use_internal_netloc:
28+
fragments = list(urllib.parse.urlparse(self.raw_value))
29+
fragments[1] = self.internal_netloc
30+
return urllib.parse.urlunparse(fragments)
31+
else:
32+
return self.raw_value
33+
34+
35+
class Settings(BaseSettings):
36+
model_config = SettingsConfigDict(
37+
env_prefix="tests_",
38+
)
39+
40+
# iceberg catalog
41+
# The default values assume the docker-compose.yml in the infra/local has been used.
42+
# These are provided for the convenience of easily running a debugger without having
43+
# to set up remote debugging
44+
host_netloc: str = "localhost:50080"
45+
docker_netloc: str = "adp-router:50080"
46+
s3_access_key: str = "adpsuperuser"
47+
s3_secret_key: str = "adppassword"
48+
s3_bucket: str = "e2e-tests"
49+
s3_endpoint: str = "http://adp-router:59000"
50+
s3_region: str = "local-01"
51+
s3_path_style_access: bool = True
52+
openid_client_id: str = "machine-infra"
53+
openid_client_secret: str = "s3cr3t"
54+
openid_scope: str = "lakekeeper"
55+
project_id: str = "c4fcd44f-7ce7-4446-9f7c-dcc7ba76dd22"
56+
warehouse_name: str = "e2e_tests"
57+
58+
# trino
59+
trino_http_scheme: str = "https"
60+
trino_host: str = "localhost"
61+
trino_port: str = "58443"
62+
trino_user: str = "machine-infra"
63+
trino_password: str = "s3cr3t"
64+
65+
@property
66+
def lakekeeper_url(self) -> Endpoint:
67+
return Endpoint(f"http://{self.host_netloc}/iceberg", self.docker_netloc)
68+
69+
@property
70+
def openid_provider_uri(self) -> Endpoint:
71+
return Endpoint(
72+
f"http://{self.host_netloc}/auth/realms/analytics-data-platform", self.docker_netloc
73+
)
74+
75+
def storage_config(self) -> Dict[str, Any]:
76+
return {
77+
"warehouse-name": self.warehouse_name,
78+
"storage-credential": {
79+
"type": "s3",
80+
"credential-type": "access-key",
81+
"aws-access-key-id": self.s3_access_key,
82+
"aws-secret-access-key": self.s3_secret_key,
83+
},
84+
"storage-profile": {
85+
"type": "s3",
86+
"bucket": self.s3_bucket,
87+
"key-prefix": "",
88+
"assume-role-arn": "",
89+
"endpoint": self.s3_endpoint,
90+
"region": self.s3_region,
91+
"path-style-access": self.s3_path_style_access,
92+
"flavor": "s3-compat",
93+
"sts-enabled": False,
94+
},
95+
"delete-profile": {"type": "hard"},
96+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from collections.abc import MutableMapping
2+
from dataclasses import dataclass
3+
import os
4+
from typing import List
5+
6+
import dlt
7+
from dlt.common.configuration.providers import (
8+
ConfigProvider,
9+
EnvironProvider,
10+
)
11+
from dlt.common.destination import (
12+
TDestinationReferenceArg,
13+
)
14+
from dlt.common.runtime.run_context import RunContext
15+
16+
from .lakekeeper import Warehouse
17+
18+
19+
def configure_dlt_for_testing():
20+
"""Set up dlt for a testing environment"""
21+
22+
def initial_providers(self) -> List[ConfigProvider]:
23+
# Use only environmental variables to configure
24+
return [
25+
EnvironProvider(),
26+
]
27+
28+
os.environ["RUNTIME__DLTHUB_TELEMETRY"] = "false"
29+
RunContext.initial_providers = initial_providers # type: ignore[method-assign]
30+
31+
32+
@dataclass
33+
class PyIcebergDestinationTestConfiguration:
34+
"""Class for defining test setup for pyiceberg destination."""
35+
36+
warehouse: Warehouse
37+
destination: TDestinationReferenceArg = "elt_common.dlt_destinations.pyiceberg"
38+
39+
def setup(self, environ: MutableMapping = os.environ) -> None:
40+
"""Sets up environment variables for this destination configuration
41+
42+
Defaults to to os.environ
43+
"""
44+
server, server_settings = self.warehouse.server, self.warehouse.server.settings
45+
environ["DESTINATION__PYICEBERG__CREDENTIALS__URI"] = str(
46+
self.warehouse.server.catalog_endpoint()
47+
)
48+
environ["DESTINATION__PYICEBERG__CREDENTIALS__PROJECT_ID"] = str(
49+
self.warehouse.server.settings.project_id
50+
)
51+
environ.setdefault("DESTINATION__PYICEBERG__CREDENTIALS__WAREHOUSE", self.warehouse.name)
52+
environ.setdefault(
53+
"DESTINATION__PYICEBERG__CREDENTIALS__OAUTH2_SERVER_URI",
54+
str(server.token_endpoint),
55+
)
56+
environ.setdefault(
57+
"DESTINATION__PYICEBERG__CREDENTIALS__CLIENT_ID",
58+
server_settings.openid_client_id,
59+
)
60+
environ.setdefault(
61+
"DESTINATION__PYICEBERG__CREDENTIALS__CLIENT_SECRET",
62+
server_settings.openid_client_secret,
63+
)
64+
environ.setdefault(
65+
"DESTINATION__PYICEBERG__CREDENTIALS__SCOPE",
66+
server_settings.openid_scope,
67+
)
68+
environ.setdefault("DESTINATION__PYICEBERG__BUCKET_URL", self.warehouse.bucket_url)
69+
# Avoid collisons on table names when the same ones are created/deleted in quick
70+
# succession
71+
environ.setdefault(
72+
"DESTINATION__PYICEBERG__TABLE_LOCATION_LAYOUT",
73+
"{location_tag}/{dataset_name}/{table_name}",
74+
)
75+
76+
def setup_pipeline(
77+
self,
78+
pipeline_name: str,
79+
dataset_name: str = None,
80+
dev_mode: bool = False,
81+
**kwargs,
82+
) -> dlt.Pipeline:
83+
"""Convenience method to setup pipeline with this configuration"""
84+
self.setup()
85+
return dlt.pipeline(
86+
pipeline_name=pipeline_name,
87+
pipelines_dir=kwargs.pop("pipelines_dir", None),
88+
destination=self.destination,
89+
dataset_name=(dataset_name if dataset_name is not None else pipeline_name + "_data"),
90+
dev_mode=dev_mode,
91+
**kwargs,
92+
)
93+
94+
def clean_catalog(self):
95+
"""Clean the destination catalog of all namespaces and tables"""
96+
catalog = self.warehouse.connect()
97+
for ns_name in catalog.list_namespaces():
98+
tables = catalog.list_tables(ns_name)
99+
for qualified_table_name in tables:
100+
catalog.purge_table(qualified_table_name)
101+
catalog.drop_namespace(ns_name)
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""A collection of utilities to support testing against this library"""
2+
3+
import tempfile
4+
import time
5+
from typing import Generator
6+
import urllib.parse
7+
import warnings
8+
9+
from minio import Minio
10+
import pytest
11+
import requests
12+
import tenacity
13+
14+
from . import DEFAULT_RETRY_ARGS
15+
from .dlt import PyIcebergDestinationTestConfiguration
16+
from .lakekeeper import Settings, Server, Warehouse
17+
18+
19+
@pytest.fixture(scope="session")
20+
def settings() -> Settings:
21+
return Settings()
22+
23+
24+
@pytest.fixture(scope="session")
25+
def token_endpoint(settings: Settings) -> str:
26+
response = requests.get(str(settings.openid_provider_uri + "/.well-known/openid-configuration"))
27+
response.raise_for_status()
28+
return response.json()["token_endpoint"]
29+
30+
31+
@pytest.fixture(scope="session")
32+
def access_token(settings: Settings, token_endpoint: str) -> str:
33+
response = requests.post(
34+
token_endpoint,
35+
data={
36+
"grant_type": "client_credentials",
37+
"client_id": settings.openid_client_id,
38+
"client_secret": settings.openid_client_secret,
39+
"scope": settings.openid_scope,
40+
},
41+
)
42+
response.raise_for_status()
43+
return response.json()["access_token"]
44+
45+
46+
@pytest.fixture(scope="session")
47+
def server(settings: Settings, access_token: str) -> Server:
48+
return Server(access_token, settings)
49+
50+
51+
@pytest.fixture(scope="session")
52+
def warehouse(settings: Settings, server: Server) -> Generator:
53+
if not settings.warehouse_name:
54+
raise ValueError("Empty 'warehouse_name' is not allowed.")
55+
56+
storage_config = settings.storage_config()
57+
# Ensure bucket exists
58+
s3_hostname = urllib.parse.urlparse(storage_config["storage-profile"]["endpoint"]).netloc
59+
minio_client = Minio(
60+
endpoint=s3_hostname,
61+
access_key=storage_config["storage-credential"]["aws-access-key-id"],
62+
secret_key=storage_config["storage-credential"]["aws-secret-access-key"],
63+
secure=False,
64+
)
65+
bucket_name = storage_config["storage-profile"]["bucket"]
66+
if not minio_client.bucket_exists(bucket_name=bucket_name):
67+
minio_client.make_bucket(bucket_name=bucket_name)
68+
print(f"Bucket {bucket_name} created.")
69+
70+
warehouse = server.create_warehouse(
71+
settings.warehouse_name, server.settings.project_id, storage_config
72+
)
73+
print(f"Warehouse {warehouse.project_id} created.")
74+
try:
75+
yield warehouse
76+
finally:
77+
78+
@tenacity.retry(**DEFAULT_RETRY_ARGS)
79+
def _remove_bucket(bucket_name):
80+
minio_client.remove_bucket(bucket_name=bucket_name)
81+
82+
try:
83+
# Allow a brief pause for the test operations to complete
84+
time.sleep(1)
85+
server.purge_warehouse(warehouse)
86+
server.delete_warehouse(warehouse)
87+
_remove_bucket(bucket_name)
88+
89+
except RuntimeError as exc:
90+
warnings.warn(
91+
f"Error deleting test warehouse '{str(warehouse.project_id)}'. It may need to be removed manually."
92+
)
93+
warnings.warn(f"Error:\n{str(exc)}")
94+
95+
96+
@pytest.fixture
97+
def destination_config(warehouse: Warehouse):
98+
destination_config = PyIcebergDestinationTestConfiguration(warehouse)
99+
try:
100+
yield destination_config
101+
finally:
102+
destination_config.clean_catalog()
103+
104+
105+
@pytest.fixture
106+
def pipelines_dir():
107+
with tempfile.TemporaryDirectory() as tmp_dir:
108+
yield tmp_dir

0 commit comments

Comments
 (0)