drc-vantage 0.1.0__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.
- drc_vantage-0.1.0/.env.example +17 -0
- drc_vantage-0.1.0/.gitignore +79 -0
- drc_vantage-0.1.0/.python-version +1 -0
- drc_vantage-0.1.0/PKG-INFO +17 -0
- drc_vantage-0.1.0/README.md +0 -0
- drc_vantage-0.1.0/pyproject.toml +27 -0
- drc_vantage-0.1.0/src/vantage/__init__.py +21 -0
- drc_vantage-0.1.0/src/vantage/client.py +162 -0
- drc_vantage-0.1.0/src/vantage/core/logo.py +156 -0
- drc_vantage-0.1.0/src/vantage/core/organization.py +52 -0
- drc_vantage-0.1.0/src/vantage/core/s3.py +63 -0
- drc_vantage-0.1.0/src/vantage/data_sources/data_source.py +68 -0
- drc_vantage-0.1.0/src/vantage/data_sources/data_source_category.py +29 -0
- drc_vantage-0.1.0/src/vantage/data_sources/sync/data_source_helpers.py +24 -0
- drc_vantage-0.1.0/src/vantage/data_sources/sync/sync_data_source.py +96 -0
- drc_vantage-0.1.0/src/vantage/data_sources/sync/sync_data_source_category.py +86 -0
- drc_vantage-0.1.0/src/vantage/data_storage_system/data_storage_system.py +74 -0
- drc_vantage-0.1.0/src/vantage/data_storage_system/sync/data_storage_system_helpers.py +50 -0
- drc_vantage-0.1.0/src/vantage/data_storage_system/sync/sync_data_storage_system.py +106 -0
- drc_vantage-0.1.0/src/vantage/datasets/alerts/check_alert.py +21 -0
- drc_vantage-0.1.0/src/vantage/datasets/alerts/check_dataset_alerts.py +22 -0
- drc_vantage-0.1.0/src/vantage/datasets/alerts/email.py +170 -0
- drc_vantage-0.1.0/src/vantage/datasets/alerts/evaluate_alert.py +284 -0
- drc_vantage-0.1.0/src/vantage/datasets/alerts/queries.py +127 -0
- drc_vantage-0.1.0/src/vantage/datasets/alerts/run_alert_check.py +97 -0
- drc_vantage-0.1.0/src/vantage/datasets/analysis/analyze_dataset.py +17 -0
- drc_vantage-0.1.0/src/vantage/datasets/analysis/column_analysis.py +436 -0
- drc_vantage-0.1.0/src/vantage/datasets/analysis/health_score.py +138 -0
- drc_vantage-0.1.0/src/vantage/datasets/analysis/list_dataset_ids.py +15 -0
- drc_vantage-0.1.0/src/vantage/datasets/analysis/metrics/anomalies.py +168 -0
- drc_vantage-0.1.0/src/vantage/datasets/analysis/metrics/completion.py +104 -0
- drc_vantage-0.1.0/src/vantage/datasets/analysis/metrics/row_count.py +140 -0
- drc_vantage-0.1.0/src/vantage/datasets/analysis/run_analysis.py +234 -0
- drc_vantage-0.1.0/src/vantage/datasets/analysis/smart_alerts.py +138 -0
- drc_vantage-0.1.0/src/vantage/datasets/dataset.py +143 -0
- drc_vantage-0.1.0/src/vantage/datasets/linking/assign_dataset_data_sources.py +109 -0
- drc_vantage-0.1.0/src/vantage/datasets/sync/dataset_helpers.py +274 -0
- drc_vantage-0.1.0/src/vantage/datasets/sync/materialized_views.py +114 -0
- drc_vantage-0.1.0/src/vantage/datasets/sync/sync_dataset.py +228 -0
- drc_vantage-0.1.0/src/vantage/datasets/teams/assign_dataset_to_team.py +284 -0
- drc_vantage-0.1.0/src/vantage/datasets/teams/team_helpers.py +68 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/__init__.py +3 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_avg.py +71 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_between.py +56 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_count_distinct.py +65 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_count_not_null.py +65 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_count_null.py +57 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_max.py +61 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_max_length.py +55 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_mean.py +71 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_median.py +75 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_min.py +61 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_min_length.py +55 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_not_between.py +56 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_not_null.py +43 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_percent_not_null.py +80 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_percent_null.py +78 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_percentile.py +83 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_regex_match.py +55 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_regex_not_match.py +55 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_stddev.py +71 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_sum.py +63 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_unique.py +46 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_values_in_set.py +59 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_values_not_in_set.py +58 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_custom_sql.py +57 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_table_duplicate_rows.py +51 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_table_freshness.py +84 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_table_row_count.py +51 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_table_row_count_between.py +42 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_table_schema_change.py +24 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/run_check.py +122 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/checks/sql_helpers.py +80 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/get_dataset_info.py +65 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/get_dataset_validations.py +105 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/recommend_validations.py +145 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/recommendations/dedupe.py +154 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/recommendations/fetch.py +334 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/recommendations/llm.py +78 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/recommendations/normalize.py +75 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/recommendations/prompt.py +140 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/recommendations/save.py +167 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/recommendations/schemas.py +67 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/rule_config.py +188 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/rule_config_reference.json +143 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/run_all_dataset_validation_suites.py +28 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/run_dataset_validations.py +13 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/run_validation.py +18 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/run_validation_rule.py +121 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/run_validation_suite.py +191 -0
- drc_vantage-0.1.0/src/vantage/datasets/validations/schemas.py +136 -0
- drc_vantage-0.1.0/src/vantage/kpis/compute/compute.py +161 -0
- drc_vantage-0.1.0/src/vantage/kpis/compute/compute_manual.py +401 -0
- drc_vantage-0.1.0/src/vantage/kpis/compute/compute_sql.py +37 -0
- drc_vantage-0.1.0/src/vantage/kpis/compute/fetch.py +36 -0
- drc_vantage-0.1.0/src/vantage/kpis/compute/save.py +66 -0
- drc_vantage-0.1.0/src/vantage/kpis/compute/schemas.py +0 -0
- drc_vantage-0.1.0/src/vantage/smart_cockpit/ai_summary/fetch.py +106 -0
- drc_vantage-0.1.0/src/vantage/smart_cockpit/ai_summary/llm.py +112 -0
- drc_vantage-0.1.0/src/vantage/smart_cockpit/ai_summary/prompt.py +86 -0
- drc_vantage-0.1.0/src/vantage/smart_cockpit/ai_summary/save.py +80 -0
- drc_vantage-0.1.0/src/vantage/smart_cockpit/ai_summary/schemas.py +26 -0
- drc_vantage-0.1.0/src/vantage/smart_cockpit/company_news/llm.py +0 -0
- drc_vantage-0.1.0/src/vantage/smart_cockpit/company_news/save.py +0 -0
- drc_vantage-0.1.0/src/vantage/smart_cockpit/company_news/schemas.py +0 -0
- drc_vantage-0.1.0/src/vantage/smart_cockpit/configuration.py +35 -0
- drc_vantage-0.1.0/src/vantage/smart_cockpit/kpi_insights/fetch.py +61 -0
- drc_vantage-0.1.0/src/vantage/smart_cockpit/kpi_insights/llm.py +59 -0
- drc_vantage-0.1.0/src/vantage/smart_cockpit/kpi_insights/prompt.py +117 -0
- drc_vantage-0.1.0/src/vantage/smart_cockpit/kpi_insights/save.py +79 -0
- drc_vantage-0.1.0/src/vantage/smart_cockpit/regenerate/generate_missing_kpi_insights.py +29 -0
- drc_vantage-0.1.0/uv.lock +1122 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# PostgreSQL (data warehouse)
|
|
2
|
+
POSTGRES_HOST=localhost
|
|
3
|
+
POSTGRES_PORT=5432
|
|
4
|
+
POSTGRES_USER=postgres
|
|
5
|
+
POSTGRES_PASSWORD=pOstgres123!
|
|
6
|
+
POSTGRES_DATABASE=demo
|
|
7
|
+
POSTGRES_DEFAULT_SCHEMA=public
|
|
8
|
+
|
|
9
|
+
# Vantage configuration
|
|
10
|
+
VANTAGE_SCHEMA=vantage
|
|
11
|
+
GENERATE_AI_VALIDATIONS=false
|
|
12
|
+
|
|
13
|
+
# MinIO / S3
|
|
14
|
+
S3_ENDPOINT_URL=http://localhost:9000
|
|
15
|
+
S3_ACCESS_KEY=minioadmin
|
|
16
|
+
S3_SECRET_KEY=minioadmin
|
|
17
|
+
S3_BUCKET_NAME=demo
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
2
|
+
|
|
3
|
+
# Dependencies
|
|
4
|
+
**/node_modules/
|
|
5
|
+
**/.pnp
|
|
6
|
+
.pnp.*
|
|
7
|
+
.yarn/*
|
|
8
|
+
!.yarn/patches
|
|
9
|
+
!.yarn/plugins
|
|
10
|
+
!.yarn/releases
|
|
11
|
+
!.yarn/versions
|
|
12
|
+
|
|
13
|
+
# Testing
|
|
14
|
+
**/coverage/
|
|
15
|
+
|
|
16
|
+
# Next.js
|
|
17
|
+
**/.next/
|
|
18
|
+
**/out/
|
|
19
|
+
|
|
20
|
+
# Production
|
|
21
|
+
**/build/
|
|
22
|
+
|
|
23
|
+
# Python
|
|
24
|
+
**/__pycache__/
|
|
25
|
+
**/*.py[cod]
|
|
26
|
+
**/.venv/
|
|
27
|
+
**/dist/
|
|
28
|
+
**/wheels/
|
|
29
|
+
**/*.egg-info/
|
|
30
|
+
**/.ruff_cache/
|
|
31
|
+
**/.pytest_cache/
|
|
32
|
+
|
|
33
|
+
# TypeScript
|
|
34
|
+
**/*.tsbuildinfo
|
|
35
|
+
**/next-env.d.ts
|
|
36
|
+
|
|
37
|
+
# Prisma
|
|
38
|
+
**/prisma/generated/
|
|
39
|
+
|
|
40
|
+
# Environment variables
|
|
41
|
+
.env
|
|
42
|
+
.env.*
|
|
43
|
+
!.env.example
|
|
44
|
+
!.env.*.example
|
|
45
|
+
|
|
46
|
+
# Debug logs
|
|
47
|
+
npm-debug.log*
|
|
48
|
+
yarn-debug.log*
|
|
49
|
+
yarn-error.log*
|
|
50
|
+
.pnpm-debug.log*
|
|
51
|
+
|
|
52
|
+
# Deployment
|
|
53
|
+
**/.vercel/
|
|
54
|
+
|
|
55
|
+
# OS
|
|
56
|
+
**/.DS_Store
|
|
57
|
+
**/Thumbs.db
|
|
58
|
+
|
|
59
|
+
# IDE — ignore personal settings, keep shared workspace config
|
|
60
|
+
**/.vscode/*
|
|
61
|
+
!**/.vscode/settings.json
|
|
62
|
+
!**/.vscode/extensions.json
|
|
63
|
+
!**/.vscode/tasks.json
|
|
64
|
+
!**/.vscode/launch.json
|
|
65
|
+
**/.idea/
|
|
66
|
+
|
|
67
|
+
# Cursor
|
|
68
|
+
**/.cursor/
|
|
69
|
+
|
|
70
|
+
# Local themes & branding
|
|
71
|
+
**/src/config/themes/
|
|
72
|
+
**/public/branding/
|
|
73
|
+
|
|
74
|
+
# Notebooks
|
|
75
|
+
*.ipynb_checkpoints/
|
|
76
|
+
notebook.ipynb
|
|
77
|
+
|
|
78
|
+
# Secrets
|
|
79
|
+
*.pem
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: drc-vantage
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: DRC Vantage SDK
|
|
5
|
+
Requires-Python: >=3.14
|
|
6
|
+
Requires-Dist: boto3>=1.43.46
|
|
7
|
+
Requires-Dist: langchain-core>=0.3.0
|
|
8
|
+
Requires-Dist: langchain-openai>=0.3.0
|
|
9
|
+
Requires-Dist: loguru>=0.7.3
|
|
10
|
+
Requires-Dist: numpy>=2.5.1
|
|
11
|
+
Requires-Dist: pillow>=11.0.0
|
|
12
|
+
Requires-Dist: psycopg[binary]>=3.3.4
|
|
13
|
+
Requires-Dist: pydantic-settings>=2.14.2
|
|
14
|
+
Requires-Dist: pydantic>=2.13.4
|
|
15
|
+
Requires-Dist: resend>=2.32.2
|
|
16
|
+
Requires-Dist: scikit-learn>=1.9.0
|
|
17
|
+
Requires-Dist: sqlalchemy>=2.0.51
|
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "drc-vantage"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "DRC Vantage SDK"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.14"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"boto3>=1.43.46",
|
|
13
|
+
"langchain-core>=0.3.0",
|
|
14
|
+
"langchain-openai>=0.3.0",
|
|
15
|
+
"loguru>=0.7.3",
|
|
16
|
+
"numpy>=2.5.1",
|
|
17
|
+
"pillow>=11.0.0",
|
|
18
|
+
"psycopg[binary]>=3.3.4",
|
|
19
|
+
"pydantic>=2.13.4",
|
|
20
|
+
"pydantic-settings>=2.14.2",
|
|
21
|
+
"resend>=2.32.2",
|
|
22
|
+
"scikit-learn>=1.9.0",
|
|
23
|
+
"sqlalchemy>=2.0.51",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["src/vantage"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""DRC Vantage SDK — platform data pipelines."""
|
|
2
|
+
|
|
3
|
+
from vantage.client import Base, Client, configure, ensure_database_exists, get_client, get_session, get_settings
|
|
4
|
+
from vantage.data_sources.data_source import DataSource
|
|
5
|
+
from vantage.data_sources.data_source_category import DataSourceCategory
|
|
6
|
+
from vantage.data_storage_system.data_storage_system import DataStorageSystem
|
|
7
|
+
from vantage.datasets.dataset import Dataset
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Base",
|
|
11
|
+
"Client",
|
|
12
|
+
"DataSource",
|
|
13
|
+
"DataSourceCategory",
|
|
14
|
+
"DataStorageSystem",
|
|
15
|
+
"Dataset",
|
|
16
|
+
"configure",
|
|
17
|
+
"ensure_database_exists",
|
|
18
|
+
"get_client",
|
|
19
|
+
"get_session",
|
|
20
|
+
"get_settings",
|
|
21
|
+
]
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Vantage SDK client: database access and runtime configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from typing import Any, Generator, Protocol, runtime_checkable
|
|
8
|
+
from urllib.parse import quote_plus
|
|
9
|
+
|
|
10
|
+
from loguru import logger
|
|
11
|
+
from sqlalchemy import create_engine, text
|
|
12
|
+
from sqlalchemy.engine import Engine
|
|
13
|
+
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Base(DeclarativeBase):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@runtime_checkable
|
|
21
|
+
class VantageSettings(Protocol):
|
|
22
|
+
"""Settings contract expected by the SDK (implemented by each consumer repo)."""
|
|
23
|
+
|
|
24
|
+
vantage_schema: str
|
|
25
|
+
generate_ai_validations: bool
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def is_postgres_configured(self) -> bool: ...
|
|
29
|
+
|
|
30
|
+
postgres_database: str
|
|
31
|
+
postgres_user: str
|
|
32
|
+
postgres_host: str
|
|
33
|
+
postgres_port: int
|
|
34
|
+
postgres_password: Any
|
|
35
|
+
|
|
36
|
+
openai_api_key: str | None
|
|
37
|
+
resend_api_key: str | None
|
|
38
|
+
|
|
39
|
+
s3_endpoint_url: str | None
|
|
40
|
+
s3_access_key: str | None
|
|
41
|
+
s3_secret_key: str | None
|
|
42
|
+
s3_bucket_name: str | None
|
|
43
|
+
|
|
44
|
+
def is_s3_config_valid(self) -> bool: ...
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def is_s3_configured(self) -> bool: ...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_client: Client | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _get_secret(value: Any) -> str:
|
|
54
|
+
if hasattr(value, "get_secret_value"):
|
|
55
|
+
return value.get_secret_value()
|
|
56
|
+
return str(value)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Client:
|
|
60
|
+
"""Entry point for SDK runtime configuration and database access."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, settings: VantageSettings) -> None:
|
|
63
|
+
self.settings = settings
|
|
64
|
+
self._engine: Engine | None = None
|
|
65
|
+
self._session_maker: sessionmaker[Session] | None = None
|
|
66
|
+
|
|
67
|
+
def _build_postgres_url(self, database: str | None = None) -> str:
|
|
68
|
+
settings = self.settings
|
|
69
|
+
if not settings.is_postgres_configured:
|
|
70
|
+
raise ValueError("PostgreSQL is not configured. Set POSTGRES_* variables in your .env file.")
|
|
71
|
+
|
|
72
|
+
db = database or settings.postgres_database
|
|
73
|
+
password = quote_plus(_get_secret(settings.postgres_password))
|
|
74
|
+
user = quote_plus(settings.postgres_user)
|
|
75
|
+
return f"postgresql+psycopg://{user}:{password}@{settings.postgres_host}:{settings.postgres_port}/{db}"
|
|
76
|
+
|
|
77
|
+
def get_engine(self) -> Engine:
|
|
78
|
+
if self._engine is None:
|
|
79
|
+
self._engine = create_engine(self._build_postgres_url(), pool_pre_ping=True)
|
|
80
|
+
return self._engine
|
|
81
|
+
|
|
82
|
+
def get_session_maker(self) -> sessionmaker[Session]:
|
|
83
|
+
if self._session_maker is None:
|
|
84
|
+
self._session_maker = sessionmaker(
|
|
85
|
+
bind=self.get_engine(),
|
|
86
|
+
class_=Session,
|
|
87
|
+
expire_on_commit=False,
|
|
88
|
+
)
|
|
89
|
+
return self._session_maker
|
|
90
|
+
|
|
91
|
+
@contextmanager
|
|
92
|
+
def get_session(self) -> Generator[Session, None, None]:
|
|
93
|
+
session = self.get_session_maker()()
|
|
94
|
+
try:
|
|
95
|
+
yield session
|
|
96
|
+
session.commit()
|
|
97
|
+
except Exception:
|
|
98
|
+
session.rollback()
|
|
99
|
+
raise
|
|
100
|
+
finally:
|
|
101
|
+
session.close()
|
|
102
|
+
|
|
103
|
+
def ensure_database_exists(self) -> None:
|
|
104
|
+
settings = self.settings
|
|
105
|
+
|
|
106
|
+
if not settings.is_postgres_configured:
|
|
107
|
+
logger.warning("Database configuration is not valid, skipping database creation check")
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
engine = create_engine(
|
|
111
|
+
self._build_postgres_url("postgres"),
|
|
112
|
+
pool_pre_ping=True,
|
|
113
|
+
isolation_level="AUTOCOMMIT",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
with engine.connect() as conn:
|
|
118
|
+
exists = conn.execute(
|
|
119
|
+
text("SELECT 1 FROM pg_database WHERE datname = :dbname"),
|
|
120
|
+
{"dbname": settings.postgres_database},
|
|
121
|
+
).scalar()
|
|
122
|
+
|
|
123
|
+
if exists:
|
|
124
|
+
logger.info(f"Database '{settings.postgres_database}' already exists")
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
logger.info(f"Database '{settings.postgres_database}' does not exist, creating it...")
|
|
128
|
+
conn.execute(text(f'CREATE DATABASE "{settings.postgres_database}"'))
|
|
129
|
+
logger.info(f"Database '{settings.postgres_database}' created successfully")
|
|
130
|
+
finally:
|
|
131
|
+
engine.dispose()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def configure(client: Client) -> None:
|
|
135
|
+
"""Set the active SDK client for the current process."""
|
|
136
|
+
global _client
|
|
137
|
+
_client = client
|
|
138
|
+
get_settings.cache_clear()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def get_client() -> Client:
|
|
142
|
+
if _client is None:
|
|
143
|
+
raise RuntimeError(
|
|
144
|
+
"Vantage client is not configured. Call vantage.configure(Client(settings)) before using the SDK."
|
|
145
|
+
)
|
|
146
|
+
return _client
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@lru_cache
|
|
150
|
+
def get_settings() -> VantageSettings:
|
|
151
|
+
return get_client().settings
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@contextmanager
|
|
155
|
+
def get_session() -> Generator[Session, None, None]:
|
|
156
|
+
with get_client().get_session() as session:
|
|
157
|
+
yield session
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def ensure_database_exists() -> None:
|
|
161
|
+
"""Ensure the configured PostgreSQL database exists."""
|
|
162
|
+
get_client().ensure_database_exists()
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Prepare and upload data source logos to S3 (mirrors frontend logo pipeline)."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import mimetypes
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
from urllib.request import Request, urlopen
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from loguru import logger
|
|
11
|
+
from PIL import Image
|
|
12
|
+
from vantage.client import get_settings
|
|
13
|
+
from vantage.core.s3 import upload_object
|
|
14
|
+
|
|
15
|
+
SVG_MIME = "image/svg+xml"
|
|
16
|
+
MAX_INPUT_BYTES = 8 * 1024 * 1024
|
|
17
|
+
LOGO_MAX_DIMENSION = 200
|
|
18
|
+
WEBP_QUALITY = 85
|
|
19
|
+
LOGO_FETCH_USER_AGENT = "demo-flows/0.1 (Foresyn; data source logo sync)"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_extension_for_logo_content_type(content_type: str) -> str | None:
|
|
23
|
+
normalized = content_type.split(";")[0].strip().lower()
|
|
24
|
+
mapping = {
|
|
25
|
+
"image/webp": "webp",
|
|
26
|
+
"image/svg+xml": "svg",
|
|
27
|
+
"image/png": "png",
|
|
28
|
+
"image/jpeg": "jpg",
|
|
29
|
+
"image/jpg": "jpg",
|
|
30
|
+
}
|
|
31
|
+
return mapping.get(normalized)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def prepare_data_source_logo(
|
|
35
|
+
image_bytes: bytes,
|
|
36
|
+
*,
|
|
37
|
+
filename: str | None = None,
|
|
38
|
+
content_type: str | None = None,
|
|
39
|
+
) -> tuple[bytes, str]:
|
|
40
|
+
"""
|
|
41
|
+
Prepare a logo for upload.
|
|
42
|
+
|
|
43
|
+
Raster images: resize/compress to WebP, max 200×200px.
|
|
44
|
+
SVG: kept as vector text (no raster compression), basic structure check.
|
|
45
|
+
"""
|
|
46
|
+
if len(image_bytes) > MAX_INPUT_BYTES:
|
|
47
|
+
raise ValueError("FILE_TOO_LARGE")
|
|
48
|
+
|
|
49
|
+
guessed_type = content_type or (
|
|
50
|
+
mimetypes.guess_type(filename or "")[0] if filename else None
|
|
51
|
+
)
|
|
52
|
+
looks_svg = guessed_type == SVG_MIME or (
|
|
53
|
+
filename and filename.lower().endswith(".svg")
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if looks_svg:
|
|
57
|
+
return _prepare_svg_logo(image_bytes)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
return _prepare_raster_logo(image_bytes)
|
|
61
|
+
except ValueError:
|
|
62
|
+
raise
|
|
63
|
+
except Exception as exc:
|
|
64
|
+
raise ValueError("COMPRESS_FAILED") from exc
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def upload_data_source_logo(image_buffer: bytes, content_type: str) -> str:
|
|
68
|
+
"""
|
|
69
|
+
Upload a data source logo to S3. Uses a random object name (never the client filename).
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
S3 object key to store in ``DataSource.logoUrl`` (e.g. ``logos/uuid.webp``)
|
|
73
|
+
"""
|
|
74
|
+
ext = get_extension_for_logo_content_type(content_type)
|
|
75
|
+
if not ext:
|
|
76
|
+
raise ValueError(f"Unsupported logo content type: {content_type}")
|
|
77
|
+
|
|
78
|
+
normalized_type = content_type.split(";")[0].strip().lower()
|
|
79
|
+
object_key = f"logos/{uuid4()}.{ext}"
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
upload_object(
|
|
83
|
+
object_key=object_key,
|
|
84
|
+
body=image_buffer,
|
|
85
|
+
content_type=normalized_type or f"image/{'jpeg' if ext == 'jpg' else ext}",
|
|
86
|
+
)
|
|
87
|
+
logger.info("Uploaded data source logo to %s", object_key)
|
|
88
|
+
return object_key
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
logger.exception("Error uploading data source logo to S3")
|
|
91
|
+
raise RuntimeError(
|
|
92
|
+
f"Failed to upload data source logo: {exc if isinstance(exc, Exception) else 'Unknown error'}"
|
|
93
|
+
) from exc
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def upload_data_source_logo_from_path(path: str | Path) -> str:
|
|
97
|
+
"""Read a local file, prepare it, upload to S3, and return the object key."""
|
|
98
|
+
file_path = Path(path)
|
|
99
|
+
if not file_path.is_file():
|
|
100
|
+
raise FileNotFoundError(f"Logo file not found: {file_path}")
|
|
101
|
+
|
|
102
|
+
raw = file_path.read_bytes()
|
|
103
|
+
prepared, content_type = prepare_data_source_logo(raw, filename=file_path.name)
|
|
104
|
+
return upload_data_source_logo(prepared, content_type)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def upload_data_source_logo_from_url(url: str) -> str:
|
|
108
|
+
"""Download a remote image, prepare it, upload to S3, and return the object key."""
|
|
109
|
+
if not get_settings().is_s3_configured:
|
|
110
|
+
return url
|
|
111
|
+
|
|
112
|
+
parsed = urlparse(url)
|
|
113
|
+
if parsed.scheme not in ("http", "https"):
|
|
114
|
+
raise ValueError(f"Invalid logo URL: {url}")
|
|
115
|
+
|
|
116
|
+
request = Request(url, headers={"User-Agent": LOGO_FETCH_USER_AGENT})
|
|
117
|
+
with urlopen(request, timeout=30) as response:
|
|
118
|
+
raw = response.read()
|
|
119
|
+
content_type = response.headers.get("Content-Type")
|
|
120
|
+
filename = Path(parsed.path).name or None
|
|
121
|
+
|
|
122
|
+
prepared, content_type = prepare_data_source_logo(
|
|
123
|
+
raw, filename=filename, content_type=content_type
|
|
124
|
+
)
|
|
125
|
+
return upload_data_source_logo(prepared, content_type)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _prepare_svg_logo(image_bytes: bytes) -> tuple[bytes, str]:
|
|
129
|
+
try:
|
|
130
|
+
text = image_bytes.decode("utf-8")
|
|
131
|
+
except UnicodeDecodeError as exc:
|
|
132
|
+
raise ValueError("INVALID_SVG") from exc
|
|
133
|
+
|
|
134
|
+
if not text.strip().lstrip().lower().startswith("<svg"):
|
|
135
|
+
raise ValueError("INVALID_SVG")
|
|
136
|
+
|
|
137
|
+
return text.encode("utf-8"), SVG_MIME
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _prepare_raster_logo(image_bytes: bytes) -> tuple[bytes, str]:
|
|
141
|
+
with Image.open(io.BytesIO(image_bytes)) as img:
|
|
142
|
+
img.thumbnail(
|
|
143
|
+
(LOGO_MAX_DIMENSION, LOGO_MAX_DIMENSION), Image.Resampling.LANCZOS
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if img.mode in ("RGBA", "LA") or (
|
|
147
|
+
img.mode == "P" and "transparency" in img.info
|
|
148
|
+
):
|
|
149
|
+
img = img.convert("RGBA")
|
|
150
|
+
else:
|
|
151
|
+
img = img.convert("RGB")
|
|
152
|
+
|
|
153
|
+
out = io.BytesIO()
|
|
154
|
+
img.save(out, format="WEBP", quality=WEBP_QUALITY, method=6)
|
|
155
|
+
|
|
156
|
+
return out.getvalue(), "image/webp"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Shared helpers for resolving platform organizations."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import text
|
|
6
|
+
|
|
7
|
+
from vantage.client import get_session
|
|
8
|
+
from vantage.client import get_settings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_organization_by_slug(organization_slug: str) -> Optional[str]:
|
|
12
|
+
with get_session() as session:
|
|
13
|
+
organization = session.execute(
|
|
14
|
+
text(f"SELECT id FROM {get_settings().vantage_schema}.organization WHERE slug = :organization_slug"),
|
|
15
|
+
{"organization_slug": organization_slug},
|
|
16
|
+
).fetchone()
|
|
17
|
+
return organization[0] if organization else None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_organization_count() -> int:
|
|
21
|
+
with get_session() as session:
|
|
22
|
+
row = session.execute(text(f"SELECT COUNT(*) FROM {get_settings().vantage_schema}.organization")).fetchone()
|
|
23
|
+
return int(row[0]) if row else 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_single_organization_id_if_only_one_exists() -> Optional[str]:
|
|
27
|
+
with get_session() as session:
|
|
28
|
+
rows = session.execute(text(f"SELECT id FROM {get_settings().vantage_schema}.organization LIMIT 2")).fetchall()
|
|
29
|
+
if len(rows) == 1:
|
|
30
|
+
return rows[0][0]
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def resolve_organization_id(
|
|
35
|
+
organization_id: Optional[str] = None,
|
|
36
|
+
organization_slug: Optional[str] = None,
|
|
37
|
+
) -> str:
|
|
38
|
+
if organization_slug:
|
|
39
|
+
org_id = get_organization_by_slug(organization_slug=organization_slug)
|
|
40
|
+
if not org_id:
|
|
41
|
+
raise ValueError(f"Organization with slug '{organization_slug}' not found")
|
|
42
|
+
return org_id
|
|
43
|
+
if organization_id:
|
|
44
|
+
return organization_id
|
|
45
|
+
org_id = get_single_organization_id_if_only_one_exists()
|
|
46
|
+
if org_id:
|
|
47
|
+
return org_id
|
|
48
|
+
if get_organization_count() == 0:
|
|
49
|
+
raise ValueError("No organization found; provide organization_id or organization_slug")
|
|
50
|
+
raise ValueError(
|
|
51
|
+
"Multiple organizations found; provide organization_id or organization_slug"
|
|
52
|
+
)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""S3-compatible object storage client (MinIO, AWS S3, etc.)."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import boto3
|
|
8
|
+
from botocore.config import Config
|
|
9
|
+
|
|
10
|
+
from vantage.client import get_settings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class S3Config:
|
|
15
|
+
endpoint_url: str
|
|
16
|
+
access_key: str
|
|
17
|
+
secret_key: str
|
|
18
|
+
bucket_name: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_s3_config() -> S3Config:
|
|
22
|
+
settings = get_settings()
|
|
23
|
+
if not settings.is_s3_config_valid():
|
|
24
|
+
raise ValueError(
|
|
25
|
+
"S3 configuration is incomplete. Set S3_ENDPOINT_URL, S3_ACCESS_KEY, S3_SECRET_KEY, and S3_BUCKET_NAME."
|
|
26
|
+
)
|
|
27
|
+
return S3Config(
|
|
28
|
+
endpoint_url=settings.s3_endpoint_url, # type: ignore[arg-type]
|
|
29
|
+
access_key=settings.s3_access_key, # type: ignore[arg-type]
|
|
30
|
+
secret_key=settings.s3_secret_key, # type: ignore[arg-type]
|
|
31
|
+
bucket_name=settings.s3_bucket_name, # type: ignore[arg-type]
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@lru_cache
|
|
36
|
+
def get_s3_client() -> Any:
|
|
37
|
+
config = get_s3_config()
|
|
38
|
+
return boto3.client(
|
|
39
|
+
"s3",
|
|
40
|
+
endpoint_url=config.endpoint_url,
|
|
41
|
+
aws_access_key_id=config.access_key,
|
|
42
|
+
aws_secret_access_key=config.secret_key,
|
|
43
|
+
region_name="us-east-1",
|
|
44
|
+
config=Config(signature_version="s3v4"),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def upload_object(
|
|
49
|
+
object_key: str,
|
|
50
|
+
body: bytes,
|
|
51
|
+
content_type: str,
|
|
52
|
+
) -> str:
|
|
53
|
+
"""Upload bytes to the configured bucket. Returns the object key."""
|
|
54
|
+
config = get_s3_config()
|
|
55
|
+
client = get_s3_client()
|
|
56
|
+
client.put_object(
|
|
57
|
+
Bucket=config.bucket_name,
|
|
58
|
+
Key=object_key,
|
|
59
|
+
Body=body,
|
|
60
|
+
ContentLength=len(body),
|
|
61
|
+
ContentType=content_type,
|
|
62
|
+
)
|
|
63
|
+
return object_key
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Platform data source definition."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional, Union
|
|
6
|
+
|
|
7
|
+
from vantage.client import get_session
|
|
8
|
+
from vantage.data_sources.data_source_category import DataSourceCategory
|
|
9
|
+
from vantage.data_sources.sync.data_source_helpers import get_data_source_id_by_slug
|
|
10
|
+
from vantage.core.logo import (
|
|
11
|
+
upload_data_source_logo_from_path,
|
|
12
|
+
upload_data_source_logo_from_url,
|
|
13
|
+
)
|
|
14
|
+
from vantage.data_sources.sync.sync_data_source import sync_data_source
|
|
15
|
+
from vantage.core.organization import resolve_organization_id
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class DataSource:
|
|
20
|
+
"""Platform data source."""
|
|
21
|
+
|
|
22
|
+
slug: str # URL-safe unique identifier for the data source
|
|
23
|
+
name: str # Display name shown in the platform
|
|
24
|
+
category: DataSourceCategory # Category this data source belongs to (synced first)
|
|
25
|
+
description: Optional[str] = None # Optional summary shown in the platform UI
|
|
26
|
+
logo_path: Optional[Union[str, Path]] = None # Local logo file uploaded on first sync
|
|
27
|
+
logo_url: Optional[str] = None # Remote logo URL or existing platform logo URL
|
|
28
|
+
organization_id: Optional[str] = None # Platform organization UUID (falls back to category value)
|
|
29
|
+
organization_slug: Optional[str] = None # Organization slug (falls back to category value)
|
|
30
|
+
data_source_id: Optional[str] = field(default=None, init=False) # Set after sync(); platform data source UUID
|
|
31
|
+
|
|
32
|
+
def _resolve_organization_id(self) -> str:
|
|
33
|
+
return resolve_organization_id(
|
|
34
|
+
organization_id=self.organization_id or self.category.organization_id,
|
|
35
|
+
organization_slug=self.organization_slug or self.category.organization_slug,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def _resolve_logo_url(self) -> str:
|
|
39
|
+
if self.logo_path and self.logo_url:
|
|
40
|
+
raise ValueError("Provide only one of logo_path or logo_url")
|
|
41
|
+
if self.logo_path:
|
|
42
|
+
return upload_data_source_logo_from_path(self.logo_path)
|
|
43
|
+
if self.logo_url:
|
|
44
|
+
if self.logo_url.startswith(("http://", "https://")):
|
|
45
|
+
return upload_data_source_logo_from_url(self.logo_url)
|
|
46
|
+
return self.logo_url
|
|
47
|
+
raise ValueError("Either logo_path or logo_url is required")
|
|
48
|
+
|
|
49
|
+
def sync(self) -> str:
|
|
50
|
+
"""Sync this data source (and its category) to the vantage. Returns the data source ID."""
|
|
51
|
+
org_id = self._resolve_organization_id()
|
|
52
|
+
self.category.organization_id = self.category.organization_id or org_id
|
|
53
|
+
category_id = self.category.sync()
|
|
54
|
+
|
|
55
|
+
with get_session() as session:
|
|
56
|
+
existing = get_data_source_id_by_slug(session, org_id, self.slug) is not None
|
|
57
|
+
|
|
58
|
+
logo_url = "" if existing else self._resolve_logo_url()
|
|
59
|
+
|
|
60
|
+
self.data_source_id = sync_data_source(
|
|
61
|
+
slug=self.slug,
|
|
62
|
+
name=self.name,
|
|
63
|
+
logo_url=logo_url,
|
|
64
|
+
category_id=category_id,
|
|
65
|
+
description=self.description,
|
|
66
|
+
organization_id=org_id,
|
|
67
|
+
)
|
|
68
|
+
return self.data_source_id
|