Skip to content

Commit 54fdefa

Browse files
committed
feat: add local and gcp path IO adapter for id_mapping; add unit test for id_registrar
1 parent 70ef08c commit 54fdefa

4 files changed

Lines changed: 125 additions & 18 deletions

File tree

data_pipeline/contract/contract_executor.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,14 @@ def apply_contract(
130130
valid_ids = set(df.get_column("order_id"))
131131

132132
temp_path = run_context.contracted_path / "id_mapping"
133-
storage_mapping = Path(run_context.storage_mapping_path)
133+
storage_mapping_str = run_context.storage_mapping_path
134+
135+
# Switch between local and gcp path
136+
storage_mapping = (
137+
storage_mapping_str
138+
if storage_mapping_str.startswith("gs://")
139+
else Path(storage_mapping_str)
140+
)
134141

135142
df = id_mapping(
136143
df=df,
@@ -146,6 +153,8 @@ def apply_contract(
146153
report["status"] = "failed"
147154
report["errors"].append("Export failed")
148155

156+
return report, invalid_ids, valid_ids
157+
149158
report["status"] = "success"
150159

151160
return report, invalid_ids, valid_ids

data_pipeline/contract/id_registrar.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import polars as pl
66
from pathlib import Path
7-
import shutil
7+
from data_pipeline.shared.storage_adapter import promote_new_mapping_files
88

99
ID_COLUMNS_TO_MAP = {
1010
"df_orders": ["order_id", "customer_id"],
@@ -87,7 +87,7 @@ def id_mapping(
8787
table_name: str,
8888
mapping_dict: dict,
8989
runtime_dir: Path,
90-
destination: Path,
90+
destination: Path | str,
9191
) -> pl.LazyFrame:
9292
"""
9393
Orchestrates the two-phase transformation of UUID strings to persistent Integer keys.
@@ -113,30 +113,39 @@ def id_mapping(
113113

114114
for id_column in cols_to_map:
115115
mapping_filename = f"{id_column}_mapping.parquet"
116-
storage_path = destination / mapping_filename
117116
temp_path = runtime_dir / mapping_filename
118117

119-
# Check if mapping exists in storage else create
120-
target_path = storage_path if storage_path.exists() else temp_path
118+
# When destination is a GCS URI
119+
if str(destination).startswith("gs://"):
120+
target_path = temp_path
121+
else:
122+
storage_path = destination / mapping_filename # type: ignore
123+
target_path = storage_path if storage_path.exists() else temp_path
121124

122125
if not target_path.parent.exists():
123126
target_path.parent.mkdir(parents=True, exist_ok=True)
124127

125128
map_uuid_to_int(df, target_path, id_column)
126129

127-
# Promote new mapping files from runtime directory to central storage
128-
if runtime_dir.exists():
129-
destination.mkdir(parents=True, exist_ok=True)
130-
for file in runtime_dir.glob("*_mapping.parquet"):
131-
shutil.copy2(file, destination)
130+
# Promote new mapping to storage
131+
promote_new_mapping_files(runtime_dir=runtime_dir, destination=destination)
132132

133133
lf_mapped = df.lazy()
134134

135135
for id_column in cols_to_map:
136136
mapping_filename = f"{id_column}_mapping.parquet"
137-
storage_path = destination / mapping_filename
138137

139-
# Enrich DataFrame with integer surrogates from the registry
138+
# Switch between local and gcp IO
139+
if str(destination).startswith("gs://"):
140+
destination_str = str(destination)
141+
if not destination_str.endswith("/"):
142+
destination_str += "/"
143+
144+
storage_path = f"{destination_str}{mapping_filename}"
145+
else:
146+
storage_path = str(destination / mapping_filename) # type: ignore
147+
148+
# Enriched existing id registry with new mapping
140149
registry_lf = pl.scan_parquet(storage_path)
141150
lf_mapped = lf_mapped.join(registry_lf, on=id_column, how="left")
142151

data_pipeline/shared/storage_adapter.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ def upload_contracted_directory(run_context: RunContext) -> None:
141141
142142
Contract:
143143
- Synchronizes the local 'contracted/' directory to 'storage_contracted_path'.
144+
- Excludes the 'id_mapping' directory to prevent cross-contamination.
144145
- Purpose: Archives newly cleaned data for delta accumulation and historical lineage.
145146
"""
146147

@@ -167,7 +168,7 @@ def upload_contracted_directory(run_context: RunContext) -> None:
167168
bucket = client.bucket(bucket_name)
168169

169170
for file in source.rglob("*"):
170-
if file.is_file():
171+
if file.is_file() and "id_mapping" not in file.parts:
171172

172173
blob = bucket.blob(f"{prefix}/{file.relative_to(source)}")
173174
blob.upload_from_filename(file)
@@ -204,3 +205,36 @@ def download_contracted_datasets(run_context: RunContext) -> None:
204205
target.parent.mkdir(parents=True, exist_ok=True)
205206

206207
blob.download_to_filename(target)
208+
209+
210+
def promote_new_mapping_files(runtime_dir: Path, destination: Path | str) -> None:
211+
"""
212+
Synchronizes new UUID mapping files from the local temporary directory to central storage.
213+
214+
Contract:
215+
- Identifies all '*_mapping.parquet' files in the local 'runtime_dir'.
216+
- Promotes them to the persistent 'destination' (local directory or GCS bucket).
217+
"""
218+
219+
if not runtime_dir.exists():
220+
return
221+
222+
destination_str = str(destination).replace("\\", "/")
223+
224+
# Local filesystem case
225+
if not destination_str.startswith("gs://"):
226+
Path(destination).mkdir(parents=True, exist_ok=True)
227+
for file in runtime_dir.glob("*_mapping.parquet"):
228+
shutil.copy2(file, Path(destination))
229+
return
230+
231+
# GCS case
232+
client = storage.Client()
233+
bucket_name, prefix = _split_gcs_path(destination_str)
234+
bucket = client.bucket(bucket_name)
235+
236+
for file in runtime_dir.glob("*_mapping.parquet"):
237+
if file.is_file():
238+
# Create a blob with the target filename in the bucket
239+
blob = bucket.blob(f"{prefix}/{file.name}")
240+
blob.upload_from_filename(str(file))

tests/test_contract_stage.py

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
enforce_parent_reference,
1616
enforce_schema,
1717
)
18+
from data_pipeline.contract.id_registrar import map_uuid_to_int, id_mapping
1819

1920
# ------------------------------------------------------------
2021
# FIXTURES
@@ -156,6 +157,64 @@ def test_enforce_schema():
156157
assert filtered["state"].dtype == pl.Categorical
157158

158159

160+
# ------------------------------------------------------------
161+
# ID REGISTRAR UNIT TESTS
162+
# ------------------------------------------------------------
163+
164+
165+
def test_map_uuid_to_int_new_file(tmp_path):
166+
df = pl.DataFrame({"user_id": ["u1", "u2", "u3"]})
167+
mapping_file = tmp_path / "user_id_mapping.parquet"
168+
169+
map_uuid_to_int(df, mapping_file, "user_id")
170+
171+
assert mapping_file.exists()
172+
mapping_df = pl.read_parquet(mapping_file)
173+
assert mapping_df.height == 3
174+
assert "user_id" in mapping_df.columns
175+
assert "user_id_int" in mapping_df.columns
176+
assert mapping_df["user_id_int"].to_list() == [1, 2, 3]
177+
assert mapping_df["user_id_int"].dtype == pl.UInt32
178+
179+
180+
def test_map_uuid_to_int_update_existing(tmp_path):
181+
mapping_file = tmp_path / "user_id_mapping.parquet"
182+
initial_df = pl.DataFrame({"user_id": ["u1", "u2"], "user_id_int": [1, 2]}).cast(
183+
{"user_id_int": pl.UInt32}
184+
)
185+
initial_df.write_parquet(mapping_file)
186+
187+
new_df = pl.DataFrame({"user_id": ["u2", "u3", "u4"]})
188+
map_uuid_to_int(new_df, mapping_file, "user_id")
189+
190+
mapping_df = pl.read_parquet(mapping_file).sort("user_id_int")
191+
assert mapping_df.height == 4
192+
assert set(mapping_df["user_id"].to_list()) == {"u1", "u2", "u3", "u4"}
193+
assert mapping_df["user_id_int"].to_list() == [1, 2, 3, 4]
194+
195+
196+
def test_id_mapping_orchestration(tmp_path):
197+
runtime_dir = tmp_path / "runtime"
198+
destination = tmp_path / "destination"
199+
runtime_dir.mkdir()
200+
destination.mkdir()
201+
202+
df = pl.DataFrame({"order_id": ["o1", "o2"], "customer_id": ["c1", "c2"]})
203+
204+
mapping_dict = {"df_orders": ["order_id", "customer_id"]}
205+
206+
lf_mapped = id_mapping(df, "df_orders", mapping_dict, runtime_dir, destination)
207+
result_df = lf_mapped.collect()
208+
209+
assert "order_id_int" in result_df.columns
210+
assert "customer_id_int" in result_df.columns
211+
assert result_df.height == 2
212+
213+
# Check if mapping files were promoted to destination
214+
assert (destination / "order_id_mapping.parquet").exists()
215+
assert (destination / "customer_id_mapping.parquet").exists()
216+
217+
159218
# ------------------------------------------------------------
160219
# EXECUTOR INTEGRATION TESTS
161220
# ------------------------------------------------------------
@@ -170,7 +229,6 @@ def test_apply_contract_orders_success(tmp_path, sample_orders_df):
170229
run_context.raw_snapshot_path / f"df_orders_{suffix}.csv"
171230
)
172231

173-
# New 3-tuple return signature
174232
report, inv_ids, val_ids = apply_contract(run_context, "df_orders")
175233

176234
assert report["status"] == "success"
@@ -186,7 +244,6 @@ def test_apply_contract_cascade_and_valid_propagation(
186244
run_context = RunContext.create(base=tmp_path, storage=tmp_path / "storage")
187245
run_context.initialize_directories()
188246

189-
# o1: valid, o2: unparsable, o3: impossible
190247
sample_orders_df = sample_orders_df.with_columns(
191248
pl.when(pl.col("order_id") == "o2")
192249
.then(pl.lit("garbage"))
@@ -206,13 +263,11 @@ def test_apply_contract_cascade_and_valid_propagation(
206263
run_context.raw_snapshot_path / f"df_payments_{suffix}.csv"
207264
)
208265

209-
# 1. Process Orders
210266
rep_o, inv_o, val_o = apply_contract(run_context, "df_orders")
211267
assert "o2" in inv_o # unparsable
212268
assert "o3" in inv_o # impossible
213269
assert "o1" in val_o # only one valid
214270

215-
# 2. Process Payments (should cascade drop o2, o3 and only keep o1)
216271
rep_p, inv_p, val_p = apply_contract(
217272
run_context, "df_payments", invalid_order_ids=inv_o, valid_order_ids=val_o
218273
)

0 commit comments

Comments
 (0)