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.
Files changed (141) hide show
  1. {det_core-0.2.2 → det_core-0.2.3}/PKG-INFO +3 -1
  2. {det_core-0.2.2 → det_core-0.2.3}/pyproject.toml +6 -1
  3. {det_core-0.2.2 → det_core-0.2.3}/src/det/__init__.py +1 -1
  4. {det_core-0.2.2 → det_core-0.2.3}/src/det/api.py +189 -3
  5. {det_core-0.2.2 → det_core-0.2.3}/src/det/catalog.py +166 -11
  6. {det_core-0.2.2 → det_core-0.2.3}/src/det/connectors.py +227 -14
  7. {det_core-0.2.2 → det_core-0.2.3}/src/det/gui.py +36 -1
  8. {det_core-0.2.2 → det_core-0.2.3}/src/det/runner.py +105 -11
  9. {det_core-0.2.2 → det_core-0.2.3}/src/det/ui/app.js +1217 -118
  10. {det_core-0.2.2 → det_core-0.2.3}/src/det/ui/index.html +5 -4
  11. {det_core-0.2.2 → det_core-0.2.3}/src/det/ui/styles.css +666 -3
  12. {det_core-0.2.2 → det_core-0.2.3}/src/det/validator.py +42 -16
  13. {det_core-0.2.2 → det_core-0.2.3}/tests/test_api_endpoints.py +116 -0
  14. {det_core-0.2.2 → det_core-0.2.3}/tests/test_catalog.py +76 -1
  15. {det_core-0.2.2 → det_core-0.2.3}/tests/test_connectors.py +177 -3
  16. {det_core-0.2.2 → det_core-0.2.3}/tests/test_etl_and_monitoring.py +83 -0
  17. {det_core-0.2.2 → det_core-0.2.3}/.github/CODEOWNERS +0 -0
  18. {det_core-0.2.2 → det_core-0.2.3}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  19. {det_core-0.2.2 → det_core-0.2.3}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  20. {det_core-0.2.2 → det_core-0.2.3}/.github/ISSUE_TEMPLATE/connector_request.yml +0 -0
  21. {det_core-0.2.2 → det_core-0.2.3}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  22. {det_core-0.2.2 → det_core-0.2.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  23. {det_core-0.2.2 → det_core-0.2.3}/.github/dependabot.yml +0 -0
  24. {det_core-0.2.2 → det_core-0.2.3}/.github/workflows/ci.yml +0 -0
  25. {det_core-0.2.2 → det_core-0.2.3}/.github/workflows/pages.yml +0 -0
  26. {det_core-0.2.2 → det_core-0.2.3}/.github/workflows/publish.yml +0 -0
  27. {det_core-0.2.2 → det_core-0.2.3}/.gitignore +0 -0
  28. {det_core-0.2.2 → det_core-0.2.3}/CODE_OF_CONDUCT.md +0 -0
  29. {det_core-0.2.2 → det_core-0.2.3}/CONTRIBUTING.md +0 -0
  30. {det_core-0.2.2 → det_core-0.2.3}/LICENSE +0 -0
  31. {det_core-0.2.2 → det_core-0.2.3}/README.md +0 -0
  32. {det_core-0.2.2 → det_core-0.2.3}/SECURITY.md +0 -0
  33. {det_core-0.2.2 → det_core-0.2.3}/media/det_linkedin_carousel.pdf +0 -0
  34. {det_core-0.2.2 → det_core-0.2.3}/scripts/build_docs_site.py +0 -0
  35. {det_core-0.2.2 → det_core-0.2.3}/scripts/generate_demo_data.py +0 -0
  36. {det_core-0.2.2 → det_core-0.2.3}/scripts/live_browser_walkthrough.py +0 -0
  37. {det_core-0.2.2 → det_core-0.2.3}/scripts/sync_wiki.py +0 -0
  38. {det_core-0.2.2 → det_core-0.2.3}/src/det/cli.py +0 -0
  39. {det_core-0.2.2 → det_core-0.2.3}/src/det/docs.py +0 -0
  40. {det_core-0.2.2 → det_core-0.2.3}/src/det/engine.py +0 -0
  41. {det_core-0.2.2 → det_core-0.2.3}/src/det/monitoring.py +0 -0
  42. {det_core-0.2.2 → det_core-0.2.3}/src/det/pipeline.py +0 -0
  43. {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/__init__.py +0 -0
  44. {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/catalog/sample_warehouse.yaml +0 -0
  45. {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/pipelines/Sample_Warehouse_Pipelines/dim_customers.yaml +0 -0
  46. {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/pipelines/Sample_Warehouse_Pipelines/dim_stores.yaml +0 -0
  47. {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
  48. {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/pipelines/Sample_Warehouse_Pipelines/fct_orders.yaml +0 -0
  49. {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
  50. {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
  51. {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
  52. {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/pipelines/Sample_Warehouse_Pipelines/stg_customers_cleaned.yaml +0 -0
  53. {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/pipelines/Sample_Warehouse_Pipelines/stg_orders_normalized.yaml +0 -0
  54. {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/pipelines/Sample_Warehouse_Pipelines/stg_stores_catalog.yaml +0 -0
  55. {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/source_data/customers/customers.csv +0 -0
  56. {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/source_data/finance/financial_reference.xlsx +0 -0
  57. {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
  58. {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
  59. {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
  60. {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/source_data/orders/2026_Q1/orders_jan.csv +0 -0
  61. {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
  62. {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/source_data/orders/2026_Q2/orders_jun.csv +0 -0
  63. {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/source_data/products/products.json +0 -0
  64. {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/source_data/returns/order_returns.parquet +0 -0
  65. {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/source_data/stores/store_catalog.xml +0 -0
  66. {det_core-0.2.2 → det_core-0.2.3}/src/det/sample_data/source_data/web_analytics/clickstream_events.ndjson +0 -0
  67. {det_core-0.2.2 → det_core-0.2.3}/src/det/ui/demo-data.js +0 -0
  68. {det_core-0.2.2 → det_core-0.2.3}/src/det/ui/help-data.js +0 -0
  69. {det_core-0.2.2 → det_core-0.2.3}/src/det/ui/mermaid.min.js +0 -0
  70. {det_core-0.2.2 → det_core-0.2.3}/src/det/workspace.py +0 -0
  71. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/__init__.py +0 -0
  72. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/catalog/default.yaml +0 -0
  73. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/pipelines/demo/summarize.yaml +0 -0
  74. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/pipelines/retail_samples/multi_source_customers.yaml +0 -0
  75. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/csv/customers.csv +0 -0
  76. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/csv/logs/year=2024/month=10/audit.csv +0 -0
  77. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/csv/logs/year=2024/month=11/audit.csv +0 -0
  78. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/csv/orders_daily/orders_2024_01.csv +0 -0
  79. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/csv/orders_daily/orders_2024_02.csv +0 -0
  80. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/csv/orders_daily/orders_2024_03.csv +0 -0
  81. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/csv/sales_daily/sales_2024_01.csv +0 -0
  82. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/csv/sales_daily/sales_2024_02.csv +0 -0
  83. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/delta/orders/_delta_log/00000000000000000000.json +0 -0
  84. {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
  85. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/delta/orders_lakehouse/_delta_log/00000000000000000000.json +0 -0
  86. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/delta/orders_lakehouse/_delta_log/00000000000000000001.json +0 -0
  87. {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
  88. {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
  89. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/destinations/csv_reports/customer_totals.csv +0 -0
  90. {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
  91. {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
  92. {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
  93. {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
  94. {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
  95. {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
  96. {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
  97. {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
  98. {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
  99. {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
  100. {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
  101. {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
  102. {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
  103. {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
  104. {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
  105. {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
  106. {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
  107. {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
  108. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/destinations/parquet_curated/curated_orders.parquet +0 -0
  109. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/excel/enterprise_data.xlsx +0 -0
  110. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/excel/financial_report.xlsx +0 -0
  111. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/json/clickstream/events_2024_01.ndjson +0 -0
  112. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/json/clickstream/events_2024_02.ndjson +0 -0
  113. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/json/products.json +0 -0
  114. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/json/stream_events.ndjson +0 -0
  115. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/parquet/events.parquet +0 -0
  116. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/parquet/lakehouse/region=EU/year=2024/data.parquet +0 -0
  117. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/parquet/lakehouse/region=US/year=2024/data.parquet +0 -0
  118. {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
  119. {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
  120. {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
  121. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/parquet/telemetry/p1.parquet +0 -0
  122. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/parquet/telemetry/p2.parquet +0 -0
  123. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/sqlite/ecommerce.db +0 -0
  124. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/xml/book_catalog.xml +0 -0
  125. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/xml/inventory/warehouse_a.xml +0 -0
  126. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/xml/inventory/warehouse_b.xml +0 -0
  127. {det_core-0.2.2 → det_core-0.2.3}/tests/fixtures/source_data/xml/stores/store_catalog.xml +0 -0
  128. {det_core-0.2.2 → det_core-0.2.3}/tests/test_catalog_previews.py +0 -0
  129. {det_core-0.2.2 → det_core-0.2.3}/tests/test_docs.py +0 -0
  130. {det_core-0.2.2 → det_core-0.2.3}/tests/test_dw_joins.py +0 -0
  131. {det_core-0.2.2 → det_core-0.2.3}/tests/test_engine.py +0 -0
  132. {det_core-0.2.2 → det_core-0.2.3}/tests/test_real_samples.py +0 -0
  133. {det_core-0.2.2 → det_core-0.2.3}/tests/test_sample_data.py +0 -0
  134. {det_core-0.2.2 → det_core-0.2.3}/wiki/CLI-&-Web-IDE-Reference.md +0 -0
  135. {det_core-0.2.2 → det_core-0.2.3}/wiki/Core-Directives-&-Architecture.md +0 -0
  136. {det_core-0.2.2 → det_core-0.2.3}/wiki/Custom-Connectors-Guide.md +0 -0
  137. {det_core-0.2.2 → det_core-0.2.3}/wiki/Home.md +0 -0
  138. {det_core-0.2.2 → det_core-0.2.3}/wiki/Quickstart-&-Installation.md +0 -0
  139. {det_core-0.2.2 → det_core-0.2.3}/wiki/SQL-ETL-Pipeline-Authoring.md +0 -0
  140. {det_core-0.2.2 → det_core-0.2.3}/wiki/_Footer.md +0 -0
  141. {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.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.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 = ["."]
@@ -1,4 +1,4 @@
1
1
  from det.workspace import Workspace
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.2.3"
4
4
  __all__ = ["Workspace", "__version__"]
@@ -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
- connection["options"],
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
- connection["options"],
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=30)
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,29}", value):
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 30 characters, "
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
- filename = self._validate_filename(self._active_filename)
192
- path = self.directory / filename
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: {filename}")
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 {filename}: {error}") from error
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(filename, catalog)
226
- path = self.directory / filename
268
+ self._write_file(target_filename, catalog)
269
+ path = self.directory / target_filename
227
270
  return {
228
- "filename": 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