📦 deps(thirdparty): update snapshots

This commit is contained in:
ci[bot]
2026-06-23 16:03:00 +00:00
parent c4c6a41c21
commit 59e15f8999
557 changed files with 10501 additions and 3168 deletions
@@ -0,0 +1,66 @@
"""Path guards for local Monte Carlo template manifests."""
from __future__ import annotations
import json
import os
from pathlib import Path
def _allow_external_paths() -> bool:
return os.getenv("MCD_ALLOW_EXTERNAL_PATHS", "").lower() in {"1", "true", "yes"}
def _is_relative_to(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
def _resolve_local_path(raw_path: str, *, expect_file: bool = False, create_parent: bool = False) -> Path:
value = str(raw_path).strip()
if not value or "\0" in value:
raise ValueError("Path must be a non-empty filesystem path")
base = Path.cwd().resolve()
candidate = Path(value).expanduser()
resolved = (candidate if candidate.is_absolute() else base / candidate).resolve()
if not _allow_external_paths() and not _is_relative_to(resolved, base):
raise ValueError(f"Path must stay under the current working directory: {raw_path!r}")
if expect_file and not resolved.is_file():
raise FileNotFoundError(f"Input file not found: {resolved}")
if create_parent:
resolved.parent.mkdir(parents=True, exist_ok=True)
return resolved
def safe_input_json_path(raw_path: str) -> Path:
path = _resolve_local_path(raw_path, expect_file=True)
if path.suffix.lower() != ".json":
raise ValueError(f"Input manifest must be a .json file: {path}")
return path
def safe_output_json_path(raw_path: str) -> Path:
path = _resolve_local_path(raw_path, create_parent=True)
if path.suffix.lower() != ".json":
raise ValueError(f"Output manifest must be a .json file: {path}")
return path
def safe_existing_directory(raw_path: str) -> Path:
path = _resolve_local_path(raw_path)
if not path.is_dir():
raise NotADirectoryError(f"Directory not found: {path}")
return path
def read_json_file(raw_path: str):
with safe_input_json_path(raw_path).open() as fh:
return json.load(fh)
def write_json_file(raw_path: str, payload, *, indent: int = 2, default=None) -> None:
with safe_output_json_path(raw_path).open("w") as fh:
json.dump(payload, fh, indent=indent, default=default)
@@ -14,8 +14,9 @@ from __future__ import annotations
import argparse
import os
from collect_metadata import collect
from collect_metadata import _require_bq_identifier, collect
from push_metadata import push
from _safe_paths import safe_output_json_path
def main() -> None:
@@ -49,21 +50,28 @@ def main() -> None:
if missing:
parser.error(f"Missing required push arguments/env vars: {missing}")
manifest_path = str(safe_output_json_path(args.manifest_file))
push_result_path = str(safe_output_json_path(args.push_result_file))
args.project_id = _require_bq_identifier(args.project_id, "project_id")
args.datasets = [_require_bq_identifier(d, "dataset") for d in args.datasets or []] or None
args.tables = [_require_bq_identifier(t, "table") for t in args.tables or []] or None
collect(
project_id=args.project_id,
datasets=args.datasets,
tables=args.tables,
only_freshness_and_volume=args.only_freshness_and_volume,
output_file=args.manifest_file,
output_file=manifest_path,
)
push(
input_file=args.manifest_file,
input_file=manifest_path,
resource_uuid=args.resource_uuid,
key_id=args.key_id,
key_token=args.key_token,
batch_size=args.batch_size,
output_file=args.push_result_file,
output_file=push_result_path,
)
@@ -15,6 +15,7 @@ import os
from collect_query_logs import LOOKBACK_HOURS, LOOKBACK_LAG_HOURS, collect
from push_query_logs import push
from _safe_paths import safe_output_json_path
def main() -> None:
@@ -43,20 +44,23 @@ def main() -> None:
if missing:
parser.error(f"Missing required push arguments/env vars: {missing}")
manifest_path = str(safe_output_json_path(args.manifest_file))
push_result_path = str(safe_output_json_path(args.push_result_file))
collect(
project_id=args.project_id,
lookback_hours=args.lookback_hours,
lookback_lag_hours=args.lookback_lag_hours,
output_file=args.manifest_file,
output_file=manifest_path,
)
push(
input_file=args.manifest_file,
input_file=manifest_path,
resource_uuid=args.resource_uuid,
key_id=args.key_id,
key_token=args.key_token,
batch_size=args.batch_size,
output_file=args.push_result_file,
output_file=push_result_path,
)
@@ -26,14 +26,24 @@ import argparse
import json
import logging
import os
import re
from datetime import datetime, timezone
from google.cloud import bigquery
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, write_json_file
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
RESOURCE_TYPE = "bigquery"
_BQ_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9_-]+$")
def _require_bq_identifier(value: str, field: str) -> str:
value = str(value).strip()
if not value or not _BQ_IDENTIFIER_RE.fullmatch(value):
raise ValueError(f"Invalid BigQuery {field}: {value!r}")
return value
# BigQuery type → Monte Carlo canonical type
BQ_TYPE_MAP: dict[str, str] = {
@@ -71,16 +81,20 @@ def _fetch_iceberg_tables(
tables: list[str] | None = None,
) -> list[dict]:
"""Query TABLE_STORAGE for BigLake (Iceberg) tables."""
project_id = _require_bq_identifier(project_id, "project_id")
datasets = [_require_bq_identifier(d, "dataset") for d in datasets or []] or None
tables = [_require_bq_identifier(t, "table") for t in tables or []] or None
conditions = [
"managed_table_type = 'BIGLAKE'",
"deleted = FALSE",
]
query_parameters = []
if datasets:
ds_list = ", ".join(f"'{d}'" for d in datasets)
conditions.append(f"table_schema IN ({ds_list})")
conditions.append("table_schema IN UNNEST(@datasets)")
query_parameters.append(bigquery.ArrayQueryParameter("datasets", "STRING", datasets))
if tables:
tbl_list = ", ".join(f"'{t}'" for t in tables)
conditions.append(f"table_name IN ({tbl_list})")
conditions.append("table_name IN UNNEST(@tables)")
query_parameters.append(bigquery.ArrayQueryParameter("tables", "STRING", tables))
where = " AND ".join(conditions)
query = f"""
@@ -96,7 +110,8 @@ def _fetch_iceberg_tables(
ORDER BY table_schema, table_name
"""
log.info("Querying TABLE_STORAGE for Iceberg tables ...")
rows = list(client.query(query).result())
job_config = bigquery.QueryJobConfig(query_parameters=query_parameters)
rows = list(client.query(query, job_config=job_config).result())
log.info("Found %d Iceberg table(s).", len(rows))
return [dict(row) for row in rows]
@@ -108,18 +123,24 @@ def _fetch_columns(
table_name: str,
) -> list[dict]:
"""Fetch column metadata for a specific table."""
project_id = _require_bq_identifier(project_id, "project_id")
dataset = _require_bq_identifier(dataset, "dataset")
table_name = _require_bq_identifier(table_name, "table")
query = f"""
SELECT column_name, data_type, ordinal_position, is_nullable, column_default
FROM `{project_id}.{dataset}.INFORMATION_SCHEMA.COLUMNS`
WHERE table_name = '{table_name}'
WHERE table_name = @table_name
ORDER BY ordinal_position
"""
job_config = bigquery.QueryJobConfig(
query_parameters=[bigquery.ScalarQueryParameter("table_name", "STRING", table_name)]
)
return [
{
"name": row["column_name"],
"type": map_bq_type(row["data_type"]),
}
for row in client.query(query).result()
for row in client.query(query, job_config=job_config).result()
]
@@ -155,6 +176,9 @@ def collect(
omits fields from the manifest. Use this for periodic hourly pushes
after the initial full metadata push.
"""
project_id = _require_bq_identifier(project_id, "project_id")
datasets = [_require_bq_identifier(d, "dataset") for d in datasets or []] or None
tables = [_require_bq_identifier(t, "table") for t in tables or []] or None
client = bigquery.Client(project=project_id) # ← SUBSTITUTE: adjust auth if needed
if only_freshness_and_volume:
@@ -200,8 +224,7 @@ def collect(
"collected_at": datetime.now(timezone.utc).isoformat(),
"assets": assets,
}
with open(output_file, "w") as fh:
json.dump(manifest, fh, indent=2)
write_json_file(output_file, manifest)
log.info("Manifest written to %s (%d assets)", output_file, len(assets))
return manifest
@@ -23,6 +23,7 @@ import os
from datetime import datetime, timedelta, timezone
from google.cloud import bigquery
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, write_json_file
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
@@ -113,8 +114,7 @@ def collect(
"query_log_count": len(entries),
"queries": entries,
}
with open(output_file, "w") as fh:
json.dump(manifest, fh, indent=2)
write_json_file(output_file, manifest)
log.info("Query log manifest written to %s", output_file)
return manifest
@@ -33,6 +33,7 @@ from pycarlo.features.ingestion.models import (
AssetVolume,
RelationalAsset,
)
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, read_json_file, write_json_file
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
@@ -92,8 +93,7 @@ def push(
"""Read a metadata manifest and push assets to Monte Carlo in batches."""
endpoint = _ENDPOINT
log.info("Using endpoint: %s", endpoint)
with open(input_file) as fh:
manifest = json.load(fh)
manifest = read_json_file(input_file)
asset_dicts = manifest.get("assets", [])
resource_type = manifest.get("resource_type", RESOURCE_TYPE)
@@ -147,8 +147,7 @@ def push(
"batch_count": total_batches,
"batch_size": batch_size,
}
with open(output_file, "w") as fh:
json.dump(push_result, fh, indent=2)
write_json_file(output_file, push_result)
log.info("Push result written to %s", output_file)
return push_result
@@ -32,6 +32,7 @@ from dateutil.parser import isoparse
from pycarlo.core import Client, Session
from pycarlo.features.ingestion import IngestionService
from pycarlo.features.ingestion.models import QueryLogEntry
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, read_json_file, write_json_file
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
@@ -95,8 +96,7 @@ def push(
endpoint = _ENDPOINT
log.info("Using endpoint: %s", endpoint)
with open(input_file) as fh:
manifest = json.load(fh)
manifest = read_json_file(input_file)
queries = manifest.get("queries", [])
log_type = manifest.get("log_type", LOG_TYPE)
@@ -114,8 +114,7 @@ def push(
"batch_count": 0,
"batch_size": batch_size,
}
with open(output_file, "w") as fh:
json.dump(push_result, fh, indent=2)
write_json_file(output_file, push_result)
return push_result
batches = [entries[i : i + batch_size] for i in range(0, len(entries), batch_size)]
@@ -165,8 +164,7 @@ def push(
"batch_count": total_batches,
"batch_size": batch_size,
}
with open(output_file, "w") as fh:
json.dump(push_result, fh, indent=2)
write_json_file(output_file, push_result)
log.info("Push result written to %s", output_file)
return push_result
@@ -0,0 +1,66 @@
"""Path guards for local Monte Carlo template manifests."""
from __future__ import annotations
import json
import os
from pathlib import Path
def _allow_external_paths() -> bool:
return os.getenv("MCD_ALLOW_EXTERNAL_PATHS", "").lower() in {"1", "true", "yes"}
def _is_relative_to(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
def _resolve_local_path(raw_path: str, *, expect_file: bool = False, create_parent: bool = False) -> Path:
value = str(raw_path).strip()
if not value or "\0" in value:
raise ValueError("Path must be a non-empty filesystem path")
base = Path.cwd().resolve()
candidate = Path(value).expanduser()
resolved = (candidate if candidate.is_absolute() else base / candidate).resolve()
if not _allow_external_paths() and not _is_relative_to(resolved, base):
raise ValueError(f"Path must stay under the current working directory: {raw_path!r}")
if expect_file and not resolved.is_file():
raise FileNotFoundError(f"Input file not found: {resolved}")
if create_parent:
resolved.parent.mkdir(parents=True, exist_ok=True)
return resolved
def safe_input_json_path(raw_path: str) -> Path:
path = _resolve_local_path(raw_path, expect_file=True)
if path.suffix.lower() != ".json":
raise ValueError(f"Input manifest must be a .json file: {path}")
return path
def safe_output_json_path(raw_path: str) -> Path:
path = _resolve_local_path(raw_path, create_parent=True)
if path.suffix.lower() != ".json":
raise ValueError(f"Output manifest must be a .json file: {path}")
return path
def safe_existing_directory(raw_path: str) -> Path:
path = _resolve_local_path(raw_path)
if not path.is_dir():
raise NotADirectoryError(f"Directory not found: {path}")
return path
def read_json_file(raw_path: str):
with safe_input_json_path(raw_path).open() as fh:
return json.load(fh)
def write_json_file(raw_path: str, payload, *, indent: int = 2, default=None) -> None:
with safe_output_json_path(raw_path).open("w") as fh:
json.dump(payload, fh, indent=indent, default=default)
@@ -20,8 +20,9 @@ from __future__ import annotations
import argparse
import os
from collect_lineage import collect, LOOKBACK_HOURS
from collect_lineage import LOOKBACK_HOURS, _bounded_int, _require_bq_identifier, collect
from push_lineage import push, _BATCH_SIZE
from _safe_paths import safe_output_json_path
def main() -> None:
@@ -47,22 +48,29 @@ def main() -> None:
if missing:
parser.error(f"Missing required arguments/env vars: {missing}")
output_path = str(safe_output_json_path(args.output_file))
push_result_path = str(safe_output_json_path(args.push_result_file))
args.project_id = _require_bq_identifier(args.project_id, "project_id")
args.region = _require_bq_identifier(args.region, "region")
args.lookback_hours = _bounded_int(args.lookback_hours, "lookback_hours", minimum=1, maximum=24 * 31)
# Step 1: Collect
collect(
project_id=args.project_id,
region=args.region,
lookback_hours=args.lookback_hours,
output_file=args.output_file,
output_file=output_path,
)
# Step 2: Push
push(
input_file=args.output_file,
input_file=output_path,
resource_uuid=args.resource_uuid,
key_id=args.key_id,
key_token=args.key_token,
batch_size=args.batch_size,
output_file=args.push_result_file,
output_file=push_result_path,
)
@@ -22,6 +22,7 @@ import os
from collect_metadata import collect
from push_metadata import push, _BATCH_SIZE
from _safe_paths import safe_output_json_path
def main() -> None:
@@ -44,20 +45,23 @@ def main() -> None:
if missing:
parser.error(f"Missing required arguments/env vars: {missing}")
output_path = str(safe_output_json_path(args.output_file))
push_result_path = str(safe_output_json_path(args.push_result_file))
# Step 1: Collect
collect(
project_id=args.project_id,
output_file=args.output_file,
output_file=output_path,
)
# Step 2: Push
push(
input_file=args.output_file,
input_file=output_path,
resource_uuid=args.resource_uuid,
key_id=args.key_id,
key_token=args.key_token,
batch_size=args.batch_size,
output_file=args.push_result_file,
output_file=push_result_path,
)
@@ -22,6 +22,7 @@ import os
from collect_query_logs import collect, LOOKBACK_HOURS, LOOKBACK_LAG_HOURS
from push_query_logs import push, _BATCH_SIZE
from _safe_paths import safe_output_json_path
def main() -> None:
@@ -47,22 +48,25 @@ def main() -> None:
if missing:
parser.error(f"Missing required arguments/env vars: {missing}")
output_path = str(safe_output_json_path(args.output_file))
push_result_path = str(safe_output_json_path(args.push_result_file))
# Step 1: Collect
collect(
project_id=args.project_id,
lookback_hours=args.lookback_hours,
lookback_lag_hours=args.lookback_lag_hours,
output_file=args.output_file,
output_file=output_path,
)
# Step 2: Push
push(
input_file=args.output_file,
input_file=output_path,
resource_uuid=args.resource_uuid,
key_id=args.key_id,
key_token=args.key_token,
batch_size=args.batch_size,
output_file=args.push_result_file,
output_file=push_result_path,
)
@@ -29,12 +29,28 @@ import re
from datetime import datetime, timedelta, timezone
from google.cloud import bigquery
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, write_json_file
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
RESOURCE_TYPE = "bigquery"
LOOKBACK_HOURS = int(os.getenv("LOOKBACK_HOURS", "24")) # ← SUBSTITUTE: adjust lookback window
_BQ_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9_-]+$")
def _require_bq_identifier(value: str, field: str) -> str:
value = str(value).strip()
if not value or not _BQ_IDENTIFIER_RE.fullmatch(value):
raise ValueError(f"Invalid BigQuery {field}: {value!r}")
return value
def _bounded_int(value: int, field: str, *, minimum: int, maximum: int) -> int:
value = int(value)
if value < minimum or value > maximum:
raise ValueError(f"{field} must be between {minimum} and {maximum}")
return value
# Regex patterns to detect CTAS and INSERT INTO SELECT in BigQuery SQL
_CTAS_PATTERN = re.compile(
@@ -65,6 +81,8 @@ def _collect_schema_link_lineage(
region: str,
) -> list[dict]:
"""Collect cross-project lineage from INFORMATION_SCHEMA.SCHEMATA_LINKS."""
project_id = _require_bq_identifier(project_id, "project_id")
region = _require_bq_identifier(region, "region")
query = f"""
SELECT
CATALOG_NAME AS source_project,
@@ -103,6 +121,8 @@ def _collect_query_lineage(
lookback_hours: int,
) -> list[dict]:
"""Derive lineage by parsing CTAS/INSERT patterns in job query history."""
project_id = _require_bq_identifier(project_id, "project_id")
lookback_hours = _bounded_int(lookback_hours, "lookback_hours", minimum=1, maximum=24 * 31)
end_dt = datetime.now(timezone.utc)
start_dt = end_dt - timedelta(hours=lookback_hours)
@@ -161,6 +181,9 @@ def collect(
Returns the manifest dict.
"""
project_id = _require_bq_identifier(project_id, "project_id")
region = _require_bq_identifier(region, "region")
lookback_hours = _bounded_int(lookback_hours, "lookback_hours", minimum=1, maximum=24 * 31)
bq_client = bigquery.Client(project=project_id)
log.info("Collecting lineage from project %s ...", project_id)
@@ -180,8 +203,7 @@ def collect(
"query_derived_edges": len(query_edges),
"edges": all_edges,
}
with open(output_file, "w") as fh:
json.dump(manifest, fh, indent=2)
write_json_file(output_file, manifest)
log.info("Lineage manifest written to %s", output_file)
return manifest
@@ -24,6 +24,7 @@ import os
from datetime import datetime, timezone
from google.cloud import bigquery
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, write_json_file
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
@@ -131,8 +132,7 @@ def collect(
"collected_at": datetime.now(timezone.utc).isoformat(),
"assets": assets,
}
with open(output_file, "w") as fh:
json.dump(manifest, fh, indent=2)
write_json_file(output_file, manifest)
log.info("Asset manifest written to %s", output_file)
return manifest
@@ -26,6 +26,7 @@ import os
from datetime import datetime, timedelta, timezone
from google.cloud import bigquery
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, write_json_file
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
@@ -130,8 +131,7 @@ def collect(
"query_log_count": len(entries),
"queries": entries,
}
with open(output_file, "w") as fh:
json.dump(manifest, fh, indent=2)
write_json_file(output_file, manifest)
log.info("Query log manifest written to %s", output_file)
return manifest
@@ -30,6 +30,7 @@ from pycarlo.features.ingestion.models import (
LineageAssetRef,
LineageEvent,
)
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, read_json_file, write_json_file
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
@@ -83,8 +84,7 @@ def push(
Returns a result dict with invocation IDs for each batch.
"""
with open(input_file) as fh:
manifest = json.load(fh)
manifest = read_json_file(input_file)
edges = manifest.get("edges", [])
resource_type = manifest.get("resource_type", RESOURCE_TYPE)
@@ -102,8 +102,7 @@ def push(
"batch_count": 0,
"batch_size": batch_size,
}
with open(output_file, "w") as fh:
json.dump(push_result, fh, indent=2)
write_json_file(output_file, push_result)
return push_result
# Split into batches
@@ -155,8 +154,7 @@ def push(
"batch_count": total_batches,
"batch_size": batch_size,
}
with open(output_file, "w") as fh:
json.dump(push_result, fh, indent=2)
write_json_file(output_file, push_result)
log.info("Push result written to %s", output_file)
return push_result
@@ -33,6 +33,7 @@ from pycarlo.features.ingestion.models import (
AssetVolume,
RelationalAsset,
)
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, read_json_file, write_json_file
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
@@ -95,8 +96,7 @@ def push(
Returns a result dict with invocation IDs for each batch.
"""
with open(input_file) as fh:
manifest = json.load(fh)
manifest = read_json_file(input_file)
asset_dicts = manifest.get("assets", [])
resource_type = manifest.get("resource_type", RESOURCE_TYPE)
@@ -150,8 +150,7 @@ def push(
"batch_count": total_batches,
"batch_size": batch_size,
}
with open(output_file, "w") as fh:
json.dump(push_result, fh, indent=2)
write_json_file(output_file, push_result)
log.info("Push result written to %s", output_file)
return push_result
@@ -28,6 +28,7 @@ from dateutil.parser import isoparse
from pycarlo.core import Client, Session
from pycarlo.features.ingestion import IngestionService
from pycarlo.features.ingestion.models import QueryLogEntry
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, read_json_file, write_json_file
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
@@ -94,8 +95,7 @@ def push(
Returns a result dict with invocation IDs for each batch.
"""
with open(input_file) as fh:
manifest = json.load(fh)
manifest = read_json_file(input_file)
queries = manifest.get("queries", [])
log_type = manifest.get("log_type", LOG_TYPE)
@@ -113,8 +113,7 @@ def push(
"batch_count": 0,
"batch_size": batch_size,
}
with open(output_file, "w") as fh:
json.dump(push_result, fh, indent=2)
write_json_file(output_file, push_result)
return push_result
# Split into batches
@@ -164,8 +163,7 @@ def push(
"batch_count": total_batches,
"batch_size": batch_size,
}
with open(output_file, "w") as fh:
json.dump(push_result, fh, indent=2)
write_json_file(output_file, push_result)
log.info("Push result written to %s", output_file)
return push_result
@@ -0,0 +1,66 @@
"""Path guards for local Monte Carlo template manifests."""
from __future__ import annotations
import json
import os
from pathlib import Path
def _allow_external_paths() -> bool:
return os.getenv("MCD_ALLOW_EXTERNAL_PATHS", "").lower() in {"1", "true", "yes"}
def _is_relative_to(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
def _resolve_local_path(raw_path: str, *, expect_file: bool = False, create_parent: bool = False) -> Path:
value = str(raw_path).strip()
if not value or "\0" in value:
raise ValueError("Path must be a non-empty filesystem path")
base = Path.cwd().resolve()
candidate = Path(value).expanduser()
resolved = (candidate if candidate.is_absolute() else base / candidate).resolve()
if not _allow_external_paths() and not _is_relative_to(resolved, base):
raise ValueError(f"Path must stay under the current working directory: {raw_path!r}")
if expect_file and not resolved.is_file():
raise FileNotFoundError(f"Input file not found: {resolved}")
if create_parent:
resolved.parent.mkdir(parents=True, exist_ok=True)
return resolved
def safe_input_json_path(raw_path: str) -> Path:
path = _resolve_local_path(raw_path, expect_file=True)
if path.suffix.lower() != ".json":
raise ValueError(f"Input manifest must be a .json file: {path}")
return path
def safe_output_json_path(raw_path: str) -> Path:
path = _resolve_local_path(raw_path, create_parent=True)
if path.suffix.lower() != ".json":
raise ValueError(f"Output manifest must be a .json file: {path}")
return path
def safe_existing_directory(raw_path: str) -> Path:
path = _resolve_local_path(raw_path)
if not path.is_dir():
raise NotADirectoryError(f"Directory not found: {path}")
return path
def read_json_file(raw_path: str):
with safe_input_json_path(raw_path).open() as fh:
return json.load(fh)
def write_json_file(raw_path: str, payload, *, indent: int = 2, default=None) -> None:
with safe_output_json_path(raw_path).open("w") as fh:
json.dump(payload, fh, indent=indent, default=default)
@@ -30,6 +30,7 @@ import os
from collect_lineage import LOOKBACK_DAYS, collect
from push_lineage import DEFAULT_BATCH_SIZE, push
from _safe_paths import safe_output_json_path
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
@@ -57,19 +58,21 @@ def main() -> None:
if missing:
parser.error(f"Missing required arguments/env vars: {missing}")
manifest_path = str(safe_output_json_path(args.manifest))
log.info("Step 1: Collecting lineage …")
collect(
host=args.host,
http_path=args.http_path,
token=args.token,
manifest_path=args.manifest,
manifest_path=manifest_path,
include_column_lineage=args.column_lineage,
lookback_days=args.lookback_days,
)
log.info("Step 2: Pushing lineage to Monte Carlo …")
push(
manifest_path=args.manifest,
manifest_path=manifest_path,
resource_uuid=args.resource_uuid,
key_id=args.key_id,
key_token=args.key_token,
@@ -27,8 +27,9 @@ import argparse
import logging
import os
from collect_metadata import collect
from collect_metadata import _quote_identifier, collect
from push_metadata import DEFAULT_BATCH_SIZE, push
from _safe_paths import safe_output_json_path
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
@@ -52,18 +53,22 @@ def main() -> None:
if missing:
parser.error(f"Missing required arguments/env vars: {missing}")
manifest_path = str(safe_output_json_path(args.manifest))
_quote_identifier(args.catalog)
log.info("Step 1: Collecting metadata …")
collect(
host=args.host,
http_path=args.http_path,
token=args.token,
catalog=args.catalog,
manifest_path=args.manifest,
manifest_path=manifest_path,
)
log.info("Step 2: Pushing metadata to Monte Carlo …")
push(
manifest_path=args.manifest,
manifest_path=manifest_path,
resource_uuid=args.resource_uuid,
key_id=args.key_id,
key_token=args.key_token,
@@ -31,6 +31,7 @@ import os
from collect_query_logs import LOOKBACK_HOURS, LOOKBACK_LAG_HOURS, MAX_ROWS, collect
from push_query_logs import DEFAULT_BATCH_SIZE, push
from _safe_paths import safe_output_json_path
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
@@ -56,12 +57,14 @@ def main() -> None:
if missing:
parser.error(f"Missing required arguments/env vars: {missing}")
manifest_path = str(safe_output_json_path(args.manifest))
log.info("Step 1: Collecting query logs …")
collect(
host=args.host,
http_path=args.http_path,
token=args.token,
manifest_path=args.manifest,
manifest_path=manifest_path,
lookback_hours=args.lookback_hours,
lookback_lag_hours=args.lookback_lag_hours,
max_rows=args.max_rows,
@@ -69,7 +72,7 @@ def main() -> None:
log.info("Step 2: Pushing query logs to Monte Carlo …")
push(
manifest_path=args.manifest,
manifest_path=manifest_path,
resource_uuid=args.resource_uuid,
key_id=args.key_id,
key_token=args.key_token,
@@ -29,6 +29,7 @@ from datetime import datetime, timezone
from typing import Any
from databricks import sql
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, write_json_file
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
@@ -37,6 +38,13 @@ RESOURCE_TYPE = "databricks"
LOOKBACK_DAYS: int = int(os.getenv("LOOKBACK_DAYS", "30")) # ← SUBSTITUTE
def _bounded_int(value: int, field: str, *, minimum: int, maximum: int) -> int:
value = int(value)
if value < minimum or value > maximum:
raise ValueError(f"{field} must be between {minimum} and {maximum}")
return value
def _check_available_memory(min_gb: float = 2.0) -> None:
"""Warn if available memory is below the threshold."""
try:
@@ -80,6 +88,7 @@ def _parse_full_name(full_name: str) -> tuple[str, str, str]:
def collect_table_lineage(cursor: Any, lookback_days: int) -> list[dict[str, Any]]:
lookback_days = _bounded_int(lookback_days, "lookback_days", minimum=1, maximum=366)
rows = _query(
cursor,
f"""
@@ -114,6 +123,7 @@ def collect_table_lineage(cursor: Any, lookback_days: int) -> list[dict[str, Any
def collect_column_lineage(cursor: Any, lookback_days: int) -> list[dict[str, Any]]:
lookback_days = _bounded_int(lookback_days, "lookback_days", minimum=1, maximum=366)
rows = _query(
cursor,
f"""
@@ -176,6 +186,7 @@ def collect(
) -> list[dict[str, Any]]:
"""Connect to Databricks, collect lineage, write a JSON manifest, and return events."""
_check_available_memory(min_gb=2.0)
lookback_days = _bounded_int(lookback_days, "lookback_days", minimum=1, maximum=366)
collected_at = datetime.now(timezone.utc).isoformat()
with sql.connect(
@@ -201,8 +212,7 @@ def collect(
"column_lineage_events": len(col_events),
"events": all_events,
}
with open(manifest_path, "w") as fh:
json.dump(manifest, fh, indent=2)
write_json_file(manifest_path, manifest)
log.info("Manifest written to %s (%d events)", manifest_path, len(all_events))
return all_events
@@ -22,15 +22,18 @@ import argparse
import json
import logging
import os
import re
from datetime import datetime, timezone
from typing import Any
from databricks import sql
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, write_json_file
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
RESOURCE_TYPE = "databricks"
_SAFE_DATABRICKS_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# Schemas to skip across all catalogs
SCHEMA_EXCLUSIONS: set[str] = { # ← SUBSTITUTE: add any internal schemas to skip
@@ -39,6 +42,21 @@ SCHEMA_EXCLUSIONS: set[str] = { # ← SUBSTITUTE: add any internal schemas to s
}
def _quote_identifier(identifier: str) -> str:
value = str(identifier).strip()
if not value:
raise ValueError("Identifier must not be empty")
if not _SAFE_DATABRICKS_IDENTIFIER_RE.fullmatch(value):
raise ValueError(
"Databricks identifier contains characters outside the safe default set"
)
return "`" + value.replace("`", "``") + "`"
def _sql_literal(value: str) -> str:
return "'" + str(value).replace("'", "''") + "'"
def _check_available_memory(min_gb: float = 2.0) -> None:
"""Warn if available memory is below the threshold."""
try:
@@ -59,8 +77,7 @@ def _check_available_memory(min_gb: float = 2.0) -> None:
)
def _query(cursor: Any, sql_text: str, params: tuple | None = None) -> list[dict[str, Any]]:
cursor.execute(sql_text, params)
def _fetch_dict_rows(cursor: Any) -> list[dict[str, Any]]:
cols = [d[0] for d in cursor.description]
rows = []
while True:
@@ -72,32 +89,40 @@ def _query(cursor: Any, sql_text: str, params: tuple | None = None) -> list[dict
def collect_tables(cursor: Any, catalog: str) -> list[dict[str, Any]]:
return _query(
cursor,
exclusions = sorted(SCHEMA_EXCLUSIONS)
placeholders = ", ".join(["%s"] * len(exclusions))
cursor.execute(
f"""
SELECT table_catalog, table_schema, table_name, table_type, comment
FROM {catalog}.information_schema.tables
WHERE table_schema NOT IN ({", ".join(f"'{s}'" for s in SCHEMA_EXCLUSIONS)})
FROM system.information_schema.tables
WHERE table_catalog = %s AND table_schema NOT IN ({placeholders})
ORDER BY table_schema, table_name
""", # ← SUBSTITUTE: add additional WHERE filters if needed
(catalog, *exclusions),
)
return _fetch_dict_rows(cursor)
def collect_columns(cursor: Any, catalog: str, schema: str, table: str) -> list[dict[str, Any]]:
return _query(
cursor,
f"""
cursor.execute(
"""
SELECT column_name, data_type, comment
FROM {catalog}.information_schema.columns
WHERE table_schema = '{schema}' AND table_name = '{table}'
FROM system.information_schema.columns
WHERE table_catalog = %s AND table_schema = %s AND table_name = %s
ORDER BY ordinal_position
""",
(catalog, schema, table),
)
return _fetch_dict_rows(cursor)
def collect_detail(cursor: Any, catalog: str, schema: str, table: str) -> dict[str, Any] | None:
try:
rows = _query(cursor, f"DESCRIBE DETAIL `{catalog}`.`{schema}`.`{table}`")
cursor.execute(
"DESCRIBE DETAIL "
f"{_quote_identifier(catalog)}.{_quote_identifier(schema)}.{_quote_identifier(table)}",
)
rows = _fetch_dict_rows(cursor)
return rows[0] if rows else None
except Exception:
log.debug("DESCRIBE DETAIL failed for %s.%s.%s", catalog, schema, table, exc_info=True)
@@ -178,8 +203,7 @@ def collect(
"asset_count": len(assets),
"assets": assets,
}
with open(manifest_path, "w") as fh:
json.dump(manifest, fh, indent=2)
write_json_file(manifest_path, manifest)
log.info("Manifest written to %s (%d assets)", manifest_path, len(assets))
return assets
@@ -27,6 +27,7 @@ from datetime import datetime, timezone
from typing import Any
from databricks import sql
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, write_json_file
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
@@ -57,6 +58,13 @@ LIMIT {max_rows}
""" # ← SUBSTITUTE: adjust status filter or add warehouse_id filter as needed
def _bounded_int(value: int, field: str, *, minimum: int, maximum: int) -> int:
value = int(value)
if value < minimum or value > maximum:
raise ValueError(f"{field} must be between {minimum} and {maximum}")
return value
def _check_available_memory(min_gb: float = 2.0) -> None:
"""Warn if available memory is below the threshold."""
try:
@@ -105,6 +113,9 @@ def collect_query_logs(
lag_hours: int,
max_rows: int,
) -> list[dict[str, Any]]:
lookback_hours = _bounded_int(lookback_hours, "lookback_hours", minimum=1, maximum=24 * 31)
lag_hours = _bounded_int(lag_hours, "lag_hours", minimum=0, maximum=24 * 7)
max_rows = _bounded_int(max_rows, "max_rows", minimum=1, maximum=100000)
rendered_sql = _QUERY_LOG_SQL.format(
lookback_hours=lookback_hours + lag_hours, # offset from NOW() to cover the window
lag_hours=lag_hours,
@@ -146,6 +157,9 @@ def collect(
) -> list[dict[str, Any]]:
"""Connect to Databricks, collect query logs, write a JSON manifest, and return entries."""
_check_available_memory(min_gb=2.0)
lookback_hours = _bounded_int(lookback_hours, "lookback_hours", minimum=1, maximum=24 * 31)
lookback_lag_hours = _bounded_int(lookback_lag_hours, "lookback_lag_hours", minimum=0, maximum=24 * 7)
max_rows = _bounded_int(max_rows, "max_rows", minimum=1, maximum=100000)
collected_at = datetime.now(timezone.utc).isoformat()
with sql.connect(
@@ -166,8 +180,7 @@ def collect(
"query_log_count": len(entries),
"entries": entries,
}
with open(manifest_path, "w") as fh:
json.dump(manifest, fh, indent=2)
write_json_file(manifest_path, manifest)
log.info("Manifest written to %s (%d entries)", manifest_path, len(entries))
return entries
@@ -32,6 +32,7 @@ from pycarlo.features.ingestion.models import (
LineageAssetRef,
LineageEvent,
)
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, read_json_file, write_json_file
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
@@ -96,8 +97,7 @@ def push(
Returns a summary dict with invocation IDs and counts.
"""
with open(manifest_path) as fh:
manifest = json.load(fh)
manifest = read_json_file(manifest_path)
event_dicts: list[dict[str, Any]] = manifest["events"]
events = [_event_from_dict(d) for d in event_dicts]
@@ -158,8 +158,7 @@ def push(
}
push_manifest_path = manifest_path.replace(".json", "_push_result.json")
with open(push_manifest_path, "w") as fh:
json.dump(summary, fh, indent=2)
write_json_file(push_manifest_path, summary)
log.info("Push result written to %s", push_manifest_path)
return summary
@@ -33,6 +33,7 @@ from pycarlo.features.ingestion.models import (
AssetVolume,
RelationalAsset,
)
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, read_json_file, write_json_file
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
@@ -85,8 +86,7 @@ def push(
Returns a summary dict with invocation IDs and counts.
"""
with open(manifest_path) as fh:
manifest = json.load(fh)
manifest = read_json_file(manifest_path)
asset_dicts: list[dict[str, Any]] = manifest["assets"]
assets = [_asset_from_dict(d) for d in asset_dicts]
@@ -144,8 +144,7 @@ def push(
# Write push result alongside the collect manifest
push_manifest_path = manifest_path.replace(".json", "_push_result.json")
with open(push_manifest_path, "w") as fh:
json.dump(summary, fh, indent=2)
write_json_file(push_manifest_path, summary)
log.info("Push result written to %s", push_manifest_path)
return summary
@@ -28,6 +28,7 @@ from dateutil.parser import isoparse
from pycarlo.core import Client, Session
from pycarlo.features.ingestion import IngestionService
from pycarlo.features.ingestion.models import QueryLogEntry
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, read_json_file, write_json_file
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
@@ -91,8 +92,7 @@ def push(
Returns a summary dict with invocation IDs and counts.
"""
with open(manifest_path) as fh:
manifest = json.load(fh)
manifest = read_json_file(manifest_path)
entry_dicts: list[dict[str, Any]] = manifest["entries"]
entries = _build_query_log_entries(entry_dicts)
@@ -110,8 +110,7 @@ def push(
"batch_size": batch_size,
}
push_manifest_path = manifest_path.replace(".json", "_push_result.json")
with open(push_manifest_path, "w") as fh:
json.dump(summary, fh, indent=2)
write_json_file(push_manifest_path, summary)
return summary
# Split into batches
@@ -166,8 +165,7 @@ def push(
}
push_manifest_path = manifest_path.replace(".json", "_push_result.json")
with open(push_manifest_path, "w") as fh:
json.dump(summary, fh, indent=2)
write_json_file(push_manifest_path, summary)
log.info("Push result written to %s", push_manifest_path)
return summary
@@ -0,0 +1,66 @@
"""Path guards for local Monte Carlo template manifests."""
from __future__ import annotations
import json
import os
from pathlib import Path
def _allow_external_paths() -> bool:
return os.getenv("MCD_ALLOW_EXTERNAL_PATHS", "").lower() in {"1", "true", "yes"}
def _is_relative_to(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
def _resolve_local_path(raw_path: str, *, expect_file: bool = False, create_parent: bool = False) -> Path:
value = str(raw_path).strip()
if not value or "\0" in value:
raise ValueError("Path must be a non-empty filesystem path")
base = Path.cwd().resolve()
candidate = Path(value).expanduser()
resolved = (candidate if candidate.is_absolute() else base / candidate).resolve()
if not _allow_external_paths() and not _is_relative_to(resolved, base):
raise ValueError(f"Path must stay under the current working directory: {raw_path!r}")
if expect_file and not resolved.is_file():
raise FileNotFoundError(f"Input file not found: {resolved}")
if create_parent:
resolved.parent.mkdir(parents=True, exist_ok=True)
return resolved
def safe_input_json_path(raw_path: str) -> Path:
path = _resolve_local_path(raw_path, expect_file=True)
if path.suffix.lower() != ".json":
raise ValueError(f"Input manifest must be a .json file: {path}")
return path
def safe_output_json_path(raw_path: str) -> Path:
path = _resolve_local_path(raw_path, create_parent=True)
if path.suffix.lower() != ".json":
raise ValueError(f"Output manifest must be a .json file: {path}")
return path
def safe_existing_directory(raw_path: str) -> Path:
path = _resolve_local_path(raw_path)
if not path.is_dir():
raise NotADirectoryError(f"Directory not found: {path}")
return path
def read_json_file(raw_path: str):
with safe_input_json_path(raw_path).open() as fh:
return json.load(fh)
def write_json_file(raw_path: str, payload, *, indent: int = 2, default=None) -> None:
with safe_output_json_path(raw_path).open("w") as fh:
json.dump(payload, fh, indent=indent, default=default)
@@ -34,6 +34,7 @@ import os
from collect_lineage import collect
from push_lineage import DEFAULT_BATCH_SIZE, DEFAULT_TIMEOUT_SECONDS, push
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, write_json_file
def main() -> None:
@@ -109,8 +110,7 @@ def main() -> None:
timeout_seconds=args.timeout,
)
with open(args.output_file, "w") as fh:
json.dump(manifest, fh, indent=2)
write_json_file(args.output_file, manifest)
print(f"Lineage manifest written to {args.output_file}")
print("Done.")
@@ -30,8 +30,9 @@ import argparse
import json
import os
from collect_metadata import collect
from collect_metadata import _bounded_int, collect
from push_metadata import DEFAULT_BATCH_SIZE, DEFAULT_TIMEOUT_SECONDS, push
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, write_json_file
def main() -> None:
@@ -95,6 +96,8 @@ def main() -> None:
if not args.resource_uuid:
parser.error("--resource-uuid is required (or set MCD_RESOURCE_UUID)")
args.hive_port = _bounded_int(args.hive_port, "hive_port", minimum=1, maximum=65535)
manifest = collect(
hive_host=args.hive_host,
hive_port=args.hive_port,
@@ -109,8 +112,7 @@ def main() -> None:
timeout_seconds=args.timeout,
)
with open(args.output_file, "w") as fh:
json.dump(manifest, fh, indent=2)
write_json_file(args.output_file, manifest)
print(f"Manifest written to {args.output_file}")
print("Done.")
@@ -35,6 +35,7 @@ import os
from collect_query_logs import collect
from push_query_logs import DEFAULT_BATCH_SIZE, DEFAULT_TIMEOUT_SECONDS, push
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, write_json_file
def main() -> None:
@@ -107,8 +108,7 @@ def main() -> None:
timeout_seconds=args.timeout,
)
with open(args.output_file, "w") as fh:
json.dump(manifest, fh, indent=2)
write_json_file(args.output_file, manifest)
print(f"Query log manifest written to {args.output_file}")
print("Done.")
@@ -31,6 +31,7 @@ import json
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, write_json_file
# ← SUBSTITUTE: set RESOURCE_TYPE to match your Monte Carlo connection type
RESOURCE_TYPE = "data-lake"
@@ -255,8 +256,7 @@ def main() -> None:
print("No lineage edges detected — no CTAS or INSERT INTO ... SELECT patterns found.")
return
with open(args.output_file, "w") as fh:
json.dump(manifest, fh, indent=2)
write_json_file(args.output_file, manifest)
print(f"Lineage manifest written to {args.output_file}")
print("Done.")
@@ -31,6 +31,7 @@ import re
from datetime import datetime, timezone
from pyhive import hive
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, write_json_file
def _check_available_memory(min_gb: float = 2.0) -> None:
@@ -82,6 +83,47 @@ _HIVE_TYPE_MAP: dict[str, str] = {
# ← SUBSTITUTE: add any internal table name prefixes you want to skip
_INTERNAL_TABLE_PREFIXES = ("tmp_", "__", "hive_")
_SAFE_HIVE_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
def _safe_hive_identifier(identifier: str) -> str:
value = str(identifier).strip()
if not value:
raise ValueError("Hive identifier must not be empty")
match = _SAFE_HIVE_IDENTIFIER_RE.fullmatch(value)
if not match:
raise ValueError("Hive identifier contains characters outside the safe default set")
return match.group(0)
def _safe_hive_identifier_from_row(row: tuple, index: int = 0) -> str:
value = str(row[index]).strip()
match = _SAFE_HIVE_IDENTIFIER_RE.fullmatch(value)
if not match:
raise ValueError("Hive identifier contains characters outside the safe default set")
return match.group(0)
def _quote_hive_identifier(identifier: str) -> str:
value = str(identifier).strip()
if not value:
raise ValueError("Hive identifier must not be empty")
allow_extended = os.getenv("HIVE_ALLOW_EXTENDED_IDENTIFIERS", "").lower() in {"1", "true", "yes"}
if not allow_extended:
value = _safe_hive_identifier(value)
elif not _SAFE_HIVE_IDENTIFIER_RE.fullmatch(value):
raise ValueError(
"Hive identifier contains characters outside the safe default set; "
"set HIVE_ALLOW_EXTENDED_IDENTIFIERS=1 to use escaped extended identifiers"
)
return "`" + value.replace("`", "``") + "`"
def _bounded_int(value: int, field: str, *, minimum: int, maximum: int) -> int:
value = int(value)
if value < minimum or value > maximum:
raise ValueError(f"{field} must be between {minimum} and {maximum}")
return value
def _normalize_hive_type(hive_type: str) -> str:
@@ -101,9 +143,8 @@ def _connect(host: str, port: int) -> hive.Connection:
return hive.connect(host=host, port=port, username="hadoop", auth="NONE")
def _fetch_rows(cursor, query: str) -> list[tuple]:
"""Execute a query and fetch results in memory-safe chunks."""
cursor.execute(query)
def _fetch_rows(cursor) -> list[tuple]:
"""Fetch query results in memory-safe chunks."""
rows: list[tuple] = []
while True:
chunk = cursor.fetchmany(1000)
@@ -207,13 +248,15 @@ def collect(
Manifest dict with keys: resource_type, collected_at, assets.
"""
_check_available_memory()
hive_port = _bounded_int(hive_port, "hive_port", minimum=1, maximum=65535)
print(f"Connecting to HiveServer2 at {hive_host}:{hive_port} ...")
conn = _connect(hive_host, hive_port)
cursor = conn.cursor()
assets: list[dict] = []
print("Collecting table metadata ...")
databases = [row[0] for row in _fetch_rows(cursor, "SHOW DATABASES")]
cursor.execute("SHOW DATABASES")
databases = [_safe_hive_identifier_from_row(row) for row in _fetch_rows(cursor)]
print(f" Found databases: {databases}")
for db in databases:
@@ -221,8 +264,13 @@ def collect(
if db in ("information_schema",):
continue
tables = _fetch_rows(cursor, f"SHOW TABLES IN {db}")
table_names = [row[0] for row in tables]
db_match = _SAFE_HIVE_IDENTIFIER_RE.fullmatch(db)
if not db_match:
raise ValueError("Hive database identifier contains characters outside the safe default set")
quoted_db = f"`{db_match.group(0)}`"
cursor.execute(f"SHOW TABLES IN {quoted_db}")
tables = _fetch_rows(cursor)
table_names = [_safe_hive_identifier_from_row(row) for row in tables]
print(f" {db}: {len(table_names)} table(s)")
for table in table_names:
@@ -230,7 +278,12 @@ def collect(
continue
try:
desc_rows = _fetch_rows(cursor, f"DESCRIBE FORMATTED {db}.{table}")
table_match = _SAFE_HIVE_IDENTIFIER_RE.fullmatch(table)
if not table_match:
raise ValueError("Hive table identifier contains characters outside the safe default set")
quoted_table = f"`{table_match.group(0)}`"
cursor.execute(f"DESCRIBE FORMATTED {quoted_db}.{quoted_table}")
desc_rows = _fetch_rows(cursor)
except Exception as exc:
print(f" WARNING: could not describe {db}.{table}: {exc}")
continue
@@ -303,8 +356,7 @@ def main() -> None:
hive_port=args.hive_port,
)
with open(args.output_file, "w") as fh:
json.dump(manifest, fh, indent=2)
write_json_file(args.output_file, manifest)
print(f"Asset manifest written to {args.output_file}")
print("Done.")
@@ -133,7 +133,7 @@ def _load_returned_rows(op_logs_dir: str) -> dict[str, int]:
each file, which reflects the final number of rows delivered to the client.
"""
rows_by_id: dict[str, int] = {}
for log_file in Path(op_logs_dir).glob("*.log"):
for log_file in safe_existing_directory(op_logs_dir).glob("*.log"):
query_id = log_file.stem
last_count: int | None = None
try:
@@ -193,6 +193,7 @@ def collect(
op_logs_dir: Optional directory containing per-query operation logs
(<queryId>.log). When provided, returned_rows is populated
from SelectOperator RECORDS_OUT counts.
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, write_json_file
Returns:
Manifest dict with keys: log_type, collected_at, entry_count,
@@ -274,8 +275,7 @@ def main() -> None:
manifest = collect(log_file=args.log_file, op_logs_dir=args.op_logs_dir)
with open(args.output_file, "w") as fh:
json.dump(manifest, fh, indent=2)
write_json_file(args.output_file, manifest)
print(f"Query log manifest written to {args.output_file}")
print("Done.")
@@ -43,6 +43,7 @@ from pycarlo.features.ingestion.models import (
LineageAssetRef,
LineageEvent,
)
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, read_json_file, write_json_file
# ← SUBSTITUTE: set RESOURCE_TYPE to match your Monte Carlo connection type
RESOURCE_TYPE = "data-lake"
@@ -286,8 +287,7 @@ def main() -> None:
if not args.resource_uuid:
parser.error("--resource-uuid is required (or set MCD_RESOURCE_UUID)")
with open(args.input_file) as fh:
manifest = json.load(fh)
manifest = read_json_file(args.input_file)
push(
manifest=manifest,
@@ -299,8 +299,7 @@ def main() -> None:
timeout_seconds=args.timeout,
)
with open(args.input_file, "w") as fh:
json.dump(manifest, fh, indent=2)
write_json_file(args.input_file, manifest)
print(f"Manifest updated in-place: {args.input_file}")
print("Done.")
@@ -43,6 +43,7 @@ from pycarlo.features.ingestion.models import (
AssetVolume,
RelationalAsset,
)
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, read_json_file, write_json_file
# ← SUBSTITUTE: default batch size for metadata push (assets per request)
DEFAULT_BATCH_SIZE = 500
@@ -223,8 +224,7 @@ def main() -> None:
if not args.resource_uuid:
parser.error("--resource-uuid is required (or set MCD_RESOURCE_UUID)")
with open(args.input_file) as fh:
manifest = json.load(fh)
manifest = read_json_file(args.input_file)
push(
manifest=manifest,
@@ -235,8 +235,7 @@ def main() -> None:
timeout_seconds=args.timeout,
)
with open(args.input_file, "w") as fh:
json.dump(manifest, fh, indent=2)
write_json_file(args.input_file, manifest)
print(f"Manifest updated in-place: {args.input_file}")
print("Done.")
@@ -39,6 +39,7 @@ from dateutil.parser import isoparse
from pycarlo.core import Client, Session
from pycarlo.features.ingestion import IngestionService
from pycarlo.features.ingestion.models import QueryLogEntry
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, read_json_file, write_json_file
# ← SUBSTITUTE: default batch size for query log push (events per request)
# Query logs include full SQL text — keep batches small to stay under the 1 MB
@@ -233,8 +234,7 @@ def main() -> None:
if not args.key_id or not args.key_token:
parser.error("--key-id and --key-token are required (or set MCD_INGEST_ID / MCD_INGEST_TOKEN)")
with open(args.input_file) as fh:
manifest = json.load(fh)
manifest = read_json_file(args.input_file)
push(
manifest=manifest,
@@ -245,8 +245,7 @@ def main() -> None:
timeout_seconds=args.timeout,
)
with open(args.input_file, "w") as fh:
json.dump(manifest, fh, indent=2)
write_json_file(args.input_file, manifest)
print(f"Manifest updated in-place: {args.input_file}")
print("Done.")
@@ -0,0 +1,66 @@
"""Path guards for local Monte Carlo template manifests."""
from __future__ import annotations
import json
import os
from pathlib import Path
def _allow_external_paths() -> bool:
return os.getenv("MCD_ALLOW_EXTERNAL_PATHS", "").lower() in {"1", "true", "yes"}
def _is_relative_to(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
def _resolve_local_path(raw_path: str, *, expect_file: bool = False, create_parent: bool = False) -> Path:
value = str(raw_path).strip()
if not value or "\0" in value:
raise ValueError("Path must be a non-empty filesystem path")
base = Path.cwd().resolve()
candidate = Path(value).expanduser()
resolved = (candidate if candidate.is_absolute() else base / candidate).resolve()
if not _allow_external_paths() and not _is_relative_to(resolved, base):
raise ValueError(f"Path must stay under the current working directory: {raw_path!r}")
if expect_file and not resolved.is_file():
raise FileNotFoundError(f"Input file not found: {resolved}")
if create_parent:
resolved.parent.mkdir(parents=True, exist_ok=True)
return resolved
def safe_input_json_path(raw_path: str) -> Path:
path = _resolve_local_path(raw_path, expect_file=True)
if path.suffix.lower() != ".json":
raise ValueError(f"Input manifest must be a .json file: {path}")
return path
def safe_output_json_path(raw_path: str) -> Path:
path = _resolve_local_path(raw_path, create_parent=True)
if path.suffix.lower() != ".json":
raise ValueError(f"Output manifest must be a .json file: {path}")
return path
def safe_existing_directory(raw_path: str) -> Path:
path = _resolve_local_path(raw_path)
if not path.is_dir():
raise NotADirectoryError(f"Directory not found: {path}")
return path
def read_json_file(raw_path: str):
with safe_input_json_path(raw_path).open() as fh:
return json.load(fh)
def write_json_file(raw_path: str, payload, *, indent: int = 2, default=None) -> None:
with safe_output_json_path(raw_path).open("w") as fh:
json.dump(payload, fh, indent=indent, default=default)
@@ -24,8 +24,9 @@ import argparse
import logging
import os
from collect_lineage import LOOKBACK_HOURS, collect
from collect_lineage import LOOKBACK_HOURS, _bounded_int, collect, validate_redshift_host
from push_lineage import DEFAULT_BATCH_SIZE, push
from _safe_paths import safe_output_json_path
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
@@ -33,7 +34,6 @@ log = logging.getLogger(__name__)
def main() -> None:
parser = argparse.ArgumentParser(description="Collect and push Redshift lineage to Monte Carlo")
parser.add_argument("--host", default=os.getenv("REDSHIFT_HOST")) # ← SUBSTITUTE
parser.add_argument("--db", default=os.getenv("REDSHIFT_DB")) # ← SUBSTITUTE
parser.add_argument("--user", default=os.getenv("REDSHIFT_USER")) # ← SUBSTITUTE
parser.add_argument("--password", default=os.getenv("REDSHIFT_PASSWORD")) # ← SUBSTITUTE
@@ -46,25 +46,37 @@ def main() -> None:
parser.add_argument("--manifest", default="manifest_lineage.json")
args = parser.parse_args()
required = ["host", "db", "user", "password", "resource_uuid", "key_id", "key_token"]
required = ["db", "user", "password", "resource_uuid", "key_id", "key_token"]
missing = [k for k in required if getattr(args, k) is None]
if missing:
parser.error(f"Missing required arguments/env vars: {missing}")
manifest_path = str(safe_output_json_path(args.manifest))
redshift_host = os.getenv("REDSHIFT_HOST")
if not redshift_host:
parser.error("Missing required env var: REDSHIFT_HOST")
redshift_host = validate_redshift_host(
redshift_host,
allow_private=os.getenv("REDSHIFT_ALLOW_PRIVATE_HOST", "").lower() in {"1", "true", "yes"},
)
args.port = _bounded_int(args.port, "port", minimum=1, maximum=65535)
args.lookback_hours = _bounded_int(args.lookback_hours, "lookback_hours", minimum=1, maximum=24 * 31)
log.info("Step 1: Collecting lineage …")
collect(
host=args.host,
host=redshift_host,
db=args.db,
user=args.user,
password=args.password,
manifest_path=args.manifest,
manifest_path=manifest_path,
port=args.port,
lookback_hours=args.lookback_hours,
)
log.info("Step 2: Pushing lineage to Monte Carlo …")
push(
manifest_path=args.manifest,
manifest_path=manifest_path,
resource_uuid=args.resource_uuid,
key_id=args.key_id,
key_token=args.key_token,
@@ -28,8 +28,9 @@ import argparse
import logging
import os
from collect_metadata import collect
from collect_metadata import _bounded_int, collect, validate_redshift_host
from push_metadata import DEFAULT_BATCH_SIZE, push
from _safe_paths import safe_output_json_path
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
@@ -37,7 +38,6 @@ log = logging.getLogger(__name__)
def main() -> None:
parser = argparse.ArgumentParser(description="Collect and push Redshift metadata to Monte Carlo")
parser.add_argument("--host", default=os.getenv("REDSHIFT_HOST")) # ← SUBSTITUTE
parser.add_argument("--db", default=os.getenv("REDSHIFT_DB")) # ← SUBSTITUTE
parser.add_argument("--user", default=os.getenv("REDSHIFT_USER")) # ← SUBSTITUTE
parser.add_argument("--password", default=os.getenv("REDSHIFT_PASSWORD")) # ← SUBSTITUTE
@@ -49,24 +49,35 @@ def main() -> None:
parser.add_argument("--manifest", default="manifest_metadata.json")
args = parser.parse_args()
required = ["host", "db", "user", "password", "resource_uuid", "key_id", "key_token"]
required = ["db", "user", "password", "resource_uuid", "key_id", "key_token"]
missing = [k for k in required if getattr(args, k) is None]
if missing:
parser.error(f"Missing required arguments/env vars: {missing}")
manifest_path = str(safe_output_json_path(args.manifest))
redshift_host = os.getenv("REDSHIFT_HOST")
if not redshift_host:
parser.error("Missing required env var: REDSHIFT_HOST")
redshift_host = validate_redshift_host(
redshift_host,
allow_private=os.getenv("REDSHIFT_ALLOW_PRIVATE_HOST", "").lower() in {"1", "true", "yes"},
)
args.port = _bounded_int(args.port, "port", minimum=1, maximum=65535)
log.info("Step 1: Collecting metadata …")
collect(
host=args.host,
host=redshift_host,
db=args.db,
user=args.user,
password=args.password,
manifest_path=args.manifest,
manifest_path=manifest_path,
port=args.port,
)
log.info("Step 2: Pushing metadata to Monte Carlo …")
push(
manifest_path=args.manifest,
manifest_path=manifest_path,
resource_uuid=args.resource_uuid,
key_id=args.key_id,
key_token=args.key_token,
@@ -28,8 +28,17 @@ import argparse
import logging
import os
from collect_query_logs import BATCH_SIZE, LOOKBACK_HOURS, LOOKBACK_LAG_HOURS, MAX_QUERIES, collect
from collect_query_logs import (
BATCH_SIZE,
LOOKBACK_HOURS,
LOOKBACK_LAG_HOURS,
MAX_QUERIES,
_bounded_int,
collect,
validate_redshift_host,
)
from push_query_logs import DEFAULT_BATCH_SIZE, push
from _safe_paths import safe_output_json_path
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
@@ -37,7 +46,6 @@ log = logging.getLogger(__name__)
def main() -> None:
parser = argparse.ArgumentParser(description="Collect and push Redshift query logs to Monte Carlo")
parser.add_argument("--host", default=os.getenv("REDSHIFT_HOST")) # ← SUBSTITUTE
parser.add_argument("--db", default=os.getenv("REDSHIFT_DB")) # ← SUBSTITUTE
parser.add_argument("--user", default=os.getenv("REDSHIFT_USER")) # ← SUBSTITUTE
parser.add_argument("--password", default=os.getenv("REDSHIFT_PASSWORD")) # ← SUBSTITUTE
@@ -53,18 +61,33 @@ def main() -> None:
parser.add_argument("--manifest", default="manifest_query_logs.json")
args = parser.parse_args()
required = ["host", "db", "user", "password", "resource_uuid", "key_id", "key_token"]
required = ["db", "user", "password", "resource_uuid", "key_id", "key_token"]
missing = [k for k in required if getattr(args, k) is None]
if missing:
parser.error(f"Missing required arguments/env vars: {missing}")
manifest_path = str(safe_output_json_path(args.manifest))
redshift_host = os.getenv("REDSHIFT_HOST")
if not redshift_host:
parser.error("Missing required env var: REDSHIFT_HOST")
redshift_host = validate_redshift_host(
redshift_host,
allow_private=os.getenv("REDSHIFT_ALLOW_PRIVATE_HOST", "").lower() in {"1", "true", "yes"},
)
args.port = _bounded_int(args.port, "port", minimum=1, maximum=65535)
args.lookback_hours = _bounded_int(args.lookback_hours, "lookback_hours", minimum=1, maximum=24 * 31)
args.lookback_lag_hours = _bounded_int(args.lookback_lag_hours, "lookback_lag_hours", minimum=0, maximum=24 * 7)
args.batch_size = _bounded_int(args.batch_size, "batch_size", minimum=1, maximum=10000)
args.max_queries = _bounded_int(args.max_queries, "max_queries", minimum=1, maximum=100000)
log.info("Step 1: Collecting query logs …")
collect(
host=args.host,
host=redshift_host,
db=args.db,
user=args.user,
password=args.password,
manifest_path=args.manifest,
manifest_path=manifest_path,
port=args.port,
lookback_hours=args.lookback_hours,
lookback_lag_hours=args.lookback_lag_hours,
@@ -74,7 +97,7 @@ def main() -> None:
log.info("Step 2: Pushing query logs to Monte Carlo …")
push(
manifest_path=args.manifest,
manifest_path=manifest_path,
resource_uuid=args.resource_uuid,
key_id=args.key_id,
key_token=args.key_token,
@@ -18,6 +18,7 @@ Prerequisites:
from __future__ import annotations
import argparse
import ipaddress
import json
import logging
import os
@@ -26,6 +27,7 @@ from datetime import datetime, timezone
from typing import Any
import psycopg2
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, write_json_file
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
@@ -33,6 +35,55 @@ log = logging.getLogger(__name__)
RESOURCE_TYPE = "redshift"
LOOKBACK_HOURS: int = int(os.getenv("LOOKBACK_HOURS", "24")) # ← SUBSTITUTE
_ALLOWED_REDSHIFT_HOST_RE = re.compile(
r"^[a-z0-9][a-z0-9.-]*\.(?:redshift|redshift-serverless)\.[a-z0-9-]+\.amazonaws\.com(?:\.cn)?$",
re.IGNORECASE,
)
def _explicitly_allowed_redshift_hosts() -> set[str]:
raw_hosts = os.getenv("REDSHIFT_ALLOWED_HOSTS", "")
return {host.strip().lower().rstrip(".") for host in raw_hosts.split(",") if host.strip()}
def validate_redshift_host(host: str, *, allow_private: bool = False) -> str:
value = str(host).strip()
if not value or any(part in value for part in ("/", "\\", "@", ":")):
raise ValueError(f"Invalid Redshift host: {host!r}")
hostname = value.lower().rstrip(".")
allowed_hosts = _explicitly_allowed_redshift_hosts()
try:
address = ipaddress.ip_address(value)
except ValueError:
if hostname in allowed_hosts:
return hostname
match = _ALLOWED_REDSHIFT_HOST_RE.fullmatch(hostname)
if match:
return match.group(0)
raise ValueError(
"Redshift host must be an AWS Redshift endpoint or be listed in REDSHIFT_ALLOWED_HOSTS"
)
if hostname not in allowed_hosts:
raise ValueError("Redshift IP hosts must be listed in REDSHIFT_ALLOWED_HOSTS")
blocked = (
address.is_loopback
or address.is_link_local
or address.is_multicast
or address.is_unspecified
or address.is_reserved
or (address.is_private and not allow_private)
)
if blocked:
raise ValueError(f"Redshift host address is not allowed: {host!r}")
return str(address)
def _bounded_int(value: int, field: str, *, minimum: int, maximum: int) -> int:
value = int(value)
if value < minimum or value > maximum:
raise ValueError(f"{field} must be between {minimum} and {maximum}")
return value
def _check_available_memory(min_gb: float = 2.0) -> None:
"""Warn if available memory is below the threshold."""
@@ -96,9 +147,10 @@ def _dictfetch(cursor: Any, sql: str, params: tuple | None = None) -> list[dict[
def fetch_query_texts(cursor: Any, lookback_hours: int) -> list[str]:
"""Assemble full query texts from sys_query_history + sys_querytext."""
lookback_hours = _bounded_int(lookback_hours, "lookback_hours", minimum=1, maximum=24 * 31)
rows = _dictfetch(
cursor,
f"""
"""
SELECT
sq.query_id,
LISTAGG(
@@ -107,11 +159,12 @@ def fetch_query_texts(cursor: Any, lookback_hours: int) -> list[str]:
) WITHIN GROUP (ORDER BY st.sequence) AS full_text
FROM sys_query_history sq
JOIN sys_querytext st ON sq.query_id = st.query_id
WHERE sq.start_time >= DATEADD(hour, -{lookback_hours}, GETDATE())
WHERE sq.start_time >= DATEADD(hour, -%s, GETDATE())
AND sq.status = 'success'
GROUP BY sq.query_id
LIMIT 50000
""", # ← SUBSTITUTE: adjust lookback_hours, LIMIT, or add user/database filters
(lookback_hours,),
)
return [r["full_text"] for r in rows if r.get("full_text")]
@@ -171,6 +224,10 @@ def collect(
) -> list[dict[str, Any]]:
"""Connect to Redshift, collect lineage, write a JSON manifest, and return events."""
_check_available_memory()
allow_private_host = os.getenv("REDSHIFT_ALLOW_PRIVATE_HOST", "").lower() in {"1", "true", "yes"}
host = validate_redshift_host(host, allow_private=allow_private_host)
port = _bounded_int(port, "port", minimum=1, maximum=65535)
lookback_hours = _bounded_int(lookback_hours, "lookback_hours", minimum=1, maximum=24 * 31)
collected_at = datetime.now(timezone.utc).isoformat()
conn = psycopg2.connect(
@@ -197,8 +254,7 @@ def collect(
"lineage_event_count": len(all_events),
"events": all_events,
}
with open(manifest_path, "w") as fh:
json.dump(manifest, fh, indent=2)
write_json_file(manifest_path, manifest)
log.info("Manifest written to %s (%d events)", manifest_path, len(all_events))
return all_events
@@ -206,7 +262,6 @@ def collect(
def main() -> None:
parser = argparse.ArgumentParser(description="Collect Redshift lineage to a manifest file")
parser.add_argument("--host", default=os.getenv("REDSHIFT_HOST")) # ← SUBSTITUTE
parser.add_argument("--db", default=os.getenv("REDSHIFT_DB")) # ← SUBSTITUTE
parser.add_argument("--user", default=os.getenv("REDSHIFT_USER")) # ← SUBSTITUTE
parser.add_argument("--password", default=os.getenv("REDSHIFT_PASSWORD")) # ← SUBSTITUTE
@@ -215,13 +270,21 @@ def main() -> None:
parser.add_argument("--manifest", default="manifest_lineage.json")
args = parser.parse_args()
required = ["host", "db", "user", "password"]
required = ["db", "user", "password"]
missing = [k for k in required if getattr(args, k) is None]
if missing:
parser.error(f"Missing required arguments/env vars: {missing}")
redshift_host = os.getenv("REDSHIFT_HOST")
if not redshift_host:
parser.error("Missing required env var: REDSHIFT_HOST")
redshift_host = validate_redshift_host(
redshift_host,
allow_private=os.getenv("REDSHIFT_ALLOW_PRIVATE_HOST", "").lower() in {"1", "true", "yes"},
)
collect(
host=args.host,
host=redshift_host,
db=args.db,
user=args.user,
password=args.password,
@@ -20,14 +20,17 @@ Prerequisites:
from __future__ import annotations
import argparse
import ipaddress
import json
import logging
import os
import re
from datetime import datetime, timezone
from typing import Any
import psycopg2
import psycopg2.extras
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, write_json_file
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
@@ -43,6 +46,59 @@ SCHEMA_EXCLUSIONS: set[str] = { # ← SUBSTITUTE: add internal schemas
"catalog_history",
}
_ALLOWED_REDSHIFT_HOST_RE = re.compile(
r"^[a-z0-9][a-z0-9.-]*\.(?:redshift|redshift-serverless)\.[a-z0-9-]+\.amazonaws\.com(?:\.cn)?$",
re.IGNORECASE,
)
def _sql_literal(value: str) -> str:
return "'" + str(value).replace("'", "''") + "'"
def _explicitly_allowed_redshift_hosts() -> set[str]:
raw_hosts = os.getenv("REDSHIFT_ALLOWED_HOSTS", "")
return {host.strip().lower().rstrip(".") for host in raw_hosts.split(",") if host.strip()}
def validate_redshift_host(host: str, *, allow_private: bool = False) -> str:
value = str(host).strip()
if not value or any(part in value for part in ("/", "\\", "@", ":")):
raise ValueError(f"Invalid Redshift host: {host!r}")
hostname = value.lower().rstrip(".")
allowed_hosts = _explicitly_allowed_redshift_hosts()
try:
address = ipaddress.ip_address(value)
except ValueError:
if hostname in allowed_hosts:
return hostname
match = _ALLOWED_REDSHIFT_HOST_RE.fullmatch(hostname)
if match:
return match.group(0)
raise ValueError(
"Redshift host must be an AWS Redshift endpoint or be listed in REDSHIFT_ALLOWED_HOSTS"
)
if hostname not in allowed_hosts:
raise ValueError("Redshift IP hosts must be listed in REDSHIFT_ALLOWED_HOSTS")
blocked = (
address.is_loopback
or address.is_link_local
or address.is_multicast
or address.is_unspecified
or address.is_reserved
or (address.is_private and not allow_private)
)
if blocked:
raise ValueError(f"Redshift host address is not allowed: {host!r}")
return str(address)
def _bounded_int(value: int, field: str, *, minimum: int, maximum: int) -> int:
value = int(value)
if value < minimum or value > maximum:
raise ValueError(f"{field} must be between {minimum} and {maximum}")
return value
def _check_available_memory(min_gb: float = 2.0) -> None:
"""Warn if available memory is below the threshold."""
@@ -85,7 +141,7 @@ def collect_databases(cursor: Any) -> list[str]:
def collect_tables(cursor: Any, db: str) -> list[dict[str, Any]]:
schema_list = ", ".join(f"'{s}'" for s in SCHEMA_EXCLUSIONS)
schema_list = ", ".join(_sql_literal(s) for s in sorted(SCHEMA_EXCLUSIONS))
return _dictfetch(
cursor,
f"""
@@ -129,6 +185,9 @@ def collect(
) -> list[dict[str, Any]]:
"""Connect to Redshift, collect metadata, write a JSON manifest, and return asset dicts."""
_check_available_memory()
allow_private_host = os.getenv("REDSHIFT_ALLOW_PRIVATE_HOST", "").lower() in {"1", "true", "yes"}
host = validate_redshift_host(host, allow_private=allow_private_host)
port = _bounded_int(port, "port", minimum=1, maximum=65535)
collected_at = datetime.now(timezone.utc).isoformat()
assets: list[dict[str, Any]] = []
@@ -183,8 +242,7 @@ def collect(
"asset_count": len(assets),
"assets": assets,
}
with open(manifest_path, "w") as fh:
json.dump(manifest, fh, indent=2)
write_json_file(manifest_path, manifest)
log.info("Manifest written to %s (%d assets)", manifest_path, len(assets))
return assets
@@ -192,7 +250,6 @@ def collect(
def main() -> None:
parser = argparse.ArgumentParser(description="Collect Redshift metadata to a manifest file")
parser.add_argument("--host", default=os.getenv("REDSHIFT_HOST")) # ← SUBSTITUTE
parser.add_argument("--db", default=os.getenv("REDSHIFT_DB")) # ← SUBSTITUTE
parser.add_argument("--user", default=os.getenv("REDSHIFT_USER")) # ← SUBSTITUTE
parser.add_argument("--password", default=os.getenv("REDSHIFT_PASSWORD")) # ← SUBSTITUTE
@@ -200,13 +257,21 @@ def main() -> None:
parser.add_argument("--manifest", default="manifest_metadata.json")
args = parser.parse_args()
required = ["host", "db", "user", "password"]
required = ["db", "user", "password"]
missing = [k for k in required if getattr(args, k) is None]
if missing:
parser.error(f"Missing required arguments/env vars: {missing}")
redshift_host = os.getenv("REDSHIFT_HOST")
if not redshift_host:
parser.error("Missing required env var: REDSHIFT_HOST")
redshift_host = validate_redshift_host(
redshift_host,
allow_private=os.getenv("REDSHIFT_ALLOW_PRIVATE_HOST", "").lower() in {"1", "true", "yes"},
)
collect(
host=args.host,
host=redshift_host,
db=args.db,
user=args.user,
password=args.password,
@@ -20,13 +20,16 @@ Prerequisites:
from __future__ import annotations
import argparse
import ipaddress
import json
import logging
import os
import re
from datetime import datetime, timezone
from typing import Any
import psycopg2
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, write_json_file
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
@@ -38,6 +41,55 @@ LOOKBACK_LAG_HOURS: int = int(os.getenv("LOOKBACK_LAG_HOURS", "1")) # ← SUBSTI
BATCH_SIZE: int = int(os.getenv("BATCH_SIZE", "200")) # ← SUBSTITUTE
MAX_QUERIES: int = int(os.getenv("MAX_QUERIES", "10000")) # ← SUBSTITUTE
_ALLOWED_REDSHIFT_HOST_RE = re.compile(
r"^[a-z0-9][a-z0-9.-]*\.(?:redshift|redshift-serverless)\.[a-z0-9-]+\.amazonaws\.com(?:\.cn)?$",
re.IGNORECASE,
)
def _explicitly_allowed_redshift_hosts() -> set[str]:
raw_hosts = os.getenv("REDSHIFT_ALLOWED_HOSTS", "")
return {host.strip().lower().rstrip(".") for host in raw_hosts.split(",") if host.strip()}
def validate_redshift_host(host: str, *, allow_private: bool = False) -> str:
value = str(host).strip()
if not value or any(part in value for part in ("/", "\\", "@", ":")):
raise ValueError(f"Invalid Redshift host: {host!r}")
hostname = value.lower().rstrip(".")
allowed_hosts = _explicitly_allowed_redshift_hosts()
try:
address = ipaddress.ip_address(value)
except ValueError:
if hostname in allowed_hosts:
return hostname
match = _ALLOWED_REDSHIFT_HOST_RE.fullmatch(hostname)
if match:
return match.group(0)
raise ValueError(
"Redshift host must be an AWS Redshift endpoint or be listed in REDSHIFT_ALLOWED_HOSTS"
)
if hostname not in allowed_hosts:
raise ValueError("Redshift IP hosts must be listed in REDSHIFT_ALLOWED_HOSTS")
blocked = (
address.is_loopback
or address.is_link_local
or address.is_multicast
or address.is_unspecified
or address.is_reserved
or (address.is_private and not allow_private)
)
if blocked:
raise ValueError(f"Redshift host address is not allowed: {host!r}")
return str(address)
def _bounded_int(value: int, field: str, *, minimum: int, maximum: int) -> int:
value = int(value)
if value < minimum or value > maximum:
raise ValueError(f"{field} must be between {minimum} and {maximum}")
return value
def _check_available_memory(min_gb: float = 2.0) -> None:
"""Warn if available memory is below the threshold."""
@@ -88,9 +140,12 @@ def fetch_query_metadata(
max_queries: int,
) -> list[dict[str, Any]]:
"""Fetch query execution metadata from sys_query_history."""
lookback_hours = _bounded_int(lookback_hours, "lookback_hours", minimum=1, maximum=24 * 31)
lag_hours = _bounded_int(lag_hours, "lag_hours", minimum=0, maximum=24 * 7)
max_queries = _bounded_int(max_queries, "max_queries", minimum=1, maximum=100000)
return _dictfetch(
cursor,
f"""
"""
SELECT
query_id,
start_time,
@@ -100,12 +155,13 @@ def fetch_query_metadata(
database_name,
elapsed_time
FROM sys_query_history
WHERE start_time >= DATEADD(hour, -{lookback_hours}, GETDATE())
AND start_time < DATEADD(hour, -{lag_hours}, GETDATE())
WHERE start_time >= DATEADD(hour, -%s, GETDATE())
AND start_time < DATEADD(hour, -%s, GETDATE())
AND status = 'success'
ORDER BY start_time
LIMIT {max_queries}
LIMIT %s
""", # ← SUBSTITUTE: add AND database_name = 'mydb' to narrow scope
(lookback_hours, lag_hours, max_queries),
)
@@ -114,11 +170,10 @@ def fetch_query_texts_batch(cursor: Any, query_ids: list[int]) -> dict[int, str]
if not query_ids:
return {}
# Build a VALUES list for the IN clause to avoid large parameter arrays
id_list = ", ".join(str(qid) for qid in query_ids)
query_ids = [_bounded_int(qid, "query_id", minimum=1, maximum=2**63 - 1) for qid in query_ids]
rows = _dictfetch(
cursor,
f"""
"""
SELECT
query_id,
LISTAGG(
@@ -126,9 +181,10 @@ def fetch_query_texts_batch(cursor: Any, query_ids: list[int]) -> dict[int, str]
''
) WITHIN GROUP (ORDER BY sequence) AS query_text
FROM sys_querytext
WHERE query_id IN ({id_list})
WHERE query_id = ANY(%s)
GROUP BY query_id
""",
(query_ids,),
)
return {r["query_id"]: r["query_text"] for r in rows if r.get("query_text")}
@@ -147,6 +203,13 @@ def collect(
) -> list[dict[str, Any]]:
"""Connect to Redshift, collect query logs, write a JSON manifest, and return entries."""
_check_available_memory()
allow_private_host = os.getenv("REDSHIFT_ALLOW_PRIVATE_HOST", "").lower() in {"1", "true", "yes"}
host = validate_redshift_host(host, allow_private=allow_private_host)
port = _bounded_int(port, "port", minimum=1, maximum=65535)
lookback_hours = _bounded_int(lookback_hours, "lookback_hours", minimum=1, maximum=24 * 31)
lookback_lag_hours = _bounded_int(lookback_lag_hours, "lookback_lag_hours", minimum=0, maximum=24 * 7)
batch_size = _bounded_int(batch_size, "batch_size", minimum=1, maximum=10000)
max_queries = _bounded_int(max_queries, "max_queries", minimum=1, maximum=100000)
collected_at = datetime.now(timezone.utc).isoformat()
conn = psycopg2.connect(
@@ -195,8 +258,7 @@ def collect(
"query_log_count": len(entries),
"entries": entries,
}
with open(manifest_path, "w") as fh:
json.dump(manifest, fh, indent=2)
write_json_file(manifest_path, manifest)
log.info("Manifest written to %s (%d entries)", manifest_path, len(entries))
return entries
@@ -204,7 +266,6 @@ def collect(
def main() -> None:
parser = argparse.ArgumentParser(description="Collect Redshift query logs to a manifest file")
parser.add_argument("--host", default=os.getenv("REDSHIFT_HOST")) # ← SUBSTITUTE
parser.add_argument("--db", default=os.getenv("REDSHIFT_DB")) # ← SUBSTITUTE
parser.add_argument("--user", default=os.getenv("REDSHIFT_USER")) # ← SUBSTITUTE
parser.add_argument("--password", default=os.getenv("REDSHIFT_PASSWORD")) # ← SUBSTITUTE
@@ -216,13 +277,21 @@ def main() -> None:
parser.add_argument("--manifest", default="manifest_query_logs.json")
args = parser.parse_args()
required = ["host", "db", "user", "password"]
required = ["db", "user", "password"]
missing = [k for k in required if getattr(args, k) is None]
if missing:
parser.error(f"Missing required arguments/env vars: {missing}")
redshift_host = os.getenv("REDSHIFT_HOST")
if not redshift_host:
parser.error("Missing required env var: REDSHIFT_HOST")
redshift_host = validate_redshift_host(
redshift_host,
allow_private=os.getenv("REDSHIFT_ALLOW_PRIVATE_HOST", "").lower() in {"1", "true", "yes"},
)
collect(
host=args.host,
host=redshift_host,
db=args.db,
user=args.user,
password=args.password,
@@ -30,6 +30,7 @@ from pycarlo.features.ingestion.models import (
LineageAssetRef,
LineageEvent,
)
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, read_json_file, write_json_file
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
@@ -68,8 +69,7 @@ def push(
Returns a summary dict with invocation IDs and counts.
"""
with open(manifest_path) as fh:
manifest = json.load(fh)
manifest = read_json_file(manifest_path)
event_dicts: list[dict[str, Any]] = manifest["events"]
events = [_event_from_dict(d) for d in event_dicts]
@@ -87,8 +87,7 @@ def push(
"batch_size": batch_size,
}
push_manifest_path = manifest_path.replace(".json", "_push_result.json")
with open(push_manifest_path, "w") as fh:
json.dump(summary, fh, indent=2)
write_json_file(push_manifest_path, summary)
return summary
# Split into batches
@@ -144,8 +143,7 @@ def push(
}
push_manifest_path = manifest_path.replace(".json", "_push_result.json")
with open(push_manifest_path, "w") as fh:
json.dump(summary, fh, indent=2)
write_json_file(push_manifest_path, summary)
log.info("Push result written to %s", push_manifest_path)
return summary
@@ -33,6 +33,7 @@ from pycarlo.features.ingestion.models import (
AssetVolume,
RelationalAsset,
)
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, read_json_file, write_json_file
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
@@ -88,8 +89,7 @@ def push(
Returns a summary dict with invocation IDs and counts.
"""
with open(manifest_path) as fh:
manifest = json.load(fh)
manifest = read_json_file(manifest_path)
asset_dicts: list[dict[str, Any]] = manifest["assets"]
assets = [_asset_from_dict(d) for d in asset_dicts]
@@ -144,8 +144,7 @@ def push(
}
push_manifest_path = manifest_path.replace(".json", "_push_result.json")
with open(push_manifest_path, "w") as fh:
json.dump(summary, fh, indent=2)
write_json_file(push_manifest_path, summary)
log.info("Push result written to %s", push_manifest_path)
return summary
@@ -28,6 +28,7 @@ from dateutil.parser import isoparse
from pycarlo.core import Client, Session
from pycarlo.features.ingestion import IngestionService
from pycarlo.features.ingestion.models import QueryLogEntry
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, read_json_file, write_json_file
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
@@ -88,8 +89,7 @@ def push(
Returns a summary dict with invocation IDs and counts.
"""
with open(manifest_path) as fh:
manifest = json.load(fh)
manifest = read_json_file(manifest_path)
entry_dicts: list[dict[str, Any]] = manifest["entries"]
entries = _build_query_log_entries(entry_dicts)
@@ -107,8 +107,7 @@ def push(
"batch_size": batch_size,
}
push_manifest_path = manifest_path.replace(".json", "_push_result.json")
with open(push_manifest_path, "w") as fh:
json.dump(summary, fh, indent=2)
write_json_file(push_manifest_path, summary)
return summary
# Split into batches
@@ -162,8 +161,7 @@ def push(
}
push_manifest_path = manifest_path.replace(".json", "_push_result.json")
with open(push_manifest_path, "w") as fh:
json.dump(summary, fh, indent=2)
write_json_file(push_manifest_path, summary)
log.info("Push result written to %s", push_manifest_path)
return summary
@@ -0,0 +1,66 @@
"""Path guards for local Monte Carlo template manifests."""
from __future__ import annotations
import json
import os
from pathlib import Path
def _allow_external_paths() -> bool:
return os.getenv("MCD_ALLOW_EXTERNAL_PATHS", "").lower() in {"1", "true", "yes"}
def _is_relative_to(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
def _resolve_local_path(raw_path: str, *, expect_file: bool = False, create_parent: bool = False) -> Path:
value = str(raw_path).strip()
if not value or "\0" in value:
raise ValueError("Path must be a non-empty filesystem path")
base = Path.cwd().resolve()
candidate = Path(value).expanduser()
resolved = (candidate if candidate.is_absolute() else base / candidate).resolve()
if not _allow_external_paths() and not _is_relative_to(resolved, base):
raise ValueError(f"Path must stay under the current working directory: {raw_path!r}")
if expect_file and not resolved.is_file():
raise FileNotFoundError(f"Input file not found: {resolved}")
if create_parent:
resolved.parent.mkdir(parents=True, exist_ok=True)
return resolved
def safe_input_json_path(raw_path: str) -> Path:
path = _resolve_local_path(raw_path, expect_file=True)
if path.suffix.lower() != ".json":
raise ValueError(f"Input manifest must be a .json file: {path}")
return path
def safe_output_json_path(raw_path: str) -> Path:
path = _resolve_local_path(raw_path, create_parent=True)
if path.suffix.lower() != ".json":
raise ValueError(f"Output manifest must be a .json file: {path}")
return path
def safe_existing_directory(raw_path: str) -> Path:
path = _resolve_local_path(raw_path)
if not path.is_dir():
raise NotADirectoryError(f"Directory not found: {path}")
return path
def read_json_file(raw_path: str):
with safe_input_json_path(raw_path).open() as fh:
return json.load(fh)
def write_json_file(raw_path: str, payload, *, indent: int = 2, default=None) -> None:
with safe_output_json_path(raw_path).open("w") as fh:
json.dump(payload, fh, indent=indent, default=default)
@@ -40,6 +40,7 @@ import os
from collect_lineage import collect, _LOOKBACK_HOURS
from push_lineage import push, _BATCH_SIZE
from _safe_paths import safe_output_json_path
def main() -> None:
@@ -126,6 +127,9 @@ def main() -> None:
if missing:
parser.error(f"Missing required arguments: {', '.join(missing)}")
output_path = str(safe_output_json_path(args.output_file))
push_result_path = str(safe_output_json_path(args.push_result_file))
# Step 1: Collect
collect(
account=args.account,
@@ -134,17 +138,17 @@ def main() -> None:
warehouse=args.warehouse,
lookback_hours=args.lookback_hours,
column_lineage=args.column_lineage,
output_file=args.output_file,
output_file=output_path,
)
# Step 2: Push
push(
input_file=args.output_file,
input_file=output_path,
resource_uuid=args.resource_uuid,
key_id=args.key_id,
key_token=args.key_token,
batch_size=args.batch_size,
output_file=args.push_result_file,
output_file=push_result_path,
)
print("Done.")
@@ -34,8 +34,9 @@ Usage
import argparse
import os
from collect_metadata import collect
from collect_metadata import _quote_identifier, collect
from push_metadata import push, _BATCH_SIZE
from _safe_paths import safe_output_json_path
def main() -> None:
@@ -111,23 +112,28 @@ def main() -> None:
if missing:
parser.error(f"Missing required arguments: {', '.join(missing)}")
output_path = str(safe_output_json_path(args.output_file))
push_result_path = str(safe_output_json_path(args.push_result_file))
_quote_identifier(args.warehouse)
# Step 1: Collect
collect(
account=args.account,
user=args.user,
password=args.password,
warehouse=args.warehouse,
output_file=args.output_file,
output_file=output_path,
)
# Step 2: Push
push(
input_file=args.output_file,
input_file=output_path,
resource_uuid=args.resource_uuid,
key_id=args.key_id,
key_token=args.key_token,
batch_size=args.batch_size,
output_file=args.push_result_file,
output_file=push_result_path,
)
print("Done.")
@@ -36,6 +36,7 @@ import os
from collect_query_logs import collect
from push_query_logs import push, _BATCH_SIZE
from _safe_paths import safe_output_json_path
def main() -> None:
@@ -111,23 +112,26 @@ def main() -> None:
if missing:
parser.error(f"Missing required arguments: {', '.join(missing)}")
output_path = str(safe_output_json_path(args.output_file))
push_result_path = str(safe_output_json_path(args.push_result_file))
# Step 1: Collect
collect(
account=args.account,
user=args.user,
password=args.password,
warehouse=args.warehouse,
output_file=args.output_file,
output_file=output_path,
)
# Step 2: Push
push(
input_file=args.output_file,
input_file=output_path,
resource_uuid=args.resource_uuid,
key_id=args.key_id,
key_token=args.key_token,
batch_size=args.batch_size,
output_file=args.push_result_file,
output_file=push_result_path,
)
print("Done.")
@@ -43,6 +43,7 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone
import snowflake.connector
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, write_json_file
# ← SUBSTITUTE: set RESOURCE_TYPE to match your Monte Carlo connection type
RESOURCE_TYPE = "snowflake"
@@ -70,6 +71,13 @@ def _check_available_memory(min_gb: float = 2.0) -> None:
# ← SUBSTITUTE: adjust the lookback window to match your collection cadence
_LOOKBACK_HOURS = 24
def _bounded_int(value: int, field: str, *, minimum: int, maximum: int) -> int:
value = int(value)
if value < minimum or value > maximum:
raise ValueError(f"{field} must be between {minimum} and {maximum}")
return value
# Regex for CTAS: CREATE [OR REPLACE] [TRANSIENT] TABLE [IF NOT EXISTS] [db.][schema.]table AS SELECT
_CTAS_RE = re.compile(
r"CREATE\s+(?:OR\s+REPLACE\s+)?(?:TRANSIENT\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?"
@@ -181,17 +189,19 @@ def _parse_edges(rows: list[dict]) -> list[_LineageEdge]:
def _fetch_query_history(conn, lookback_hours: int) -> list[dict]:
lookback_hours = _bounded_int(lookback_hours, "lookback_hours", minimum=1, maximum=24 * 31)
cursor = conn.cursor()
cursor.execute(
f"""
"""
SELECT QUERY_ID, QUERY_TEXT, START_TIME, END_TIME, USER_NAME, DATABASE_NAME, EXECUTION_STATUS
FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY
WHERE START_TIME >= DATEADD(hour, -{lookback_hours}, CURRENT_TIMESTAMP())
WHERE START_TIME >= DATEADD(hour, -%s, CURRENT_TIMESTAMP())
AND EXECUTION_STATUS = 'SUCCESS'
AND QUERY_TYPE IN ('CREATE_TABLE_AS_SELECT', 'INSERT', 'MERGE', 'CREATE_VIEW')
ORDER BY START_TIME
LIMIT 50000
"""
""",
(lookback_hours,),
# ← SUBSTITUTE: adjust QUERY_TYPE list, LIMIT, or add a WHERE clause to scope to specific databases
)
columns = [col[0] for col in cursor.description]
@@ -220,6 +230,7 @@ def collect(
Returns the manifest dict.
"""
_check_available_memory()
lookback_hours = _bounded_int(lookback_hours, "lookback_hours", minimum=1, maximum=24 * 31)
print(f"Connecting to Snowflake account: {account} ...")
conn = snowflake.connector.connect(
account=account,
@@ -241,8 +252,7 @@ def collect(
"column_lineage": column_lineage,
"edges": [],
}
with open(output_file, "w") as fh:
json.dump(manifest, fh, indent=2)
write_json_file(output_file, manifest)
return manifest
edges = _parse_edges(rows)
@@ -271,8 +281,7 @@ def collect(
for e in edges
],
}
with open(output_file, "w") as fh:
json.dump(manifest, fh, indent=2)
write_json_file(output_file, manifest)
print(f"Lineage manifest written to {output_file}")
return manifest
@@ -35,6 +35,7 @@ import os
from datetime import datetime, timezone
import snowflake.connector
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, write_json_file
# ← SUBSTITUTE: set RESOURCE_TYPE to match your Monte Carlo connection type
RESOURCE_TYPE = "snowflake"
@@ -78,6 +79,13 @@ _TABLE_TYPE_MAP = {
}
def _quote_identifier(identifier: str) -> str:
value = str(identifier).strip()
if not value:
raise ValueError("Identifier must not be empty")
return '"' + value.replace('"', '""') + '"'
def _normalize_table_type(raw_type: str | None) -> str:
"""Map Snowflake's TABLE_TYPE value to MC-accepted 'TABLE' or 'VIEW'."""
if not raw_type:
@@ -115,7 +123,7 @@ def _collect_assets(conn) -> list[dict]:
for db in databases:
# --- Discover schemas in each database ---
try:
cursor.execute(f'SHOW SCHEMAS IN DATABASE "{db}"')
cursor.execute("SHOW SCHEMAS IN DATABASE IDENTIFIER(%s)", (db,))
except Exception as exc:
print(f" WARNING: could not list schemas in {db}: {exc}")
continue
@@ -142,10 +150,11 @@ def _collect_assets(conn) -> list[dict]:
BYTES,
LAST_ALTERED,
COMMENT
FROM "{db}".INFORMATION_SCHEMA.TABLES
FROM IDENTIFIER(%s)
WHERE TABLE_SCHEMA != 'INFORMATION_SCHEMA'
ORDER BY TABLE_SCHEMA, TABLE_NAME
"""
""",
(f"{db}.INFORMATION_SCHEMA.TABLES",),
)
except Exception as exc:
print(f" WARNING: could not query INFORMATION_SCHEMA.TABLES in {db}: {exc}")
@@ -172,11 +181,11 @@ def _collect_assets(conn) -> list[dict]:
cursor.execute(
f"""
SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, COMMENT
FROM "{db}".INFORMATION_SCHEMA.COLUMNS
FROM IDENTIFIER(%s)
WHERE TABLE_SCHEMA = %s
ORDER BY TABLE_NAME, ORDINAL_POSITION
""",
(schema,),
(f"{db}.INFORMATION_SCHEMA.COLUMNS", schema),
)
except Exception as exc:
print(f" WARNING: could not fetch columns for {db}.{schema}: {exc}")
@@ -264,8 +273,7 @@ def collect(
"collected_at": datetime.now(tz=timezone.utc).isoformat(),
"assets": assets,
}
with open(output_file, "w") as fh:
json.dump(manifest, fh, indent=2)
write_json_file(output_file, manifest)
print(f"Asset manifest written to {output_file}")
return manifest
@@ -35,6 +35,7 @@ import os
from datetime import datetime, timezone
import snowflake.connector
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, write_json_file
# ← SUBSTITUTE: set LOG_TYPE to match your warehouse type (query logs use log_type, not resource_type)
LOG_TYPE = "snowflake"
@@ -162,8 +163,7 @@ def collect(
"window_end": None,
"queries": [],
}
with open(output_file, "w") as fh:
json.dump(manifest, fh, indent=2, default=str)
write_json_file(output_file, manifest, default=str)
return manifest
start_times = [r["START_TIME"] for r in rows if r.get("START_TIME") is not None]
@@ -189,8 +189,7 @@ def collect(
for r in rows
],
}
with open(output_file, "w") as fh:
json.dump(manifest, fh, indent=2, default=str)
write_json_file(output_file, manifest, default=str)
print(f"Query log manifest written to {output_file}")
return manifest
@@ -43,6 +43,7 @@ from pycarlo.features.ingestion.models import (
LineageAssetRef,
LineageEvent,
)
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, read_json_file, write_json_file
# ← SUBSTITUTE: set RESOURCE_TYPE to match your Monte Carlo connection type
RESOURCE_TYPE = "snowflake"
@@ -155,8 +156,7 @@ def push(
Returns a result dict with invocation IDs for each batch.
"""
with open(input_file) as fh:
manifest = json.load(fh)
manifest = read_json_file(input_file)
edges = manifest.get("edges", [])
resource_type = manifest.get("resource_type", RESOURCE_TYPE)
@@ -182,8 +182,7 @@ def push(
"batch_count": 0,
"batch_size": batch_size,
}
with open(output_file, "w") as fh:
json.dump(push_result, fh, indent=2)
write_json_file(output_file, push_result)
return push_result
# Split into batches
@@ -236,8 +235,7 @@ def push(
"batch_size": batch_size,
"edges": edges, # preserve for downstream validation
}
with open(output_file, "w") as fh:
json.dump(push_result, fh, indent=2)
write_json_file(output_file, push_result)
print(f"Push result written to {output_file}")
return push_result
@@ -42,6 +42,7 @@ from pycarlo.features.ingestion.models import (
AssetVolume,
RelationalAsset,
)
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, read_json_file, write_json_file
# ← SUBSTITUTE: set RESOURCE_TYPE to match your Monte Carlo connection type
RESOURCE_TYPE = "snowflake"
@@ -102,8 +103,7 @@ def push(
Returns a result dict with invocation IDs for each batch.
"""
with open(input_file) as fh:
manifest = json.load(fh)
manifest = read_json_file(input_file)
asset_dicts = manifest.get("assets", [])
resource_type = manifest.get("resource_type", RESOURCE_TYPE)
@@ -157,8 +157,7 @@ def push(
"batch_count": total_batches,
"batch_size": batch_size,
}
with open(output_file, "w") as fh:
json.dump(push_result, fh, indent=2)
write_json_file(output_file, push_result)
print(f"Push result written to {output_file}")
return push_result
@@ -37,6 +37,7 @@ from dateutil.parser import isoparse
from pycarlo.core import Client, Session
from pycarlo.features.ingestion import IngestionService
from pycarlo.features.ingestion.models import QueryLogEntry
from _safe_paths import safe_existing_directory, safe_input_json_path, safe_output_json_path, read_json_file, write_json_file
# ← SUBSTITUTE: set LOG_TYPE to match your warehouse type (query logs use log_type, not resource_type)
LOG_TYPE = "snowflake"
@@ -107,8 +108,7 @@ def push(
Returns a result dict with invocation IDs for each batch.
"""
with open(input_file) as fh:
manifest = json.load(fh)
manifest = read_json_file(input_file)
queries = manifest.get("queries", [])
log_type = manifest.get("log_type", LOG_TYPE)
@@ -126,8 +126,7 @@ def push(
"batch_count": 0,
"batch_size": batch_size,
}
with open(output_file, "w") as fh:
json.dump(push_result, fh, indent=2)
write_json_file(output_file, push_result)
return push_result
# Split into batches
@@ -177,8 +176,7 @@ def push(
"batch_count": total_batches,
"batch_size": batch_size,
}
with open(output_file, "w") as fh:
json.dump(push_result, fh, indent=2)
write_json_file(output_file, push_result)
print(f"Push result written to {output_file}")
return push_result
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Smoke tests for Monte Carlo template path guards."""
from __future__ import annotations
import importlib.util
import os
from pathlib import Path
from tempfile import TemporaryDirectory
TEMPLATE_DIRS = [
"bigquery",
"bigquery-iceberg",
"databricks",
"hive",
"redshift",
"snowflake",
]
def load_safe_paths(template_dir: Path):
module_path = template_dir / "_safe_paths.py"
spec = importlib.util.spec_from_file_location(f"{template_dir.name}_safe_paths", module_path)
if spec is None or spec.loader is None:
raise AssertionError(f"Could not load {module_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def assert_raises(fn, exc_type: type[BaseException]) -> None:
try:
fn()
except exc_type:
return
raise AssertionError(f"Expected {exc_type.__name__}")
def test_template_dir(template_dir: Path) -> None:
safe_paths = load_safe_paths(template_dir)
with TemporaryDirectory() as tmp:
previous_cwd = Path.cwd()
try:
os.chdir(tmp)
out_path = safe_paths.safe_output_json_path("out/manifest.json")
assert out_path == Path(tmp, "out", "manifest.json").resolve()
assert out_path.parent.is_dir()
out_path.write_text("{}", encoding="utf-8")
assert safe_paths.safe_input_json_path("out/manifest.json") == out_path
Path("logs").mkdir()
assert safe_paths.safe_existing_directory("logs") == Path(tmp, "logs").resolve()
assert_raises(lambda: safe_paths.safe_output_json_path("../escape.json"), ValueError)
assert_raises(lambda: safe_paths.safe_output_json_path("manifest.txt"), ValueError)
assert_raises(lambda: safe_paths.safe_input_json_path("missing.json"), FileNotFoundError)
finally:
os.chdir(previous_cwd)
def main() -> None:
root = Path(__file__).resolve().parent / "templates"
for name in TEMPLATE_DIRS:
test_template_dir(root / name)
print(f"PASS {name}")
if __name__ == "__main__":
main()