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.
Files changed (112) hide show
  1. drc_vantage-0.1.0/.env.example +17 -0
  2. drc_vantage-0.1.0/.gitignore +79 -0
  3. drc_vantage-0.1.0/.python-version +1 -0
  4. drc_vantage-0.1.0/PKG-INFO +17 -0
  5. drc_vantage-0.1.0/README.md +0 -0
  6. drc_vantage-0.1.0/pyproject.toml +27 -0
  7. drc_vantage-0.1.0/src/vantage/__init__.py +21 -0
  8. drc_vantage-0.1.0/src/vantage/client.py +162 -0
  9. drc_vantage-0.1.0/src/vantage/core/logo.py +156 -0
  10. drc_vantage-0.1.0/src/vantage/core/organization.py +52 -0
  11. drc_vantage-0.1.0/src/vantage/core/s3.py +63 -0
  12. drc_vantage-0.1.0/src/vantage/data_sources/data_source.py +68 -0
  13. drc_vantage-0.1.0/src/vantage/data_sources/data_source_category.py +29 -0
  14. drc_vantage-0.1.0/src/vantage/data_sources/sync/data_source_helpers.py +24 -0
  15. drc_vantage-0.1.0/src/vantage/data_sources/sync/sync_data_source.py +96 -0
  16. drc_vantage-0.1.0/src/vantage/data_sources/sync/sync_data_source_category.py +86 -0
  17. drc_vantage-0.1.0/src/vantage/data_storage_system/data_storage_system.py +74 -0
  18. drc_vantage-0.1.0/src/vantage/data_storage_system/sync/data_storage_system_helpers.py +50 -0
  19. drc_vantage-0.1.0/src/vantage/data_storage_system/sync/sync_data_storage_system.py +106 -0
  20. drc_vantage-0.1.0/src/vantage/datasets/alerts/check_alert.py +21 -0
  21. drc_vantage-0.1.0/src/vantage/datasets/alerts/check_dataset_alerts.py +22 -0
  22. drc_vantage-0.1.0/src/vantage/datasets/alerts/email.py +170 -0
  23. drc_vantage-0.1.0/src/vantage/datasets/alerts/evaluate_alert.py +284 -0
  24. drc_vantage-0.1.0/src/vantage/datasets/alerts/queries.py +127 -0
  25. drc_vantage-0.1.0/src/vantage/datasets/alerts/run_alert_check.py +97 -0
  26. drc_vantage-0.1.0/src/vantage/datasets/analysis/analyze_dataset.py +17 -0
  27. drc_vantage-0.1.0/src/vantage/datasets/analysis/column_analysis.py +436 -0
  28. drc_vantage-0.1.0/src/vantage/datasets/analysis/health_score.py +138 -0
  29. drc_vantage-0.1.0/src/vantage/datasets/analysis/list_dataset_ids.py +15 -0
  30. drc_vantage-0.1.0/src/vantage/datasets/analysis/metrics/anomalies.py +168 -0
  31. drc_vantage-0.1.0/src/vantage/datasets/analysis/metrics/completion.py +104 -0
  32. drc_vantage-0.1.0/src/vantage/datasets/analysis/metrics/row_count.py +140 -0
  33. drc_vantage-0.1.0/src/vantage/datasets/analysis/run_analysis.py +234 -0
  34. drc_vantage-0.1.0/src/vantage/datasets/analysis/smart_alerts.py +138 -0
  35. drc_vantage-0.1.0/src/vantage/datasets/dataset.py +143 -0
  36. drc_vantage-0.1.0/src/vantage/datasets/linking/assign_dataset_data_sources.py +109 -0
  37. drc_vantage-0.1.0/src/vantage/datasets/sync/dataset_helpers.py +274 -0
  38. drc_vantage-0.1.0/src/vantage/datasets/sync/materialized_views.py +114 -0
  39. drc_vantage-0.1.0/src/vantage/datasets/sync/sync_dataset.py +228 -0
  40. drc_vantage-0.1.0/src/vantage/datasets/teams/assign_dataset_to_team.py +284 -0
  41. drc_vantage-0.1.0/src/vantage/datasets/teams/team_helpers.py +68 -0
  42. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/__init__.py +3 -0
  43. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_avg.py +71 -0
  44. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_between.py +56 -0
  45. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_count_distinct.py +65 -0
  46. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_count_not_null.py +65 -0
  47. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_count_null.py +57 -0
  48. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_max.py +61 -0
  49. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_max_length.py +55 -0
  50. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_mean.py +71 -0
  51. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_median.py +75 -0
  52. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_min.py +61 -0
  53. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_min_length.py +55 -0
  54. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_not_between.py +56 -0
  55. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_not_null.py +43 -0
  56. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_percent_not_null.py +80 -0
  57. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_percent_null.py +78 -0
  58. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_percentile.py +83 -0
  59. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_regex_match.py +55 -0
  60. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_regex_not_match.py +55 -0
  61. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_stddev.py +71 -0
  62. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_sum.py +63 -0
  63. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_unique.py +46 -0
  64. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_values_in_set.py +59 -0
  65. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_column_values_not_in_set.py +58 -0
  66. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_custom_sql.py +57 -0
  67. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_table_duplicate_rows.py +51 -0
  68. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_table_freshness.py +84 -0
  69. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_table_row_count.py +51 -0
  70. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_table_row_count_between.py +42 -0
  71. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/check_table_schema_change.py +24 -0
  72. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/run_check.py +122 -0
  73. drc_vantage-0.1.0/src/vantage/datasets/validations/checks/sql_helpers.py +80 -0
  74. drc_vantage-0.1.0/src/vantage/datasets/validations/get_dataset_info.py +65 -0
  75. drc_vantage-0.1.0/src/vantage/datasets/validations/get_dataset_validations.py +105 -0
  76. drc_vantage-0.1.0/src/vantage/datasets/validations/recommend_validations.py +145 -0
  77. drc_vantage-0.1.0/src/vantage/datasets/validations/recommendations/dedupe.py +154 -0
  78. drc_vantage-0.1.0/src/vantage/datasets/validations/recommendations/fetch.py +334 -0
  79. drc_vantage-0.1.0/src/vantage/datasets/validations/recommendations/llm.py +78 -0
  80. drc_vantage-0.1.0/src/vantage/datasets/validations/recommendations/normalize.py +75 -0
  81. drc_vantage-0.1.0/src/vantage/datasets/validations/recommendations/prompt.py +140 -0
  82. drc_vantage-0.1.0/src/vantage/datasets/validations/recommendations/save.py +167 -0
  83. drc_vantage-0.1.0/src/vantage/datasets/validations/recommendations/schemas.py +67 -0
  84. drc_vantage-0.1.0/src/vantage/datasets/validations/rule_config.py +188 -0
  85. drc_vantage-0.1.0/src/vantage/datasets/validations/rule_config_reference.json +143 -0
  86. drc_vantage-0.1.0/src/vantage/datasets/validations/run_all_dataset_validation_suites.py +28 -0
  87. drc_vantage-0.1.0/src/vantage/datasets/validations/run_dataset_validations.py +13 -0
  88. drc_vantage-0.1.0/src/vantage/datasets/validations/run_validation.py +18 -0
  89. drc_vantage-0.1.0/src/vantage/datasets/validations/run_validation_rule.py +121 -0
  90. drc_vantage-0.1.0/src/vantage/datasets/validations/run_validation_suite.py +191 -0
  91. drc_vantage-0.1.0/src/vantage/datasets/validations/schemas.py +136 -0
  92. drc_vantage-0.1.0/src/vantage/kpis/compute/compute.py +161 -0
  93. drc_vantage-0.1.0/src/vantage/kpis/compute/compute_manual.py +401 -0
  94. drc_vantage-0.1.0/src/vantage/kpis/compute/compute_sql.py +37 -0
  95. drc_vantage-0.1.0/src/vantage/kpis/compute/fetch.py +36 -0
  96. drc_vantage-0.1.0/src/vantage/kpis/compute/save.py +66 -0
  97. drc_vantage-0.1.0/src/vantage/kpis/compute/schemas.py +0 -0
  98. drc_vantage-0.1.0/src/vantage/smart_cockpit/ai_summary/fetch.py +106 -0
  99. drc_vantage-0.1.0/src/vantage/smart_cockpit/ai_summary/llm.py +112 -0
  100. drc_vantage-0.1.0/src/vantage/smart_cockpit/ai_summary/prompt.py +86 -0
  101. drc_vantage-0.1.0/src/vantage/smart_cockpit/ai_summary/save.py +80 -0
  102. drc_vantage-0.1.0/src/vantage/smart_cockpit/ai_summary/schemas.py +26 -0
  103. drc_vantage-0.1.0/src/vantage/smart_cockpit/company_news/llm.py +0 -0
  104. drc_vantage-0.1.0/src/vantage/smart_cockpit/company_news/save.py +0 -0
  105. drc_vantage-0.1.0/src/vantage/smart_cockpit/company_news/schemas.py +0 -0
  106. drc_vantage-0.1.0/src/vantage/smart_cockpit/configuration.py +35 -0
  107. drc_vantage-0.1.0/src/vantage/smart_cockpit/kpi_insights/fetch.py +61 -0
  108. drc_vantage-0.1.0/src/vantage/smart_cockpit/kpi_insights/llm.py +59 -0
  109. drc_vantage-0.1.0/src/vantage/smart_cockpit/kpi_insights/prompt.py +117 -0
  110. drc_vantage-0.1.0/src/vantage/smart_cockpit/kpi_insights/save.py +79 -0
  111. drc_vantage-0.1.0/src/vantage/smart_cockpit/regenerate/generate_missing_kpi_insights.py +29 -0
  112. 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