easy-data-loader 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.
- easy_data_loader-0.1.0/LICENSE +21 -0
- easy_data_loader-0.1.0/PKG-INFO +52 -0
- easy_data_loader-0.1.0/README.md +32 -0
- easy_data_loader-0.1.0/pyproject.toml +37 -0
- easy_data_loader-0.1.0/setup.cfg +4 -0
- easy_data_loader-0.1.0/src/easy_data_loader/__init__.py +11 -0
- easy_data_loader-0.1.0/src/easy_data_loader/cli.py +302 -0
- easy_data_loader-0.1.0/src/easy_data_loader/config_loader.py +184 -0
- easy_data_loader-0.1.0/src/easy_data_loader/custom_exceptions.py +21 -0
- easy_data_loader-0.1.0/src/easy_data_loader/database_connector.py +190 -0
- easy_data_loader-0.1.0/src/easy_data_loader/database_operations.py +129 -0
- easy_data_loader-0.1.0/src/easy_data_loader/driver_detector.py +46 -0
- easy_data_loader-0.1.0/src/easy_data_loader/file_operations.py +146 -0
- easy_data_loader-0.1.0/src/easy_data_loader/log.py +90 -0
- easy_data_loader-0.1.0/src/easy_data_loader/models.py +168 -0
- easy_data_loader-0.1.0/src/easy_data_loader/orchestrator.py +59 -0
- easy_data_loader-0.1.0/src/easy_data_loader/pipeline.py +169 -0
- easy_data_loader-0.1.0/src/easy_data_loader/pipeline_base.py +121 -0
- easy_data_loader-0.1.0/src/easy_data_loader/procedure_pipeline.py +56 -0
- easy_data_loader-0.1.0/src/easy_data_loader.egg-info/PKG-INFO +52 -0
- easy_data_loader-0.1.0/src/easy_data_loader.egg-info/SOURCES.txt +24 -0
- easy_data_loader-0.1.0/src/easy_data_loader.egg-info/dependency_links.txt +1 -0
- easy_data_loader-0.1.0/src/easy_data_loader.egg-info/entry_points.txt +2 -0
- easy_data_loader-0.1.0/src/easy_data_loader.egg-info/requires.txt +10 -0
- easy_data_loader-0.1.0/src/easy_data_loader.egg-info/top_level.txt +1 -0
- easy_data_loader-0.1.0/tests/test_imports.py +7 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Bojoi Gabriel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: easy_data_loader
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Data transfer utilities between files and databases
|
|
5
|
+
Author-email: Bojoi Gabriel <bojoigabriel@gmail.com>
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: click>=8.3.0
|
|
10
|
+
Requires-Dist: ipykernel
|
|
11
|
+
Requires-Dist: openpyxl>=3.1.5
|
|
12
|
+
Requires-Dist: pandas>=2.3.3
|
|
13
|
+
Requires-Dist: pyarrow>=22.0.0
|
|
14
|
+
Requires-Dist: pydantic>=2.12.5
|
|
15
|
+
Requires-Dist: pydantic-settings>=2.12.0
|
|
16
|
+
Requires-Dist: pyodbc>=5.2.0
|
|
17
|
+
Requires-Dist: python-dotenv>=1.1.1
|
|
18
|
+
Requires-Dist: sqlalchemy>=2.0.43
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# Easy Data Loader 🚀
|
|
22
|
+
|
|
23
|
+
**Easy Data Loader** is a flexible, modular Python library designed to streamline ETL (Extract, Transform, Load) processes between various data sources (CSV, Excel, Parquet) and SQL databases (MSSQL, PostgreSQL, and others).
|
|
24
|
+
|
|
25
|
+
## ✨ Key Features
|
|
26
|
+
- **Declarative Configuration**: Manage connections and pipelines through simple python files and `.env` resources.
|
|
27
|
+
- **Integrated CLI**: Initialize a standardized project structure with a single command.
|
|
28
|
+
- **Custom Transformation Hooks**: Inject your own Pandas transformation logic directly into the pipeline execution.
|
|
29
|
+
- **Performance Optimized**: Built-in support for chunked loading and writing to handle large datasets efficiently.
|
|
30
|
+
- **Extensible Architecture**: Uses a Factory Pattern for database connectors, making it easy to support new drivers.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## 📦 Installation
|
|
35
|
+
|
|
36
|
+
Install directly via `pip` or `uv`:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install easy_data_loader
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## 🚀 Getting Started
|
|
43
|
+
|
|
44
|
+
1. Initialize a new project structure to generate template configurations:
|
|
45
|
+
```bash
|
|
46
|
+
easy-loader init
|
|
47
|
+
```
|
|
48
|
+
2. Review the generated `config/` folders for sample resources and pipelines.
|
|
49
|
+
3. Run all discovered pipelines across the active configurations:
|
|
50
|
+
```bash
|
|
51
|
+
easy-loader run_all
|
|
52
|
+
```
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Easy Data Loader 🚀
|
|
2
|
+
|
|
3
|
+
**Easy Data Loader** is a flexible, modular Python library designed to streamline ETL (Extract, Transform, Load) processes between various data sources (CSV, Excel, Parquet) and SQL databases (MSSQL, PostgreSQL, and others).
|
|
4
|
+
|
|
5
|
+
## ✨ Key Features
|
|
6
|
+
- **Declarative Configuration**: Manage connections and pipelines through simple python files and `.env` resources.
|
|
7
|
+
- **Integrated CLI**: Initialize a standardized project structure with a single command.
|
|
8
|
+
- **Custom Transformation Hooks**: Inject your own Pandas transformation logic directly into the pipeline execution.
|
|
9
|
+
- **Performance Optimized**: Built-in support for chunked loading and writing to handle large datasets efficiently.
|
|
10
|
+
- **Extensible Architecture**: Uses a Factory Pattern for database connectors, making it easy to support new drivers.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 📦 Installation
|
|
15
|
+
|
|
16
|
+
Install directly via `pip` or `uv`:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install easy_data_loader
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## 🚀 Getting Started
|
|
23
|
+
|
|
24
|
+
1. Initialize a new project structure to generate template configurations:
|
|
25
|
+
```bash
|
|
26
|
+
easy-loader init
|
|
27
|
+
```
|
|
28
|
+
2. Review the generated `config/` folders for sample resources and pipelines.
|
|
29
|
+
3. Run all discovered pipelines across the active configurations:
|
|
30
|
+
```bash
|
|
31
|
+
easy-loader run_all
|
|
32
|
+
```
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "easy_data_loader"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description ="Data transfer utilities between files and databases"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Bojoi Gabriel", email = "bojoigabriel@gmail.com"}
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"click>=8.3.0",
|
|
12
|
+
"ipykernel",
|
|
13
|
+
"openpyxl>=3.1.5",
|
|
14
|
+
"pandas>=2.3.3",
|
|
15
|
+
"pyarrow>=22.0.0",
|
|
16
|
+
"pydantic>=2.12.5",
|
|
17
|
+
"pydantic-settings>=2.12.0",
|
|
18
|
+
"pyodbc>=5.2.0",
|
|
19
|
+
"python-dotenv>=1.1.1",
|
|
20
|
+
"sqlalchemy>=2.0.43",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = [
|
|
25
|
+
"ipykernel>=7.1.0",
|
|
26
|
+
"pytest>=8.4.2",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
easy-loader = "easy_data_loader.cli:main"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["setuptools>=61.0"]
|
|
37
|
+
build-backend = "setuptools.build_meta"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
2
|
+
|
|
3
|
+
from .pipeline import LoadPipeline
|
|
4
|
+
from .procedure_pipeline import ProcedurePipeline
|
|
5
|
+
from .orchestrator import OrchestratorPipeline
|
|
6
|
+
from .models import BasePipelineDefinition, ProcedureDefinition, ColumnDefinition, OrchestratorDefinition
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"LoadPipeline", "ProcedurePipeline", "OrchestratorPipeline",
|
|
10
|
+
"BasePipelineDefinition", "ProcedureDefinition", "ColumnDefinition", "OrchestratorDefinition"
|
|
11
|
+
]
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
# Integrated templates
|
|
6
|
+
PIPELINE_TEMPLATE = """
|
|
7
|
+
from easy_data_loader.pipeline import LoadPipeline
|
|
8
|
+
from easy_data_loader.models import BasePipelineDefinition, ColumnDefinition
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from sqlalchemy.types import DATETIME, INT, DECIMAL, NVARCHAR
|
|
11
|
+
|
|
12
|
+
example_pipeline = BasePipelineDefinition(
|
|
13
|
+
pipeline_name="test_pipeline", # pipeline name to be used when initializing the Pipeline object
|
|
14
|
+
|
|
15
|
+
# source name represented by the file having the source settings -> corresponds to a file in the config/resources folder
|
|
16
|
+
source = "example_file",
|
|
17
|
+
|
|
18
|
+
# "dbo.FactSales" ## if the source is a database then source sql defines the table name or a custom query to be executed
|
|
19
|
+
# source_sql = "SELECT TOP 100 * FROM dbo.FactSales"
|
|
20
|
+
|
|
21
|
+
# destination name represented by the file having the destination settings -> corresponds to a file in the config/resources folder
|
|
22
|
+
destination="example_database",
|
|
23
|
+
|
|
24
|
+
# if the destination is a database then here we define the destination table name
|
|
25
|
+
destination_table="dbo.LargeSalesData",
|
|
26
|
+
|
|
27
|
+
# columns definition if we are sending data to a database table
|
|
28
|
+
columns={
|
|
29
|
+
"transaction_id": ColumnDefinition(target_name="new_transaction_id", data_type=INT()),
|
|
30
|
+
"date": ColumnDefinition(target_name="sales_date", data_type=DATETIME()),
|
|
31
|
+
"customer_id": ColumnDefinition(target_name="id_customer", data_type=INT()),
|
|
32
|
+
"product_category" : ColumnDefinition(target_name="category_of_product", data_type=NVARCHAR(100)),
|
|
33
|
+
"units_sold" : ColumnDefinition(target_name="units", data_type=INT()),
|
|
34
|
+
"unit_price" : ColumnDefinition(target_name="price", data_type=DECIMAL(6,2)),
|
|
35
|
+
"raw_notes" : ColumnDefinition(target_name="notes", data_type=NVARCHAR(100))
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
# different parameters passed to the write functions
|
|
39
|
+
write_parameters={"if_exists" : "replace", "index" : False},
|
|
40
|
+
|
|
41
|
+
# different parameters passed to the read function
|
|
42
|
+
read_parameters={"sep" : ";"}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def add_timestamp(df):
|
|
46
|
+
# Adding an audit column during load
|
|
47
|
+
df['insert_timestamp'] = pd.Timestamp.now()
|
|
48
|
+
return df
|
|
49
|
+
|
|
50
|
+
example_pipeline.transform = add_timestamp
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
PROCEDURE_TEMPLATE = """
|
|
54
|
+
from easy_data_loader.models import ProcedureDefinition
|
|
55
|
+
|
|
56
|
+
example_procedure = ProcedureDefinition(
|
|
57
|
+
pipeline_name="example_procedure",
|
|
58
|
+
resource="example_database",
|
|
59
|
+
procedures=[
|
|
60
|
+
("dbo.sp_UpdateSales", {"year": 2024}),
|
|
61
|
+
("dbo.sp_ArchiveOldData", {})
|
|
62
|
+
]
|
|
63
|
+
)
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
ORCHESTRATOR_TEMPLATE = """
|
|
67
|
+
from easy_data_loader.models import OrchestratorDefinition
|
|
68
|
+
|
|
69
|
+
example_orchestrator = OrchestratorDefinition(
|
|
70
|
+
orchestrator_name="example_orchestrator",
|
|
71
|
+
pipelines=[
|
|
72
|
+
"example_pipeline",
|
|
73
|
+
"example_procedure"
|
|
74
|
+
],
|
|
75
|
+
fail_fast=True
|
|
76
|
+
)
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
DATABASE_ENV = """
|
|
81
|
+
# database resource definition
|
|
82
|
+
CONN_SERVER_TYPE=MSSQL
|
|
83
|
+
CONN_SERVER=.
|
|
84
|
+
CONN_DATABASE=test_database
|
|
85
|
+
CONN_USERNAME=my_user
|
|
86
|
+
CONN_PASSWORD=my_password
|
|
87
|
+
CONN_PORT=1433
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
FILE_ENV = """
|
|
91
|
+
# file resource definition
|
|
92
|
+
FILE_TYPE=CSV
|
|
93
|
+
FOLDER_PATH=./data/imports
|
|
94
|
+
FILE_NAME=large_sales_data
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
MAIN = """
|
|
98
|
+
from easy_data_loader.pipeline import LoadPipeline
|
|
99
|
+
|
|
100
|
+
# Run an ETL pipeline
|
|
101
|
+
LoadPipeline(pipeline_name="example_pipeline").run()
|
|
102
|
+
|
|
103
|
+
# Run a procedure pipeline
|
|
104
|
+
# from easy_data_loader.procedure_pipeline import ProcedurePipeline
|
|
105
|
+
# ProcedurePipeline(pipeline_name="example_procedure").run()
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
@click.group()
|
|
109
|
+
def main():
|
|
110
|
+
"""Easy Data Loader CLI - ETL instrument between files and databases"""
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
@main.command()
|
|
114
|
+
def init():
|
|
115
|
+
"""Initialize folder structure and sample files"""
|
|
116
|
+
base_path = Path.cwd()
|
|
117
|
+
|
|
118
|
+
# folders
|
|
119
|
+
folders = ['config/resources', 'config/pipelines']
|
|
120
|
+
for folder in folders:
|
|
121
|
+
(base_path / folder).mkdir(parents=True, exist_ok=True)
|
|
122
|
+
|
|
123
|
+
# Example files to create
|
|
124
|
+
files = {
|
|
125
|
+
"config/pipelines/pipeline_example.py": PIPELINE_TEMPLATE,
|
|
126
|
+
"config/pipelines/procedure_example.py": PROCEDURE_TEMPLATE,
|
|
127
|
+
"config/pipelines/orchestrator_example.py": ORCHESTRATOR_TEMPLATE,
|
|
128
|
+
"config/resources/database_example.env": DATABASE_ENV,
|
|
129
|
+
"config/resources/file_example.env": FILE_ENV,
|
|
130
|
+
"main.py" : MAIN,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for name, content in files.items():
|
|
134
|
+
file_path = base_path / name
|
|
135
|
+
if not file_path.exists():
|
|
136
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
137
|
+
f.write(content)
|
|
138
|
+
click.echo(f"Created: {name}")
|
|
139
|
+
else:
|
|
140
|
+
click.echo(f"Skipped: {name} (already exists)")
|
|
141
|
+
|
|
142
|
+
click.echo("\nProject initialized successfully!")
|
|
143
|
+
|
|
144
|
+
@main.command()
|
|
145
|
+
def list():
|
|
146
|
+
"""List all discovered resources and pipelines"""
|
|
147
|
+
from .config_loader import Configuration
|
|
148
|
+
config = Configuration()
|
|
149
|
+
|
|
150
|
+
click.echo("--- Discovered Resources ---")
|
|
151
|
+
for name in config.get_all_resources():
|
|
152
|
+
click.echo(f" - {name}")
|
|
153
|
+
|
|
154
|
+
click.echo("\n--- Discovered Pipelines ---")
|
|
155
|
+
for name in config.get_all_pipelines():
|
|
156
|
+
click.echo(f" - {name}")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@main.command()
|
|
160
|
+
@click.argument('resource_name')
|
|
161
|
+
@click.argument('table_name')
|
|
162
|
+
def inspect_db(resource_name, table_name):
|
|
163
|
+
"""Inspect a database table and generate ColumnDefinition code"""
|
|
164
|
+
from .config_loader import Configuration
|
|
165
|
+
from .database_connector import CONNECTOR_FACTORY
|
|
166
|
+
from .database_operations import DatabaseOperations
|
|
167
|
+
from .models import ConnectionSettings
|
|
168
|
+
|
|
169
|
+
config = Configuration()
|
|
170
|
+
resource = config.get_resource(resource_name)
|
|
171
|
+
|
|
172
|
+
if not isinstance(resource, ConnectionSettings):
|
|
173
|
+
click.echo(f"Error: Resource '{resource_name}' is not a database connection.")
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
# Initialize connector and ops
|
|
177
|
+
connector = CONNECTOR_FACTORY[resource.conn_server_type](resource)
|
|
178
|
+
ops = DatabaseOperations(connector.get_engine())
|
|
179
|
+
|
|
180
|
+
schema = ops.inspect_table(table_name)
|
|
181
|
+
|
|
182
|
+
if not schema:
|
|
183
|
+
click.echo(f"No columns found for table '{table_name}'.")
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
click.echo(f"\n# Suggested Column definitions for {table_name}:")
|
|
187
|
+
click.echo("columns={")
|
|
188
|
+
for col, dtype in schema.items():
|
|
189
|
+
click.echo(f' "{col}": ColumnDefinition(target_name="{col}", data_type={dtype}),')
|
|
190
|
+
click.echo("}")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@main.command()
|
|
194
|
+
def run_all():
|
|
195
|
+
"""Run all discovered pipelines and show status summary"""
|
|
196
|
+
from .config_loader import Configuration
|
|
197
|
+
from .pipeline import LoadPipeline
|
|
198
|
+
from .procedure_pipeline import ProcedurePipeline
|
|
199
|
+
from .models import BasePipelineDefinition, ProcedureDefinition, OrchestratorDefinition
|
|
200
|
+
|
|
201
|
+
config = Configuration()
|
|
202
|
+
pipelines = config.get_all_pipelines()
|
|
203
|
+
|
|
204
|
+
if not pipelines:
|
|
205
|
+
click.echo("No pipelines discovered.")
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
results = {}
|
|
209
|
+
|
|
210
|
+
click.echo(f"🚀 Running {len(pipelines)} discovered pipelines...\n")
|
|
211
|
+
|
|
212
|
+
for name in pipelines:
|
|
213
|
+
click.echo(f"Pipeline: {name} ... ", nl=False)
|
|
214
|
+
try:
|
|
215
|
+
definition = config.get_pipeline(name)
|
|
216
|
+
if isinstance(definition, BasePipelineDefinition):
|
|
217
|
+
success = LoadPipeline(name).run()
|
|
218
|
+
elif isinstance(definition, ProcedureDefinition):
|
|
219
|
+
success = ProcedurePipeline(name).run()
|
|
220
|
+
elif isinstance(definition, OrchestratorDefinition):
|
|
221
|
+
from .orchestrator import OrchestratorPipeline
|
|
222
|
+
success = OrchestratorPipeline(name).run()
|
|
223
|
+
else:
|
|
224
|
+
success = False
|
|
225
|
+
|
|
226
|
+
results[name] = "SUCCESS" if success else "FAILED"
|
|
227
|
+
except Exception as e:
|
|
228
|
+
results[name] = f"ERROR: {str(e)}"
|
|
229
|
+
|
|
230
|
+
click.echo(results[name])
|
|
231
|
+
|
|
232
|
+
click.echo("\n" + "=" * 40)
|
|
233
|
+
click.echo(f"{'PIPELINE':<25} | {'STATUS'}")
|
|
234
|
+
click.echo("-" * 40)
|
|
235
|
+
for name, status in results.items():
|
|
236
|
+
click.echo(f"{name:<25} | {status}")
|
|
237
|
+
|
|
238
|
+
@main.command()
|
|
239
|
+
@click.argument('orchestrator_name')
|
|
240
|
+
def run_orchestrator(orchestrator_name):
|
|
241
|
+
"""Run a specific orchestrator by name"""
|
|
242
|
+
from .orchestrator import OrchestratorPipeline
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
success = OrchestratorPipeline(orchestrator_name).run()
|
|
246
|
+
if success:
|
|
247
|
+
click.echo(f"✅ Orchestrator '{orchestrator_name}' completed successfully.")
|
|
248
|
+
else:
|
|
249
|
+
click.echo(f"❌ Orchestrator '{orchestrator_name}' failed.")
|
|
250
|
+
except Exception as e:
|
|
251
|
+
click.echo(f"💥 Error: {str(e)}")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@main.command()
|
|
255
|
+
def validate_resources():
|
|
256
|
+
"""Validate all configured resources"""
|
|
257
|
+
from .config_loader import Configuration
|
|
258
|
+
from .database_connector import CONNECTOR_FACTORY
|
|
259
|
+
from .models import ConnectionSettings, FileSettings
|
|
260
|
+
|
|
261
|
+
config = Configuration()
|
|
262
|
+
resources = config.get_all_resources()
|
|
263
|
+
|
|
264
|
+
if not resources:
|
|
265
|
+
click.echo("No resources found.")
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
click.echo(f"🔍 Validating {len(resources)} resources...\n")
|
|
269
|
+
|
|
270
|
+
results = {}
|
|
271
|
+
|
|
272
|
+
for name, resource in resources.items():
|
|
273
|
+
click.echo(f"Resource: {name} ... ", nl=False)
|
|
274
|
+
try:
|
|
275
|
+
if isinstance(resource, ConnectionSettings):
|
|
276
|
+
# Validate Database Connection
|
|
277
|
+
connector = CONNECTOR_FACTORY[resource.conn_server_type](resource)
|
|
278
|
+
# The connector tests connection in __init__, so if we are here it passed
|
|
279
|
+
results[name] = "OK (Connected)"
|
|
280
|
+
elif isinstance(resource, FileSettings):
|
|
281
|
+
# Validate File Path
|
|
282
|
+
if resource.folder_path.exists():
|
|
283
|
+
results[name] = "OK (Path Exists)"
|
|
284
|
+
else:
|
|
285
|
+
raise ValueError(f"Path does not exist: {resource.folder_path}")
|
|
286
|
+
else:
|
|
287
|
+
results[name] = "UNKNOWN TYPE"
|
|
288
|
+
|
|
289
|
+
except Exception as e:
|
|
290
|
+
results[name] = f"FAILED: {str(e)}"
|
|
291
|
+
|
|
292
|
+
click.echo(results[name])
|
|
293
|
+
|
|
294
|
+
click.echo("\n" + "=" * 60)
|
|
295
|
+
click.echo(f"{'RESOURCE':<30} | {'STATUS'}")
|
|
296
|
+
click.echo("-" * 60)
|
|
297
|
+
for name, status in results.items():
|
|
298
|
+
click.echo(f"{name:<30} | {status}")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
if __name__ == "__main__":
|
|
302
|
+
main()
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Dict, Union
|
|
4
|
+
from types import ModuleType
|
|
5
|
+
from dotenv import dotenv_values
|
|
6
|
+
|
|
7
|
+
from .log import LoggedComponent
|
|
8
|
+
from .models import BasePipelineDefinition, ProcedureDefinition, OrchestratorDefinition, ConnectionSettings, FileSettings, ResourceConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Configuration(LoggedComponent):
|
|
12
|
+
"""
|
|
13
|
+
Configuration manager with lazy loading capabilities.
|
|
14
|
+
Resources and pipelines are loaded only when requested.
|
|
15
|
+
This class implements the Singleton pattern.
|
|
16
|
+
"""
|
|
17
|
+
_instance = None
|
|
18
|
+
_initialized = False
|
|
19
|
+
|
|
20
|
+
def __new__(cls, *args, **kwargs):
|
|
21
|
+
if cls._instance is None:
|
|
22
|
+
cls._instance = super(Configuration, cls).__new__(cls)
|
|
23
|
+
return cls._instance
|
|
24
|
+
|
|
25
|
+
def __init__(self, config_dir: str = "./config"):
|
|
26
|
+
if not self._initialized:
|
|
27
|
+
super().__init__()
|
|
28
|
+
self.config_dir = Path(config_dir)
|
|
29
|
+
self.resources : Dict[str, ResourceConfig] = {}
|
|
30
|
+
self.pipelines : Dict[str, Union[BasePipelineDefinition, ProcedureDefinition, OrchestratorDefinition]] = {}
|
|
31
|
+
|
|
32
|
+
self.logger.debug(f"Initializing configuration from directory: {config_dir}")
|
|
33
|
+
self._initialized = True
|
|
34
|
+
|
|
35
|
+
def _load_env_file(self, env_file: Path) -> ResourceConfig:
|
|
36
|
+
"""Load environment variables from a specific file"""
|
|
37
|
+
self.logger.debug(f"Loading environment file: {env_file}")
|
|
38
|
+
|
|
39
|
+
env_file_name = env_file.stem
|
|
40
|
+
|
|
41
|
+
if env_file_name.startswith('database_'):
|
|
42
|
+
return ConnectionSettings(_env_file=[env_file]) # type: ignore
|
|
43
|
+
if env_file_name.startswith('file_'):
|
|
44
|
+
return FileSettings(_env_file=[env_file]) # type: ignore
|
|
45
|
+
|
|
46
|
+
self.log_and_raise(ValueError,
|
|
47
|
+
f"Failed to load env file: {env_file.name}. "
|
|
48
|
+
f"Resource files must start with 'database_' or 'file_' prefix."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def _import_module(self, module_file: Path) -> ModuleType:
|
|
52
|
+
"""Dynamically import a Python module from a file path"""
|
|
53
|
+
|
|
54
|
+
self.logger.debug(f"Importing configuration from {module_file}")
|
|
55
|
+
|
|
56
|
+
spec = importlib.util.spec_from_file_location(module_file.stem, module_file)
|
|
57
|
+
|
|
58
|
+
if spec is None:
|
|
59
|
+
self.log_and_raise(
|
|
60
|
+
ImportError,
|
|
61
|
+
f"Could not create module spec for {module_file}",
|
|
62
|
+
file_path=str(module_file),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if spec.loader is not None:
|
|
66
|
+
module = importlib.util.module_from_spec(spec)
|
|
67
|
+
|
|
68
|
+
spec.loader.exec_module(module)
|
|
69
|
+
self.logger.debug(
|
|
70
|
+
f"Succesfully imported configuration from {str(module_file)}"
|
|
71
|
+
)
|
|
72
|
+
return module
|
|
73
|
+
else:
|
|
74
|
+
self.log_and_raise(
|
|
75
|
+
ImportError,
|
|
76
|
+
f"Module spec has no loader {module_file}",
|
|
77
|
+
file_path=str(module_file),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def get_resource(self, resource_name: str) -> ResourceConfig:
|
|
81
|
+
"""
|
|
82
|
+
Retrieve a connection by name.
|
|
83
|
+
Uses lazy loading: checks memory first, then attempts to load from file.
|
|
84
|
+
"""
|
|
85
|
+
self.logger.debug(f"Retrieving connection by name: {resource_name}")
|
|
86
|
+
|
|
87
|
+
# 1. Check if already loaded
|
|
88
|
+
if resource_name in self.resources:
|
|
89
|
+
return self.resources[resource_name]
|
|
90
|
+
|
|
91
|
+
# 2. Try to load from file
|
|
92
|
+
resource_file = self.config_dir / "resources" / f"{resource_name}.env"
|
|
93
|
+
if resource_file.exists():
|
|
94
|
+
try:
|
|
95
|
+
resource = self._load_env_file(resource_file)
|
|
96
|
+
self.resources[resource_name] = resource
|
|
97
|
+
self.logger.info(f"Lazily loaded resource: {resource_name}")
|
|
98
|
+
return resource
|
|
99
|
+
except Exception as e:
|
|
100
|
+
self.log_exception(e, f"Failed to load resource: {resource_name}")
|
|
101
|
+
raise
|
|
102
|
+
|
|
103
|
+
# 3. Not found
|
|
104
|
+
self.log_and_raise(ValueError,
|
|
105
|
+
f"Resource not found: {resource_name}. "
|
|
106
|
+
f"Checked path: {resource_file}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def get_pipeline(self, pipeline_name: str) -> Union[BasePipelineDefinition, ProcedureDefinition, OrchestratorDefinition]:
|
|
110
|
+
"""
|
|
111
|
+
Retrieve a pipeline definition by name.
|
|
112
|
+
Uses lazy loading: checks memory first, then attempts to load from file.
|
|
113
|
+
"""
|
|
114
|
+
self.logger.debug(f"Retriving pipeline definition by name: {pipeline_name}")
|
|
115
|
+
|
|
116
|
+
# 1. Check if already loaded
|
|
117
|
+
if pipeline_name in self.pipelines:
|
|
118
|
+
return self.pipelines[pipeline_name]
|
|
119
|
+
|
|
120
|
+
# 2. Try to load from file
|
|
121
|
+
pipeline_file = self.config_dir / "pipelines" / f"{pipeline_name}.py"
|
|
122
|
+
if pipeline_file.exists():
|
|
123
|
+
try:
|
|
124
|
+
config_module = self._import_module(pipeline_file)
|
|
125
|
+
# Find the definition in the module
|
|
126
|
+
for attr_name in dir(config_module):
|
|
127
|
+
attr = getattr(config_module, attr_name)
|
|
128
|
+
if isinstance(attr, (BasePipelineDefinition, ProcedureDefinition, OrchestratorDefinition)):
|
|
129
|
+
self.pipelines[pipeline_name] = attr
|
|
130
|
+
self.logger.info(f"Lazily loaded pipeline: {pipeline_name}")
|
|
131
|
+
return attr
|
|
132
|
+
except Exception as e:
|
|
133
|
+
self.log_exception(e, f"Failed to load pipeline: {pipeline_name}")
|
|
134
|
+
raise
|
|
135
|
+
|
|
136
|
+
# 3. Not found
|
|
137
|
+
self.log_and_raise(ValueError,
|
|
138
|
+
f"Pipeline not found: {pipeline_name}. "
|
|
139
|
+
f"Checked path: {pipeline_file}")
|
|
140
|
+
|
|
141
|
+
def get_all_resources(self) -> dict[str, ResourceConfig]:
|
|
142
|
+
"""
|
|
143
|
+
Retrieve all connections.
|
|
144
|
+
Scans the resources directory if not all loaded.
|
|
145
|
+
"""
|
|
146
|
+
resources_dir = self.config_dir / "resources"
|
|
147
|
+
if not resources_dir.exists():
|
|
148
|
+
return self.resources
|
|
149
|
+
|
|
150
|
+
# Discover all .env files
|
|
151
|
+
for env_file in resources_dir.glob("*.env"):
|
|
152
|
+
# Simple logging to debug discovery
|
|
153
|
+
self.logger.debug(f"Found potential resource file: {env_file.name}")
|
|
154
|
+
|
|
155
|
+
resource_name = env_file.stem
|
|
156
|
+
if resource_name not in self.resources:
|
|
157
|
+
try:
|
|
158
|
+
self.resources[resource_name] = self._load_env_file(env_file)
|
|
159
|
+
except Exception as e:
|
|
160
|
+
self.logger.warning(f"Failed to load resource {resource_name}: {e}")
|
|
161
|
+
|
|
162
|
+
return self.resources
|
|
163
|
+
|
|
164
|
+
def get_all_pipelines(self) -> dict[str, BasePipelineDefinition]:
|
|
165
|
+
"""
|
|
166
|
+
Retrieve all pipelines.
|
|
167
|
+
Scans the pipelines directory if not all loaded.
|
|
168
|
+
"""
|
|
169
|
+
pipelines_dir = self.config_dir / "pipelines"
|
|
170
|
+
if not pipelines_dir.exists():
|
|
171
|
+
return self.pipelines
|
|
172
|
+
|
|
173
|
+
for pipeline_file in pipelines_dir.glob("*.py"):
|
|
174
|
+
self.logger.debug(f"Found potential pipeline file: {pipeline_file.name}")
|
|
175
|
+
|
|
176
|
+
pipeline_name = pipeline_file.stem
|
|
177
|
+
if pipeline_name not in self.pipelines:
|
|
178
|
+
# Helper to trigger lazy load
|
|
179
|
+
try:
|
|
180
|
+
self.get_pipeline(pipeline_name)
|
|
181
|
+
except Exception as e:
|
|
182
|
+
self.logger.warning(f"Failed to load pipeline {pipeline_name}: {e}")
|
|
183
|
+
|
|
184
|
+
return self.pipelines
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class DriverNotFoundException(Exception):
|
|
2
|
+
def __init__(self, message: str = "ODBC driver not available"):
|
|
3
|
+
self.message = message
|
|
4
|
+
super().__init__(self.message)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class EngineTestException(Exception):
|
|
8
|
+
def __init__(self, message: str = "Engine has not passed connection test"):
|
|
9
|
+
self.message = message
|
|
10
|
+
super().__init__(self.message)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DatabaseOperationException(Exception):
|
|
14
|
+
def __init__(self, operation: str, message: str):
|
|
15
|
+
self.message = message
|
|
16
|
+
super().__init__(self.message)
|
|
17
|
+
|
|
18
|
+
class InvalidFileException(Exception):
|
|
19
|
+
def __init__(self, message: str = "The provided file is invalid or corrupted"):
|
|
20
|
+
self.message = message
|
|
21
|
+
super().__init__(self.message)
|