det-core 0.2.2__tar.gz → 0.2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {det_core-0.2.2 → det_core-0.2.3}/PKG-INFO +3 -1
- {det_core-0.2.2 → det_core-0.2.3}/pyproject.toml +6 -1
- {det_core-0.2.2 → det_core-0.2.3}/src/det/__init__.py +1 -1
- {det_core-0.2.2 → det_core-0.2.3}/src/det/api.py +189 -3
- {det_core-0.2.2 → det_core-0.2.3}/src/det/catalog.py +166 -11
- {det_core-0.2.2 → det_core-0.2.3}/src/det/connectors.py +227 -14
- {det_core-0.2.2 → det_core-0.2.3}/src/det/gui.py +36 -1
- {det_core-0.2.2 → det_core-0.2.3}/src/det/runner.py +105 -11
- {det_core-0.2.2 → det_core-0.2.3}/src/det/ui/app.js +1217 -118
- {det_core-0.2.2 → det_core-0.2.3}/src/det/ui/index.html +5 -4
- {det_core-0.2.2 → det_core-0.2.3}/src/det/ui/styles.css +666 -3
- {det_core-0.2.2 → det_core-0.2.3}/src/det/validator.py +42 -16
- {det_core-0.2.2 → det_core-0.2.3}/tests/test_api_endpoints.py +116 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/test_catalog.py +76 -1
- {det_core-0.2.2 → det_core-0.2.3}/tests/test_connectors.py +177 -3
- {det_core-0.2.2 → det_core-0.2.3}/tests/test_etl_and_monitoring.py +83 -0
- {det_core-0.2.2 → det_core-0.2.3}/.github/CODEOWNERS +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/.github/ISSUE_TEMPLATE/connector_request.yml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/.github/dependabot.yml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/.github/workflows/ci.yml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/.github/workflows/pages.yml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/.github/workflows/publish.yml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/.gitignore +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/CODE_OF_CONDUCT.md +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/CONTRIBUTING.md +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/LICENSE +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/README.md +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/SECURITY.md +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/media/det_linkedin_carousel.pdf +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/scripts/build_docs_site.py +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/scripts/generate_demo_data.py +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/scripts/live_browser_walkthrough.py +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/scripts/sync_wiki.py +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/cli.py +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/docs.py +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/engine.py +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/monitoring.py +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/pipeline.py +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/__init__.py +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/catalog/sample_warehouse.yaml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/pipelines/Sample_Warehouse_Pipelines/dim_customers.yaml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/pipelines/Sample_Warehouse_Pipelines/dim_stores.yaml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/pipelines/Sample_Warehouse_Pipelines/fct_daily_channel_kpis.yaml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/pipelines/Sample_Warehouse_Pipelines/fct_orders.yaml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/pipelines/Sample_Warehouse_Pipelines/mart_customer_rfm_loyalty.yaml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/pipelines/Sample_Warehouse_Pipelines/mart_product_supply_and_returns.yaml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/pipelines/Sample_Warehouse_Pipelines/mart_store_performance_vs_targets.yaml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/pipelines/Sample_Warehouse_Pipelines/stg_customers_cleaned.yaml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/pipelines/Sample_Warehouse_Pipelines/stg_orders_normalized.yaml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/pipelines/Sample_Warehouse_Pipelines/stg_stores_catalog.yaml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/source_data/customers/customers.csv +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/source_data/finance/financial_reference.xlsx +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/source_data/inventory/supplier_shipments/_delta_log/00000000000000000000.json +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/source_data/inventory/supplier_shipments/part-00000-129f1927-3bee-4e71-aba6-6aa1ee15ba6c-c000.snappy.parquet +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/source_data/orders/2026_Q1/orders_feb_mar.csv +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/source_data/orders/2026_Q1/orders_jan.csv +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/source_data/orders/2026_Q2/orders_apr_may.csv +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/source_data/orders/2026_Q2/orders_jun.csv +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/source_data/products/products.json +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/source_data/returns/order_returns.parquet +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/source_data/stores/store_catalog.xml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/source_data/web_analytics/clickstream_events.ndjson +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/ui/demo-data.js +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/ui/help-data.js +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/ui/mermaid.min.js +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/src/det/workspace.py +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/__init__.py +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/catalog/default.yaml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/pipelines/demo/summarize.yaml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/pipelines/retail_samples/multi_source_customers.yaml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/csv/customers.csv +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/csv/logs/year=2024/month=10/audit.csv +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/csv/logs/year=2024/month=11/audit.csv +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/csv/orders_daily/orders_2024_01.csv +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/csv/orders_daily/orders_2024_02.csv +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/csv/orders_daily/orders_2024_03.csv +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/csv/sales_daily/sales_2024_01.csv +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/csv/sales_daily/sales_2024_02.csv +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/delta/orders/_delta_log/00000000000000000000.json +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/delta/orders/part-00000-f5365dc8-1d4d-454c-aa41-c4ddfca78cdf-c000.snappy.parquet +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/delta/orders_lakehouse/_delta_log/00000000000000000000.json +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/delta/orders_lakehouse/_delta_log/00000000000000000001.json +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/delta/orders_lakehouse/part-00000-10d0329a-40dd-4d4d-bd16-ce24e3a17341-c000.snappy.parquet +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/delta/orders_lakehouse/part-00000-e1a3154d-2b6c-49b9-9344-630e123997a0-c000.snappy.parquet +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/destinations/csv_reports/customer_totals.csv +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/destinations/delta_dw/orders_delta/_delta_log/00000000000000000000.json +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/destinations/delta_dw/orders_delta/_delta_log/00000000000000000001.json +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/destinations/delta_dw/orders_delta/_delta_log/00000000000000000002.json +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/destinations/delta_dw/orders_delta/_delta_log/00000000000000000003.json +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/destinations/delta_dw/orders_delta/_delta_log/00000000000000000004.json +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/destinations/delta_dw/orders_delta/_delta_log/00000000000000000005.json +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/destinations/delta_dw/orders_delta/_delta_log/00000000000000000006.json +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/destinations/delta_dw/orders_delta/_delta_log/00000000000000000007.json +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/destinations/delta_dw/orders_delta/_delta_log/00000000000000000008.json +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/destinations/delta_dw/orders_delta/part-00000-3f7ee30a-c4fa-4f36-aa5e-1847210c0edc-c000.snappy.parquet +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/destinations/delta_dw/orders_delta/part-00000-499dda44-f966-464a-921b-cb80c81d90a6-c000.snappy.parquet +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/destinations/delta_dw/orders_delta/part-00000-53a1d512-6694-4cd9-a5d4-e1a142a3529d-c000.snappy.parquet +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/destinations/delta_dw/orders_delta/part-00000-694d5d14-665a-4a14-8dd4-9fc08f9c1461-c000.snappy.parquet +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/destinations/delta_dw/orders_delta/part-00000-891ab1e9-f639-4fe8-8112-ce26165d7b94-c000.snappy.parquet +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/destinations/delta_dw/orders_delta/part-00000-b74a842e-2096-44c9-bda3-3f506c57e47d-c000.snappy.parquet +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/destinations/delta_dw/orders_delta/part-00000-dcb107b1-5f98-49b9-8c41-be19f9de5fcc-c000.snappy.parquet +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/destinations/delta_dw/orders_delta/part-00000-e085df3c-c287-4e1d-b452-8c721e84cdcd-c000.snappy.parquet +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/destinations/delta_dw/orders_delta/part-00000-f0f973f4-8a77-43d4-be31-e1d50c798c54-c000.snappy.parquet +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/destinations/parquet_curated/curated_orders.parquet +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/excel/enterprise_data.xlsx +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/excel/financial_report.xlsx +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/json/clickstream/events_2024_01.ndjson +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/json/clickstream/events_2024_02.ndjson +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/json/products.json +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/json/stream_events.ndjson +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/parquet/events.parquet +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/parquet/lakehouse/region=EU/year=2024/data.parquet +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/parquet/lakehouse/region=US/year=2024/data.parquet +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/parquet/lakehouse_enterprise/year=2024/region=EU-WEST/data.parquet +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/parquet/lakehouse_enterprise/year=2024/region=US-EAST/data.parquet +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/parquet/lakehouse_enterprise/year=2024/region=US-WEST/data.parquet +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/parquet/telemetry/p1.parquet +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/parquet/telemetry/p2.parquet +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/sqlite/ecommerce.db +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/xml/book_catalog.xml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/xml/inventory/warehouse_a.xml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/xml/inventory/warehouse_b.xml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/xml/stores/store_catalog.xml +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/test_catalog_previews.py +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/test_docs.py +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/test_dw_joins.py +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/test_engine.py +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/test_real_samples.py +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/tests/test_sample_data.py +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/wiki/CLI-&-Web-IDE-Reference.md +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/wiki/Core-Directives-&-Architecture.md +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/wiki/Custom-Connectors-Guide.md +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/wiki/Home.md +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/wiki/Quickstart-&-Installation.md +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/wiki/SQL-ETL-Pipeline-Authoring.md +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/wiki/_Footer.md +0 -0
- {det_core-0.2.2 → det_core-0.2.3}/wiki/_Sidebar.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: det-core
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Fast, local-first SQL ETL workspace and pipeline orchestration engine powered by Apache DataFusion
|
|
5
5
|
Project-URL: Homepage, https://github.com/det-Labs/det
|
|
6
6
|
Project-URL: Repository, https://github.com/det-Labs/det
|
|
@@ -25,6 +25,8 @@ Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
|
25
25
|
Classifier: Topic :: Software Development :: Build Tools
|
|
26
26
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
27
27
|
Requires-Python: >=3.14
|
|
28
|
+
Requires-Dist: adbc-driver-manager>=1.0.0
|
|
29
|
+
Requires-Dist: adbc-driver-postgresql>=1.0.0
|
|
28
30
|
Requires-Dist: datafusion<56,>=55.0.0
|
|
29
31
|
Requires-Dist: deltalake>=1.0.0
|
|
30
32
|
Requires-Dist: fastexcel>=0.20.0
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "det-core"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.3"
|
|
8
8
|
description = "Fast, local-first SQL ETL workspace and pipeline orchestration engine powered by Apache DataFusion"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -52,6 +52,8 @@ dependencies = [
|
|
|
52
52
|
"fastexcel>=0.20.0",
|
|
53
53
|
"openpyxl>=3.1.0",
|
|
54
54
|
"deltalake>=1.0.0",
|
|
55
|
+
"adbc-driver-postgresql>=1.0.0",
|
|
56
|
+
"adbc-driver-manager>=1.0.0",
|
|
55
57
|
]
|
|
56
58
|
|
|
57
59
|
[project.optional-dependencies]
|
|
@@ -74,3 +76,6 @@ Issues = "https://github.com/det-Labs/det/issues"
|
|
|
74
76
|
|
|
75
77
|
[tool.hatch.build.targets.wheel]
|
|
76
78
|
packages = ["src/det"]
|
|
79
|
+
|
|
80
|
+
[tool.pytest.ini_options]
|
|
81
|
+
pythonpath = ["."]
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
from dataclasses import asdict, dataclass
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from typing import Any
|
|
6
|
+
from uuid import uuid4
|
|
6
7
|
|
|
7
8
|
from det import __version__
|
|
8
|
-
from det.catalog import CatalogStore
|
|
9
|
+
from det.catalog import CatalogStore, resolve_connection_options
|
|
9
10
|
from det.connectors import connector_metadata, scan_builtin
|
|
10
11
|
from det.engine import create_session_context, run_sql
|
|
11
12
|
from det.monitoring import MonitoringStore
|
|
@@ -90,11 +91,13 @@ def infer_connection_schema(connection_id: str) -> dict[str, Any]:
|
|
|
90
91
|
if not connection["is_source"]:
|
|
91
92
|
raise ValueError("Schema inference is available only for source connections.")
|
|
92
93
|
workspace = get_ui_state()["workspace"]
|
|
94
|
+
catalog_doc = store.read()["catalog"]
|
|
95
|
+
options = resolve_connection_options(connection, catalog_doc)
|
|
93
96
|
context = create_session_context()
|
|
94
97
|
dataframe = scan_builtin(
|
|
95
98
|
context,
|
|
96
99
|
connection["connector"],
|
|
97
|
-
|
|
100
|
+
options,
|
|
98
101
|
workspace,
|
|
99
102
|
)
|
|
100
103
|
inferred = [
|
|
@@ -116,11 +119,13 @@ def preview_connection(connection_id: str) -> dict[str, Any]:
|
|
|
116
119
|
if not connection["is_source"]:
|
|
117
120
|
raise ValueError("Data preview is available only for source connections.")
|
|
118
121
|
workspace = get_ui_state()["workspace"]
|
|
122
|
+
catalog_doc = store.read()["catalog"]
|
|
123
|
+
options = resolve_connection_options(connection, catalog_doc)
|
|
119
124
|
context = create_session_context()
|
|
120
125
|
dataframe = scan_builtin(
|
|
121
126
|
context,
|
|
122
127
|
connection["connector"],
|
|
123
|
-
|
|
128
|
+
options,
|
|
124
129
|
workspace,
|
|
125
130
|
saved_columns=connection["columns"] or None,
|
|
126
131
|
)
|
|
@@ -146,6 +151,187 @@ def preview_connection(connection_id: str) -> dict[str, Any]:
|
|
|
146
151
|
}
|
|
147
152
|
|
|
148
153
|
|
|
154
|
+
def preview_adhoc_connection(
|
|
155
|
+
connector: str,
|
|
156
|
+
options: dict[str, Any],
|
|
157
|
+
columns: list[dict[str, Any]] | None = None,
|
|
158
|
+
limit: int = 25,
|
|
159
|
+
) -> dict[str, Any]:
|
|
160
|
+
"""Scan and preview up to limit rows directly from connection parameters without registration."""
|
|
161
|
+
import time
|
|
162
|
+
|
|
163
|
+
start_time = time.perf_counter()
|
|
164
|
+
workspace = get_ui_state()["workspace"]
|
|
165
|
+
context = create_session_context()
|
|
166
|
+
try:
|
|
167
|
+
from det.connectors import connector_definitions
|
|
168
|
+
for c_def in connector_definitions():
|
|
169
|
+
if connector in (c_def.key, c_def.guid):
|
|
170
|
+
connector = c_def.guid
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
dataframe = scan_builtin(
|
|
174
|
+
context,
|
|
175
|
+
connector,
|
|
176
|
+
options,
|
|
177
|
+
workspace,
|
|
178
|
+
saved_columns=columns or None,
|
|
179
|
+
)
|
|
180
|
+
view_name = f"det_adhoc_{uuid4().hex[:8]}"
|
|
181
|
+
context.register_view(view_name, dataframe)
|
|
182
|
+
preview = run_sql(context, f"SELECT * FROM {view_name} LIMIT {limit}")
|
|
183
|
+
batches = preview.collect()
|
|
184
|
+
import pyarrow as pa
|
|
185
|
+
|
|
186
|
+
rows = pa.Table.from_batches(batches, schema=preview.schema()).to_pylist() if batches else []
|
|
187
|
+
elapsed_ms = round((time.perf_counter() - start_time) * 1000, 1)
|
|
188
|
+
inferred_columns = [
|
|
189
|
+
{
|
|
190
|
+
"name": field.name,
|
|
191
|
+
"data_type": str(field.type),
|
|
192
|
+
"nullable": field.nullable,
|
|
193
|
+
"description": "",
|
|
194
|
+
}
|
|
195
|
+
for field in preview.schema()
|
|
196
|
+
]
|
|
197
|
+
return {
|
|
198
|
+
"success": True,
|
|
199
|
+
"columns": columns or inferred_columns,
|
|
200
|
+
"inferred_columns": inferred_columns,
|
|
201
|
+
"rows": rows,
|
|
202
|
+
"row_count": len(rows),
|
|
203
|
+
"row_limit": limit,
|
|
204
|
+
"elapsed_ms": elapsed_ms,
|
|
205
|
+
}
|
|
206
|
+
except Exception as exc:
|
|
207
|
+
return {
|
|
208
|
+
"success": False,
|
|
209
|
+
"error": str(exc),
|
|
210
|
+
"elapsed_ms": round((time.perf_counter() - start_time) * 1000, 1),
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_database_connection(
|
|
215
|
+
connector: str,
|
|
216
|
+
options: dict[str, Any],
|
|
217
|
+
profile_id: str | None = None,
|
|
218
|
+
) -> dict[str, Any]:
|
|
219
|
+
"""Test connectivity to a database without fetching tables."""
|
|
220
|
+
import time
|
|
221
|
+
|
|
222
|
+
start_time = time.perf_counter()
|
|
223
|
+
workspace = get_ui_state()["workspace"]
|
|
224
|
+
try:
|
|
225
|
+
from det.connectors import build_postgres_uri, connector_definitions, resolve_env_vars
|
|
226
|
+
|
|
227
|
+
if profile_id:
|
|
228
|
+
store = get_catalog_store()
|
|
229
|
+
profile = store.get_profile(profile_id)
|
|
230
|
+
connector = profile["connector"]
|
|
231
|
+
options = {**profile["options"], **options}
|
|
232
|
+
|
|
233
|
+
connector_key = connector
|
|
234
|
+
for c_def in connector_definitions():
|
|
235
|
+
if connector in (c_def.key, c_def.guid):
|
|
236
|
+
connector_key = c_def.key
|
|
237
|
+
break
|
|
238
|
+
|
|
239
|
+
resolved_options = resolve_env_vars(options, workspace)
|
|
240
|
+
|
|
241
|
+
if connector_key == "postgresql":
|
|
242
|
+
import adbc_driver_postgresql.dbapi as pg_dbapi
|
|
243
|
+
|
|
244
|
+
uri = build_postgres_uri(resolved_options)
|
|
245
|
+
with pg_dbapi.connect(uri) as conn, conn.cursor() as cursor:
|
|
246
|
+
cursor.execute("SELECT 1;")
|
|
247
|
+
cursor.fetchone()
|
|
248
|
+
elapsed_ms = round((time.perf_counter() - start_time) * 1000, 1)
|
|
249
|
+
return {
|
|
250
|
+
"success": True,
|
|
251
|
+
"message": "Connected successfully!",
|
|
252
|
+
"elapsed_ms": elapsed_ms,
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
"success": False,
|
|
257
|
+
"error": f"Connector '{connector_key}' does not support connection testing.",
|
|
258
|
+
}
|
|
259
|
+
except Exception as exc:
|
|
260
|
+
elapsed_ms = round((time.perf_counter() - start_time) * 1000, 1)
|
|
261
|
+
return {
|
|
262
|
+
"success": False,
|
|
263
|
+
"error": str(exc),
|
|
264
|
+
"elapsed_ms": elapsed_ms,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def discover_database_tables(
|
|
269
|
+
connector: str,
|
|
270
|
+
options: dict[str, Any],
|
|
271
|
+
profile_id: str | None = None,
|
|
272
|
+
) -> dict[str, Any]:
|
|
273
|
+
"""Discover available schemas and tables from a database connection."""
|
|
274
|
+
import time
|
|
275
|
+
|
|
276
|
+
start_time = time.perf_counter()
|
|
277
|
+
workspace = get_ui_state()["workspace"]
|
|
278
|
+
try:
|
|
279
|
+
from det.connectors import build_postgres_uri, connector_definitions, resolve_env_vars
|
|
280
|
+
|
|
281
|
+
if profile_id:
|
|
282
|
+
store = get_catalog_store()
|
|
283
|
+
profile = store.get_profile(profile_id)
|
|
284
|
+
connector = profile["connector"]
|
|
285
|
+
options = {**profile["options"], **options}
|
|
286
|
+
|
|
287
|
+
connector_key = connector
|
|
288
|
+
for c_def in connector_definitions():
|
|
289
|
+
if connector in (c_def.key, c_def.guid):
|
|
290
|
+
connector_key = c_def.key
|
|
291
|
+
break
|
|
292
|
+
|
|
293
|
+
resolved_options = resolve_env_vars(options, workspace)
|
|
294
|
+
|
|
295
|
+
if connector_key == "postgresql":
|
|
296
|
+
import adbc_driver_postgresql.dbapi as pg_dbapi
|
|
297
|
+
|
|
298
|
+
uri = build_postgres_uri(resolved_options)
|
|
299
|
+
with pg_dbapi.connect(uri) as conn, conn.cursor() as cursor:
|
|
300
|
+
cursor.execute(
|
|
301
|
+
"SELECT table_schema, table_name "
|
|
302
|
+
"FROM information_schema.tables "
|
|
303
|
+
"WHERE table_schema NOT IN ('information_schema', 'pg_catalog') "
|
|
304
|
+
"ORDER BY table_schema, table_name;"
|
|
305
|
+
)
|
|
306
|
+
table = cursor.fetch_arrow_table()
|
|
307
|
+
rows = table.to_pylist() if table else []
|
|
308
|
+
tables = [
|
|
309
|
+
{"schema": r["table_schema"], "name": r["table_name"]}
|
|
310
|
+
for r in rows
|
|
311
|
+
]
|
|
312
|
+
elapsed_ms = round((time.perf_counter() - start_time) * 1000, 1)
|
|
313
|
+
return {
|
|
314
|
+
"success": True,
|
|
315
|
+
"tables": tables,
|
|
316
|
+
"count": len(tables),
|
|
317
|
+
"elapsed_ms": elapsed_ms,
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
"success": False,
|
|
322
|
+
"error": f"Connector '{connector_key}' does not support table discovery.",
|
|
323
|
+
"tables": [],
|
|
324
|
+
}
|
|
325
|
+
except Exception as exc:
|
|
326
|
+
elapsed_ms = round((time.perf_counter() - start_time) * 1000, 1)
|
|
327
|
+
return {
|
|
328
|
+
"success": False,
|
|
329
|
+
"error": str(exc),
|
|
330
|
+
"tables": [],
|
|
331
|
+
"elapsed_ms": elapsed_ms,
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
|
|
149
335
|
def get_pipeline_store() -> PipelineStore:
|
|
150
336
|
"""Return the workspace pipeline store configured for this process."""
|
|
151
337
|
if _pipeline_store is None:
|
|
@@ -21,12 +21,46 @@ def _canonical_guid(value: Any) -> str:
|
|
|
21
21
|
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"det:legacy-id:{value}"))
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
class CatalogProfile(BaseModel):
|
|
25
|
+
model_config = ConfigDict(extra="forbid")
|
|
26
|
+
|
|
27
|
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
28
|
+
name: str = Field(min_length=1, max_length=100)
|
|
29
|
+
connector: str
|
|
30
|
+
description: str = Field(default="", max_length=2000)
|
|
31
|
+
options: dict[str, Any] = Field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
@field_validator("id", mode="before")
|
|
34
|
+
@classmethod
|
|
35
|
+
def canonicalize_id(cls, value: Any) -> str:
|
|
36
|
+
return _canonical_guid(value) if value is not None else str(uuid.uuid4())
|
|
37
|
+
|
|
38
|
+
@field_validator("connector")
|
|
39
|
+
@classmethod
|
|
40
|
+
def validate_connector_guid(cls, value: str) -> str:
|
|
41
|
+
try:
|
|
42
|
+
return str(uuid.UUID(value))
|
|
43
|
+
except ValueError as error:
|
|
44
|
+
raise ValueError("Connector type must be identified by a GUID.") from error
|
|
45
|
+
|
|
46
|
+
@field_validator("name")
|
|
47
|
+
@classmethod
|
|
48
|
+
def validate_profile_name(cls, value: str) -> str:
|
|
49
|
+
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,99}", value):
|
|
50
|
+
raise ValueError(
|
|
51
|
+
"Profile names must start with a letter, be at most 100 characters, "
|
|
52
|
+
"and contain only letters, numbers, and underscores."
|
|
53
|
+
)
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
24
57
|
class CatalogConnection(BaseModel):
|
|
25
58
|
model_config = ConfigDict(extra="forbid")
|
|
26
59
|
|
|
27
60
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
28
|
-
name: str = Field(min_length=1, max_length=
|
|
61
|
+
name: str = Field(min_length=1, max_length=100)
|
|
29
62
|
connector: str
|
|
63
|
+
profile: str | None = None
|
|
30
64
|
is_source: bool = True
|
|
31
65
|
is_destination: bool = False
|
|
32
66
|
description: str = Field(default="", max_length=2000)
|
|
@@ -49,9 +83,9 @@ class CatalogConnection(BaseModel):
|
|
|
49
83
|
@field_validator("name")
|
|
50
84
|
@classmethod
|
|
51
85
|
def validate_sql_identifier(cls, value: str) -> str:
|
|
52
|
-
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,
|
|
86
|
+
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,99}", value):
|
|
53
87
|
raise ValueError(
|
|
54
|
-
"Connection names must start with a letter, be at most
|
|
88
|
+
"Connection names must start with a letter, be at most 100 characters, "
|
|
55
89
|
"and contain only letters, numbers, and underscores."
|
|
56
90
|
)
|
|
57
91
|
return value
|
|
@@ -100,6 +134,7 @@ class CatalogDocument(BaseModel):
|
|
|
100
134
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
101
135
|
version: int = 1
|
|
102
136
|
name: str = Field(min_length=1, max_length=120)
|
|
137
|
+
profiles: list[CatalogProfile] = Field(default_factory=list)
|
|
103
138
|
folders: list[CatalogFolder] = Field(default_factory=list)
|
|
104
139
|
|
|
105
140
|
@field_validator("id", mode="before")
|
|
@@ -186,16 +221,16 @@ class CatalogStore:
|
|
|
186
221
|
self._active_filename = filename
|
|
187
222
|
return self.read()
|
|
188
223
|
|
|
189
|
-
def read(self) -> dict[str, Any]:
|
|
224
|
+
def read(self, filename: str | None = None) -> dict[str, Any]:
|
|
190
225
|
with self._lock:
|
|
191
|
-
|
|
192
|
-
path = self.directory /
|
|
226
|
+
target_filename = self._validate_filename(filename or self._active_filename)
|
|
227
|
+
path = self.directory / target_filename
|
|
193
228
|
try:
|
|
194
229
|
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
195
230
|
if raw is None:
|
|
196
231
|
raw = {}
|
|
197
232
|
if not isinstance(raw, dict):
|
|
198
|
-
raise ValueError(f"Catalog root must be a YAML mapping: {
|
|
233
|
+
raise ValueError(f"Catalog root must be a YAML mapping: {target_filename}")
|
|
199
234
|
needs_migration = False
|
|
200
235
|
if "folders" not in raw or not raw["folders"]:
|
|
201
236
|
raw["folders"] = [{
|
|
@@ -210,6 +245,14 @@ class CatalogStore:
|
|
|
210
245
|
if "id" not in raw:
|
|
211
246
|
raw["id"] = str(uuid.uuid4())
|
|
212
247
|
needs_migration = True
|
|
248
|
+
if "profiles" not in raw or raw["profiles"] is None:
|
|
249
|
+
raw["profiles"] = []
|
|
250
|
+
elif isinstance(raw["profiles"], dict):
|
|
251
|
+
raw["profiles"] = [
|
|
252
|
+
{"name": k, **v} if isinstance(v, dict) else v
|
|
253
|
+
for k, v in raw["profiles"].items()
|
|
254
|
+
]
|
|
255
|
+
needs_migration = True
|
|
213
256
|
for folder in raw["folders"]:
|
|
214
257
|
for connection in folder.get("connections", []):
|
|
215
258
|
if "direction" in connection:
|
|
@@ -219,13 +262,13 @@ class CatalogStore:
|
|
|
219
262
|
needs_migration = True
|
|
220
263
|
catalog = CatalogDocument.model_validate(raw)
|
|
221
264
|
except (OSError, ValueError, yaml.YAMLError, ValidationError) as error:
|
|
222
|
-
raise ValueError(f"Could not load catalog {
|
|
265
|
+
raise ValueError(f"Could not load catalog {target_filename}: {error}") from error
|
|
223
266
|
self._validate_ids(catalog)
|
|
224
267
|
if needs_migration:
|
|
225
|
-
self._write_file(
|
|
226
|
-
path = self.directory /
|
|
268
|
+
self._write_file(target_filename, catalog)
|
|
269
|
+
path = self.directory / target_filename
|
|
227
270
|
return {
|
|
228
|
-
"filename":
|
|
271
|
+
"filename": target_filename,
|
|
229
272
|
"catalog": catalog.model_dump(mode="json"),
|
|
230
273
|
"yaml": path.read_text(encoding="utf-8"),
|
|
231
274
|
}
|
|
@@ -363,6 +406,78 @@ class CatalogStore:
|
|
|
363
406
|
}
|
|
364
407
|
raise FileNotFoundError(f"Connection does not exist: {connection_id}")
|
|
365
408
|
|
|
409
|
+
def add_profile(self, values: dict[str, Any]) -> dict[str, Any]:
|
|
410
|
+
with self._lock:
|
|
411
|
+
catalog = self._read_document()
|
|
412
|
+
profile = CatalogProfile.model_validate(values)
|
|
413
|
+
if any(p.name.casefold() == profile.name.casefold() for p in catalog.profiles):
|
|
414
|
+
raise ValueError(f"Profile with name '{profile.name}' already exists.")
|
|
415
|
+
catalog.profiles.append(profile)
|
|
416
|
+
return self._save_document(catalog)
|
|
417
|
+
|
|
418
|
+
def update_profile(self, profile_id: str, values: dict[str, Any]) -> dict[str, Any]:
|
|
419
|
+
with self._lock:
|
|
420
|
+
catalog = self._read_document()
|
|
421
|
+
existing = None
|
|
422
|
+
for idx, p in enumerate(catalog.profiles):
|
|
423
|
+
if p.id == profile_id:
|
|
424
|
+
existing = p
|
|
425
|
+
updated = CatalogProfile.model_validate({
|
|
426
|
+
**p.model_dump(),
|
|
427
|
+
**values,
|
|
428
|
+
"id": profile_id,
|
|
429
|
+
})
|
|
430
|
+
catalog.profiles[idx] = updated
|
|
431
|
+
break
|
|
432
|
+
if not existing:
|
|
433
|
+
raise FileNotFoundError(f"Profile does not exist: {profile_id}")
|
|
434
|
+
return self._save_document(catalog)
|
|
435
|
+
|
|
436
|
+
def delete_profile(self, profile_id: str) -> dict[str, Any]:
|
|
437
|
+
with self._lock:
|
|
438
|
+
catalog = self._read_document()
|
|
439
|
+
profile = None
|
|
440
|
+
for p in catalog.profiles:
|
|
441
|
+
if p.id == profile_id:
|
|
442
|
+
profile = p
|
|
443
|
+
break
|
|
444
|
+
if not profile:
|
|
445
|
+
raise FileNotFoundError(f"Profile does not exist: {profile_id}")
|
|
446
|
+
|
|
447
|
+
in_use = [
|
|
448
|
+
conn.name
|
|
449
|
+
for folder in catalog.folders
|
|
450
|
+
for conn in folder.connections
|
|
451
|
+
if conn.profile in (profile.id, profile.name)
|
|
452
|
+
]
|
|
453
|
+
if in_use:
|
|
454
|
+
raise ValueError(
|
|
455
|
+
f"Cannot delete profile '{profile.name}' because it is in use by {len(in_use)} connection(s): {', '.join(in_use[:3])}"
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
catalog.profiles.remove(profile)
|
|
459
|
+
return self._save_document(catalog)
|
|
460
|
+
|
|
461
|
+
def get_profile(self, profile_id: str) -> dict[str, Any]:
|
|
462
|
+
catalog = self._read_document()
|
|
463
|
+
for p in catalog.profiles:
|
|
464
|
+
if p.id == profile_id or p.name == profile_id:
|
|
465
|
+
return p.model_dump(mode="json")
|
|
466
|
+
raise FileNotFoundError(f"Profile does not exist: {profile_id}")
|
|
467
|
+
|
|
468
|
+
def bulk_add_connections(
|
|
469
|
+
self, folder_id: str, connections_data: list[dict[str, Any]]
|
|
470
|
+
) -> dict[str, Any]:
|
|
471
|
+
with self._lock:
|
|
472
|
+
catalog = self._read_document()
|
|
473
|
+
folder = self._folder(catalog, folder_id)
|
|
474
|
+
for values in connections_data:
|
|
475
|
+
connection_values = dict(values)
|
|
476
|
+
connection_values.pop("folder_id", None)
|
|
477
|
+
conn = CatalogConnection.model_validate(connection_values)
|
|
478
|
+
folder.connections.append(conn)
|
|
479
|
+
return self._save_document(catalog)
|
|
480
|
+
|
|
366
481
|
def _read_document(self) -> CatalogDocument:
|
|
367
482
|
return CatalogDocument.model_validate(self.read()["catalog"])
|
|
368
483
|
|
|
@@ -425,6 +540,8 @@ class CatalogStore:
|
|
|
425
540
|
@staticmethod
|
|
426
541
|
def _validate_ids(catalog: CatalogDocument) -> None:
|
|
427
542
|
item_ids = [catalog.id]
|
|
543
|
+
if hasattr(catalog, "profiles"):
|
|
544
|
+
item_ids.extend(profile.id for profile in catalog.profiles)
|
|
428
545
|
item_ids.extend(folder.id for folder in catalog.folders)
|
|
429
546
|
item_ids.extend(
|
|
430
547
|
item_id
|
|
@@ -444,3 +561,41 @@ class CatalogStore:
|
|
|
444
561
|
if not normalized:
|
|
445
562
|
raise ValueError(f"{item_type} name cannot be empty.")
|
|
446
563
|
return normalized
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def resolve_connection_options(
|
|
567
|
+
connection: dict[str, Any] | CatalogConnection,
|
|
568
|
+
catalog_doc: dict[str, Any] | CatalogDocument | None = None,
|
|
569
|
+
) -> dict[str, Any]:
|
|
570
|
+
"""Merge options from referenced profile (if any) with connection options."""
|
|
571
|
+
if isinstance(connection, CatalogConnection):
|
|
572
|
+
conn_dict = connection.model_dump(mode="json")
|
|
573
|
+
else:
|
|
574
|
+
conn_dict = dict(connection)
|
|
575
|
+
|
|
576
|
+
options = dict(conn_dict.get("options") or {})
|
|
577
|
+
profile_ref = conn_dict.get("profile")
|
|
578
|
+
if not profile_ref or not catalog_doc:
|
|
579
|
+
return options
|
|
580
|
+
|
|
581
|
+
if isinstance(catalog_doc, CatalogDocument):
|
|
582
|
+
doc_dict = catalog_doc.model_dump(mode="json")
|
|
583
|
+
else:
|
|
584
|
+
doc_dict = dict(catalog_doc)
|
|
585
|
+
|
|
586
|
+
profiles = doc_dict.get("profiles") or []
|
|
587
|
+
profile = None
|
|
588
|
+
if isinstance(profiles, list):
|
|
589
|
+
for p in profiles:
|
|
590
|
+
if p.get("id") == profile_ref or p.get("name") == profile_ref:
|
|
591
|
+
profile = p
|
|
592
|
+
break
|
|
593
|
+
elif isinstance(profiles, dict):
|
|
594
|
+
profile = profiles.get(profile_ref)
|
|
595
|
+
|
|
596
|
+
if profile:
|
|
597
|
+
profile_opts = profile.get("options") or {}
|
|
598
|
+
# Profile options are base, connection options override
|
|
599
|
+
return {**profile_opts, **options}
|
|
600
|
+
|
|
601
|
+
return options
|