📦 deps(thirdparty): update snapshots
This commit is contained in:
+66
@@ -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)
|
||||
+12
-4
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+7
-3
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+32
-9
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+3
-4
@@ -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
|
||||
|
||||
+4
-6
@@ -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
|
||||
|
||||
+66
@@ -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)
|
||||
+12
-4
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+7
-3
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+7
-3
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+24
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+4
-6
@@ -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
|
||||
|
||||
+3
-4
@@ -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
|
||||
|
||||
+4
-6
@@ -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
|
||||
|
||||
+66
@@ -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)
|
||||
+5
-2
@@ -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,
|
||||
|
||||
+8
-3
@@ -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,
|
||||
|
||||
+5
-2
@@ -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,
|
||||
|
||||
+12
-2
@@ -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
|
||||
|
||||
+38
-14
@@ -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
|
||||
|
||||
+15
-2
@@ -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
|
||||
|
||||
+3
-4
@@ -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
|
||||
|
||||
+3
-4
@@ -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
|
||||
|
||||
+4
-6
@@ -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
|
||||
|
||||
+66
@@ -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)
|
||||
+2
-2
@@ -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.")
|
||||
|
||||
|
||||
+5
-3
@@ -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.")
|
||||
|
||||
|
||||
+2
-2
@@ -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.")
|
||||
|
||||
|
||||
+2
-2
@@ -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.")
|
||||
|
||||
|
||||
+61
-9
@@ -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.")
|
||||
|
||||
|
||||
+3
-3
@@ -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.")
|
||||
|
||||
|
||||
+3
-4
@@ -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.")
|
||||
|
||||
|
||||
+3
-4
@@ -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.")
|
||||
|
||||
|
||||
+3
-4
@@ -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.")
|
||||
|
||||
|
||||
+66
@@ -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)
|
||||
+18
-6
@@ -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,
|
||||
|
||||
+17
-6
@@ -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,
|
||||
|
||||
+29
-6
@@ -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,
|
||||
|
||||
+70
-7
@@ -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,
|
||||
|
||||
+71
-6
@@ -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,
|
||||
|
||||
+82
-13
@@ -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,
|
||||
|
||||
+4
-6
@@ -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
|
||||
|
||||
+3
-4
@@ -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
|
||||
|
||||
+4
-6
@@ -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
|
||||
|
||||
+66
@@ -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)
|
||||
+7
-3
@@ -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.")
|
||||
|
||||
+10
-4
@@ -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.")
|
||||
|
||||
+7
-3
@@ -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.")
|
||||
|
||||
+16
-7
@@ -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
|
||||
|
||||
+15
-7
@@ -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
|
||||
|
||||
+3
-4
@@ -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
|
||||
|
||||
+4
-6
@@ -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
|
||||
|
||||
+3
-4
@@ -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
|
||||
|
||||
+4
-6
@@ -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
|
||||
|
||||
+71
@@ -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()
|
||||
Reference in New Issue
Block a user