nexolith 0.2.0__py3-none-any.whl
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.
- nexolith/README.md +10 -0
- nexolith/__init__.py +3 -0
- nexolith/cli/README.md +16 -0
- nexolith/cli/__init__.py +3 -0
- nexolith/cli/app.py +105 -0
- nexolith/cli/diagnostics.py +125 -0
- nexolith/config/README.md +9 -0
- nexolith/config/__init__.py +4 -0
- nexolith/config/loader.py +67 -0
- nexolith/config/models.py +97 -0
- nexolith/connectors/README.md +9 -0
- nexolith/connectors/__init__.py +3 -0
- nexolith/connectors/base.py +11 -0
- nexolith/connectors/csv.py +42 -0
- nexolith/connectors/registry.py +69 -0
- nexolith/connectors/sql.py +90 -0
- nexolith/exceptions/README.md +9 -0
- nexolith/exceptions/__init__.py +25 -0
- nexolith/execution/README.md +11 -0
- nexolith/execution/__init__.py +3 -0
- nexolith/execution/runner.py +48 -0
- nexolith/models/README.md +6 -0
- nexolith/models/__init__.py +3 -0
- nexolith/models/execution.py +43 -0
- nexolith/release_validation.py +118 -0
- nexolith/transformations/README.md +7 -0
- nexolith/transformations/__init__.py +6 -0
- nexolith/transformations/base.py +7 -0
- nexolith/transformations/builtin.py +87 -0
- nexolith/transformations/registry.py +54 -0
- nexolith/types.py +5 -0
- nexolith-0.2.0.dist-info/METADATA +302 -0
- nexolith-0.2.0.dist-info/RECORD +36 -0
- nexolith-0.2.0.dist-info/WHEEL +4 -0
- nexolith-0.2.0.dist-info/entry_points.txt +2 -0
- nexolith-0.2.0.dist-info/licenses/LICENSE +201 -0
nexolith/README.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Nexolith package
|
|
2
|
+
|
|
3
|
+
This directory contains the application package. Configuration, connectors, transformations,
|
|
4
|
+
execution state, CLI presentation, and domain exceptions remain separated by subpackage.
|
|
5
|
+
|
|
6
|
+
New pipeline capabilities should depend on the shared row types and domain interfaces rather than
|
|
7
|
+
CLI code. See the [project README](../../README.md) for installation and usage.
|
|
8
|
+
|
|
9
|
+
`release_validation.py` is maintainer tooling used by the tagged-release workflow to enforce the
|
|
10
|
+
version, changelog, and distribution contract before publishing.
|
nexolith/__init__.py
ADDED
nexolith/cli/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# CLI
|
|
2
|
+
|
|
3
|
+
The Typer application exposes `nexolith validate`, `nexolith run`, `nexolith diagnostics`, and
|
|
4
|
+
`nexolith --version`.
|
|
5
|
+
Presentation belongs here; pipeline execution and I/O belong in their dedicated packages.
|
|
6
|
+
|
|
7
|
+
`diagnostics` prints deterministic, issue-friendly environment information. It reports Nexolith,
|
|
8
|
+
Python, platform, dependency, and optional-feature versions without inspecting environment
|
|
9
|
+
variables or reporting usernames, hostnames, filesystem paths, or connection details.
|
|
10
|
+
|
|
11
|
+
Expected errors render as `Error [category]: message` without a traceback. Configuration failures
|
|
12
|
+
exit with code `2`; execution, connector, and transformation failures exit with code `3`.
|
|
13
|
+
Unexpected programming errors are allowed to propagate for debugging.
|
|
14
|
+
|
|
15
|
+
Add CLI tests under `tests/unit/` whenever diagnostics or exit codes change. Tests must use
|
|
16
|
+
recognizable sentinel secrets and generic assertions that never echo those values on failure.
|
nexolith/cli/__init__.py
ADDED
nexolith/cli/app.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from enum import IntEnum
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from nexolith import __version__
|
|
9
|
+
from nexolith.cli.diagnostics import (
|
|
10
|
+
collect_environment_diagnostics,
|
|
11
|
+
render_environment_diagnostics,
|
|
12
|
+
)
|
|
13
|
+
from nexolith.config import load_pipeline
|
|
14
|
+
from nexolith.exceptions import (
|
|
15
|
+
ConfigurationError,
|
|
16
|
+
ConnectorError,
|
|
17
|
+
ExecutionError,
|
|
18
|
+
NexolithError,
|
|
19
|
+
TransformationError,
|
|
20
|
+
)
|
|
21
|
+
from nexolith.execution import DefaultPipelineRunner
|
|
22
|
+
from nexolith.models import ExecutionResult
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(help="Build data flows that last.", no_args_is_help=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ExitCode(IntEnum):
|
|
28
|
+
CONFIGURATION_ERROR = 2
|
|
29
|
+
EXECUTION_ERROR = 3
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _error_category(error: NexolithError) -> str:
|
|
33
|
+
cause = error.__cause__ if isinstance(error, ExecutionError) else error
|
|
34
|
+
if isinstance(cause, ConfigurationError):
|
|
35
|
+
return "configuration"
|
|
36
|
+
if isinstance(cause, ConnectorError):
|
|
37
|
+
return "connector"
|
|
38
|
+
if isinstance(cause, TransformationError):
|
|
39
|
+
return "transformation"
|
|
40
|
+
return "execution"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _show_error(error: NexolithError) -> None:
|
|
44
|
+
typer.echo(f"Error [{_error_category(error)}]: {error}", err=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def version_callback(value: bool) -> None:
|
|
48
|
+
if value:
|
|
49
|
+
typer.echo(f"Nexolith {__version__}")
|
|
50
|
+
raise typer.Exit()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.callback()
|
|
54
|
+
def main(
|
|
55
|
+
version: bool = typer.Option(
|
|
56
|
+
False, "--version", callback=version_callback, is_eager=True, help="Show version."
|
|
57
|
+
),
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Nexolith pipeline CLI."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _show_result(result: ExecutionResult) -> None:
|
|
63
|
+
duration = result.duration_seconds or 0.0
|
|
64
|
+
typer.echo(f"Pipeline: {result.pipeline_name}")
|
|
65
|
+
typer.echo(f"Status: {result.status.value}")
|
|
66
|
+
typer.echo(f"Rows read: {result.rows_read}")
|
|
67
|
+
typer.echo(f"Rows written: {result.rows_written}")
|
|
68
|
+
typer.echo(f"Duration: {duration:.3f}s")
|
|
69
|
+
if result.error:
|
|
70
|
+
typer.echo(f"Error: {result.error}", err=True)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.command()
|
|
74
|
+
def diagnostics() -> None:
|
|
75
|
+
"""Print secret-safe environment details for troubleshooting."""
|
|
76
|
+
report = collect_environment_diagnostics()
|
|
77
|
+
typer.echo(render_environment_diagnostics(report))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.command()
|
|
81
|
+
def validate(path: Annotated[Path, typer.Argument(exists=False, readable=True)]) -> None:
|
|
82
|
+
"""Validate a pipeline YAML file without running it."""
|
|
83
|
+
try:
|
|
84
|
+
config = load_pipeline(path)
|
|
85
|
+
except ConfigurationError as exc:
|
|
86
|
+
_show_error(exc)
|
|
87
|
+
raise typer.Exit(code=ExitCode.CONFIGURATION_ERROR) from exc
|
|
88
|
+
typer.echo(f"Pipeline '{config.name}' is valid.")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.command()
|
|
92
|
+
def run(path: Annotated[Path, typer.Argument(exists=False, readable=True)]) -> None:
|
|
93
|
+
"""Run a pipeline YAML file."""
|
|
94
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
|
95
|
+
try:
|
|
96
|
+
config = load_pipeline(path)
|
|
97
|
+
except ConfigurationError as exc:
|
|
98
|
+
_show_error(exc)
|
|
99
|
+
raise typer.Exit(code=ExitCode.CONFIGURATION_ERROR) from exc
|
|
100
|
+
try:
|
|
101
|
+
result = DefaultPipelineRunner().run(config, raise_on_error=True)
|
|
102
|
+
except ExecutionError as exc:
|
|
103
|
+
_show_error(exc)
|
|
104
|
+
raise typer.Exit(code=ExitCode.EXECUTION_ERROR) from exc
|
|
105
|
+
_show_result(result)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Collect and render shareable, secret-safe environment diagnostics."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import platform
|
|
5
|
+
import sqlite3
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from importlib import metadata
|
|
8
|
+
|
|
9
|
+
from nexolith import __version__
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class DiagnosticItem:
|
|
14
|
+
"""A named diagnostic value rendered in a stable order."""
|
|
15
|
+
|
|
16
|
+
name: str
|
|
17
|
+
value: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class EnvironmentDiagnostics:
|
|
22
|
+
"""Environment details that are safe to include in a public issue."""
|
|
23
|
+
|
|
24
|
+
nexolith_version: str
|
|
25
|
+
python_version: str
|
|
26
|
+
python_implementation: str
|
|
27
|
+
operating_system: str
|
|
28
|
+
operating_system_release: str
|
|
29
|
+
machine: str
|
|
30
|
+
installation: str
|
|
31
|
+
dependencies: tuple[DiagnosticItem, ...]
|
|
32
|
+
features: tuple[DiagnosticItem, ...]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_CORE_DISTRIBUTIONS = (
|
|
36
|
+
("Pydantic", "pydantic"),
|
|
37
|
+
("PyYAML", "PyYAML"),
|
|
38
|
+
("SQLAlchemy", "SQLAlchemy"),
|
|
39
|
+
("Typer", "typer"),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _known(value: str) -> str:
|
|
44
|
+
return value or "unknown"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _distribution_status(distribution_name: str) -> str:
|
|
48
|
+
try:
|
|
49
|
+
installed_version = metadata.version(distribution_name)
|
|
50
|
+
except metadata.PackageNotFoundError:
|
|
51
|
+
return "not installed"
|
|
52
|
+
return f"available ({installed_version})"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _installation_status() -> str:
|
|
56
|
+
try:
|
|
57
|
+
distribution = metadata.distribution("nexolith")
|
|
58
|
+
except metadata.PackageNotFoundError:
|
|
59
|
+
return "package metadata unavailable"
|
|
60
|
+
direct_url = distribution.read_text("direct_url.json")
|
|
61
|
+
if direct_url is None:
|
|
62
|
+
return "installed package"
|
|
63
|
+
try:
|
|
64
|
+
direct_url_data = json.loads(direct_url)
|
|
65
|
+
except (json.JSONDecodeError, TypeError):
|
|
66
|
+
return "installed package"
|
|
67
|
+
if not isinstance(direct_url_data, dict):
|
|
68
|
+
return "installed package"
|
|
69
|
+
directory_info = direct_url_data.get("dir_info")
|
|
70
|
+
if isinstance(directory_info, dict) and directory_info.get("editable") is True:
|
|
71
|
+
return "editable package"
|
|
72
|
+
return "installed package"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def collect_environment_diagnostics() -> EnvironmentDiagnostics:
|
|
76
|
+
"""Collect bounded diagnostics without reading environment values or reporting local paths."""
|
|
77
|
+
|
|
78
|
+
dependencies = tuple(
|
|
79
|
+
DiagnosticItem(label, _distribution_status(distribution_name))
|
|
80
|
+
for label, distribution_name in _CORE_DISTRIBUTIONS
|
|
81
|
+
)
|
|
82
|
+
features = (
|
|
83
|
+
DiagnosticItem("SQLite", f"available (SQLite {sqlite3.sqlite_version})"),
|
|
84
|
+
DiagnosticItem("PostgreSQL", _distribution_status("psycopg")),
|
|
85
|
+
)
|
|
86
|
+
return EnvironmentDiagnostics(
|
|
87
|
+
nexolith_version=__version__,
|
|
88
|
+
python_version=_known(platform.python_version()),
|
|
89
|
+
python_implementation=_known(platform.python_implementation()),
|
|
90
|
+
operating_system=_known(platform.system()),
|
|
91
|
+
operating_system_release=_known(platform.release()),
|
|
92
|
+
machine=_known(platform.machine()),
|
|
93
|
+
installation=_installation_status(),
|
|
94
|
+
dependencies=dependencies,
|
|
95
|
+
features=features,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def render_environment_diagnostics(report: EnvironmentDiagnostics) -> str:
|
|
100
|
+
"""Render diagnostics as deterministic plain text suitable for an issue report."""
|
|
101
|
+
|
|
102
|
+
lines = [
|
|
103
|
+
"Nexolith environment diagnostics",
|
|
104
|
+
"================================",
|
|
105
|
+
f"Nexolith: {report.nexolith_version}",
|
|
106
|
+
f"Python: {report.python_version} ({report.python_implementation})",
|
|
107
|
+
(
|
|
108
|
+
"Platform: "
|
|
109
|
+
f"{report.operating_system} {report.operating_system_release} ({report.machine})"
|
|
110
|
+
),
|
|
111
|
+
f"Installation: {report.installation}",
|
|
112
|
+
"",
|
|
113
|
+
"Core dependencies:",
|
|
114
|
+
]
|
|
115
|
+
lines.extend(f" {item.name}: {item.value}" for item in report.dependencies)
|
|
116
|
+
lines.extend(("", "Optional features:"))
|
|
117
|
+
lines.extend(f" {item.name}: {item.value}" for item in report.features)
|
|
118
|
+
lines.extend(
|
|
119
|
+
(
|
|
120
|
+
"",
|
|
121
|
+
"Privacy: environment variables, usernames, hostnames, and filesystem paths "
|
|
122
|
+
"are not reported.",
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Configuration
|
|
2
|
+
|
|
3
|
+
This package defines Pydantic pipeline models and loads YAML with `${VARIABLE_NAME}` substitution.
|
|
4
|
+
Unknown fields and missing environment variables must produce concise errors without exposing
|
|
5
|
+
environment contents.
|
|
6
|
+
|
|
7
|
+
Configuration failures raise `ConfigurationError`; parser, validation, and file I/O causes are
|
|
8
|
+
chained internally while public messages omit raw input. Add a discriminated model when
|
|
9
|
+
introducing a new built-in component type.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
from pydantic import ValidationError
|
|
8
|
+
|
|
9
|
+
from nexolith.config.models import PipelineConfig
|
|
10
|
+
from nexolith.exceptions import ConfigurationError
|
|
11
|
+
|
|
12
|
+
ENV_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _resolve_string(value: str) -> str:
|
|
16
|
+
missing: set[str] = set()
|
|
17
|
+
|
|
18
|
+
def replace(match: re.Match[str]) -> str:
|
|
19
|
+
name = match.group(1)
|
|
20
|
+
resolved = os.getenv(name)
|
|
21
|
+
if resolved is None:
|
|
22
|
+
missing.add(name)
|
|
23
|
+
return match.group(0)
|
|
24
|
+
return resolved
|
|
25
|
+
|
|
26
|
+
result = ENV_PATTERN.sub(replace, value)
|
|
27
|
+
if missing:
|
|
28
|
+
names = ", ".join(sorted(missing))
|
|
29
|
+
raise ConfigurationError(f"Missing environment variable(s): {names}")
|
|
30
|
+
return result
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _resolve_environment(value: Any) -> Any:
|
|
34
|
+
if isinstance(value, str):
|
|
35
|
+
return _resolve_string(value)
|
|
36
|
+
if isinstance(value, list):
|
|
37
|
+
return [_resolve_environment(item) for item in value]
|
|
38
|
+
if isinstance(value, dict):
|
|
39
|
+
return {key: _resolve_environment(item) for key, item in value.items()}
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_pipeline(path: Path) -> PipelineConfig:
|
|
44
|
+
if not path.is_file():
|
|
45
|
+
raise ConfigurationError(f"Pipeline file not found: {path}. Check the path and try again.")
|
|
46
|
+
try:
|
|
47
|
+
content = path.read_text(encoding="utf-8")
|
|
48
|
+
except OSError as exc:
|
|
49
|
+
raise ConfigurationError(
|
|
50
|
+
f"Could not read pipeline file: {path}. Check file permissions."
|
|
51
|
+
) from exc
|
|
52
|
+
try:
|
|
53
|
+
document = yaml.safe_load(content)
|
|
54
|
+
except yaml.YAMLError as exc:
|
|
55
|
+
mark = getattr(exc, "problem_mark", None)
|
|
56
|
+
location = f" at line {mark.line + 1}, column {mark.column + 1}" if mark else ""
|
|
57
|
+
raise ConfigurationError(f"Invalid YAML in {path}{location}.") from exc
|
|
58
|
+
if not isinstance(document, dict):
|
|
59
|
+
raise ConfigurationError("Pipeline YAML must contain a mapping at its root")
|
|
60
|
+
try:
|
|
61
|
+
return PipelineConfig.validate_document(_resolve_environment(document))
|
|
62
|
+
except ValidationError as exc:
|
|
63
|
+
errors = []
|
|
64
|
+
for error in exc.errors(include_url=False):
|
|
65
|
+
location = ".".join(str(part) for part in error["loc"])
|
|
66
|
+
errors.append(f"{location}: {error['msg']}")
|
|
67
|
+
raise ConfigurationError("Invalid pipeline configuration:\n" + "\n".join(errors)) from exc
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from typing import Any, Literal
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
4
|
+
|
|
5
|
+
from nexolith.types import Scalar
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ComponentConfig(BaseModel):
|
|
9
|
+
model_config = ConfigDict(extra="forbid")
|
|
10
|
+
type: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CsvSourceConfig(ComponentConfig):
|
|
14
|
+
type: Literal["csv"]
|
|
15
|
+
path: str
|
|
16
|
+
encoding: str = "utf-8"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SqlSourceConfig(ComponentConfig):
|
|
20
|
+
type: Literal["sqlite", "postgresql"]
|
|
21
|
+
connection_url: str
|
|
22
|
+
query: str | None = None
|
|
23
|
+
table: str | None = None
|
|
24
|
+
|
|
25
|
+
@model_validator(mode="after")
|
|
26
|
+
def require_query_or_table(self) -> "SqlSourceConfig":
|
|
27
|
+
if not self.query and not self.table:
|
|
28
|
+
raise ValueError("either 'query' or 'table' is required")
|
|
29
|
+
return self
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CsvDestinationConfig(ComponentConfig):
|
|
33
|
+
type: Literal["csv"]
|
|
34
|
+
path: str
|
|
35
|
+
encoding: str = "utf-8"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SqlDestinationConfig(ComponentConfig):
|
|
39
|
+
type: Literal["sqlite", "postgresql"]
|
|
40
|
+
connection_url: str
|
|
41
|
+
table: str
|
|
42
|
+
mode: Literal["append", "replace", "fail"] = "fail"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SelectConfig(ComponentConfig):
|
|
46
|
+
type: Literal["select"]
|
|
47
|
+
columns: list[str] = Field(min_length=1)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RenameConfig(ComponentConfig):
|
|
51
|
+
type: Literal["rename"]
|
|
52
|
+
columns: dict[str, str] = Field(min_length=1)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class DropNullsConfig(ComponentConfig):
|
|
56
|
+
type: Literal["drop_nulls"]
|
|
57
|
+
columns: list[str] | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class FilterConfig(ComponentConfig):
|
|
61
|
+
type: Literal["filter"]
|
|
62
|
+
column: str
|
|
63
|
+
operator: Literal[
|
|
64
|
+
"equals",
|
|
65
|
+
"not_equals",
|
|
66
|
+
"greater_than",
|
|
67
|
+
"greater_than_or_equal",
|
|
68
|
+
"less_than",
|
|
69
|
+
"less_than_or_equal",
|
|
70
|
+
"contains",
|
|
71
|
+
"is_null",
|
|
72
|
+
"is_not_null",
|
|
73
|
+
]
|
|
74
|
+
value: Scalar = None
|
|
75
|
+
|
|
76
|
+
@model_validator(mode="after")
|
|
77
|
+
def require_value(self) -> "FilterConfig":
|
|
78
|
+
if self.operator not in {"is_null", "is_not_null"} and self.value is None:
|
|
79
|
+
raise ValueError(f"'value' is required for operator '{self.operator}'")
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
SourceConfig = CsvSourceConfig | SqlSourceConfig
|
|
84
|
+
DestinationConfig = CsvDestinationConfig | SqlDestinationConfig
|
|
85
|
+
TransformationConfig = SelectConfig | RenameConfig | DropNullsConfig | FilterConfig
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class PipelineConfig(BaseModel):
|
|
89
|
+
model_config = ConfigDict(extra="forbid")
|
|
90
|
+
name: str = Field(min_length=1)
|
|
91
|
+
source: SourceConfig = Field(discriminator="type")
|
|
92
|
+
transformations: list[TransformationConfig] = Field(default_factory=list)
|
|
93
|
+
destination: DestinationConfig = Field(discriminator="type")
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def validate_document(cls, document: Any) -> "PipelineConfig":
|
|
97
|
+
return cls.model_validate(document)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Connectors
|
|
2
|
+
|
|
3
|
+
Connectors translate external data to and from Nexolith's `Rows` representation. CSV and SQL
|
|
4
|
+
implementations are constructed through the connector registry.
|
|
5
|
+
|
|
6
|
+
New connectors should implement the source or destination protocol, wrap expected I/O or driver
|
|
7
|
+
failures in `ConnectorError` with exception chaining, and close resources deterministically.
|
|
8
|
+
Public errors and logs must never include usernames, credentials, complete connection URLs, or raw
|
|
9
|
+
driver diagnostics. Real PostgreSQL coverage lives in `tests/integration/test_postgresql.py`.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from nexolith.exceptions import ConnectorError
|
|
5
|
+
from nexolith.types import Rows
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CsvSource:
|
|
9
|
+
def __init__(self, path: str, encoding: str = "utf-8") -> None:
|
|
10
|
+
self.path = Path(path)
|
|
11
|
+
self.encoding = encoding
|
|
12
|
+
|
|
13
|
+
def read(self) -> Rows:
|
|
14
|
+
try:
|
|
15
|
+
with self.path.open(newline="", encoding=self.encoding) as handle:
|
|
16
|
+
return [dict(row) for row in csv.DictReader(handle)]
|
|
17
|
+
except (OSError, csv.Error) as exc:
|
|
18
|
+
raise ConnectorError(
|
|
19
|
+
f"Could not read CSV file '{self.path}'. Check the path and permissions."
|
|
20
|
+
) from exc
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CsvDestination:
|
|
24
|
+
def __init__(self, path: str, encoding: str = "utf-8") -> None:
|
|
25
|
+
self.path = Path(path)
|
|
26
|
+
self.encoding = encoding
|
|
27
|
+
|
|
28
|
+
def write(self, rows: Rows) -> int:
|
|
29
|
+
if not rows:
|
|
30
|
+
raise ConnectorError("Cannot write an empty dataset to CSV")
|
|
31
|
+
try:
|
|
32
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
columns = list(rows[0])
|
|
34
|
+
with self.path.open("w", newline="", encoding=self.encoding) as handle:
|
|
35
|
+
writer = csv.DictWriter(handle, fieldnames=columns)
|
|
36
|
+
writer.writeheader()
|
|
37
|
+
writer.writerows(rows)
|
|
38
|
+
except (OSError, csv.Error) as exc:
|
|
39
|
+
raise ConnectorError(
|
|
40
|
+
f"Could not write CSV file '{self.path}'. Check the path and permissions."
|
|
41
|
+
) from exc
|
|
42
|
+
return len(rows)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
|
|
3
|
+
from nexolith.config.models import (
|
|
4
|
+
CsvDestinationConfig,
|
|
5
|
+
CsvSourceConfig,
|
|
6
|
+
DestinationConfig,
|
|
7
|
+
SourceConfig,
|
|
8
|
+
SqlDestinationConfig,
|
|
9
|
+
SqlSourceConfig,
|
|
10
|
+
)
|
|
11
|
+
from nexolith.connectors.base import DestinationConnector, SourceConnector
|
|
12
|
+
from nexolith.connectors.csv import CsvDestination, CsvSource
|
|
13
|
+
from nexolith.connectors.sql import SqlDestination, SqlSource
|
|
14
|
+
from nexolith.exceptions import ConnectorError
|
|
15
|
+
|
|
16
|
+
SourceFactory = Callable[[SourceConfig], SourceConnector]
|
|
17
|
+
DestinationFactory = Callable[[DestinationConfig], DestinationConnector]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConnectorRegistry:
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self._sources: dict[str, SourceFactory] = {}
|
|
23
|
+
self._destinations: dict[str, DestinationFactory] = {}
|
|
24
|
+
|
|
25
|
+
def register_source(self, name: str, factory: SourceFactory) -> None:
|
|
26
|
+
self._sources[name] = factory
|
|
27
|
+
|
|
28
|
+
def register_destination(self, name: str, factory: DestinationFactory) -> None:
|
|
29
|
+
self._destinations[name] = factory
|
|
30
|
+
|
|
31
|
+
def create_source(self, config: SourceConfig) -> SourceConnector:
|
|
32
|
+
try:
|
|
33
|
+
return self._sources[config.type](config)
|
|
34
|
+
except KeyError as exc:
|
|
35
|
+
raise ConnectorError(f"Unknown source connector: {config.type}") from exc
|
|
36
|
+
|
|
37
|
+
def create_destination(self, config: DestinationConfig) -> DestinationConnector:
|
|
38
|
+
try:
|
|
39
|
+
return self._destinations[config.type](config)
|
|
40
|
+
except KeyError as exc:
|
|
41
|
+
raise ConnectorError(f"Unknown destination connector: {config.type}") from exc
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def default_connector_registry() -> ConnectorRegistry:
|
|
45
|
+
registry = ConnectorRegistry()
|
|
46
|
+
|
|
47
|
+
def csv_source(config: SourceConfig) -> SourceConnector:
|
|
48
|
+
assert isinstance(config, CsvSourceConfig)
|
|
49
|
+
return CsvSource(config.path, config.encoding)
|
|
50
|
+
|
|
51
|
+
def sql_source(config: SourceConfig) -> SourceConnector:
|
|
52
|
+
assert isinstance(config, SqlSourceConfig)
|
|
53
|
+
return SqlSource(config.connection_url, config.query, config.table)
|
|
54
|
+
|
|
55
|
+
def csv_destination(config: DestinationConfig) -> DestinationConnector:
|
|
56
|
+
assert isinstance(config, CsvDestinationConfig)
|
|
57
|
+
return CsvDestination(config.path, config.encoding)
|
|
58
|
+
|
|
59
|
+
def sql_destination(config: DestinationConfig) -> DestinationConnector:
|
|
60
|
+
assert isinstance(config, SqlDestinationConfig)
|
|
61
|
+
return SqlDestination(config.connection_url, config.table, config.mode)
|
|
62
|
+
|
|
63
|
+
registry.register_source("csv", csv_source)
|
|
64
|
+
registry.register_source("sqlite", sql_source)
|
|
65
|
+
registry.register_source("postgresql", sql_source)
|
|
66
|
+
registry.register_destination("csv", csv_destination)
|
|
67
|
+
registry.register_destination("sqlite", sql_destination)
|
|
68
|
+
registry.register_destination("postgresql", sql_destination)
|
|
69
|
+
return registry
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import MetaData, Table, create_engine, inspect, text
|
|
4
|
+
from sqlalchemy.engine import Engine
|
|
5
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
6
|
+
|
|
7
|
+
from nexolith.exceptions import ConnectorError
|
|
8
|
+
from nexolith.types import Rows
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SqlSource:
|
|
12
|
+
def __init__(self, connection_url: str, query: str | None, table: str | None) -> None:
|
|
13
|
+
self.engine = _create_sql_engine(connection_url)
|
|
14
|
+
self.query = query
|
|
15
|
+
self.table = table
|
|
16
|
+
|
|
17
|
+
def read(self) -> Rows:
|
|
18
|
+
statement = self.query or f'SELECT * FROM "{self.table}"'
|
|
19
|
+
try:
|
|
20
|
+
with self.engine.connect() as connection:
|
|
21
|
+
return [dict(row) for row in connection.execute(text(statement)).mappings()]
|
|
22
|
+
except SQLAlchemyError as exc:
|
|
23
|
+
raise ConnectorError(
|
|
24
|
+
"Could not read from SQL source. Check the connection, table, or query."
|
|
25
|
+
) from exc
|
|
26
|
+
finally:
|
|
27
|
+
self.engine.dispose()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SqlDestination:
|
|
31
|
+
def __init__(self, connection_url: str, table: str, mode: str) -> None:
|
|
32
|
+
self.engine = _create_sql_engine(connection_url)
|
|
33
|
+
self.table = table
|
|
34
|
+
self.mode = mode
|
|
35
|
+
|
|
36
|
+
def _prepare_table(self, engine: Engine, rows: Rows) -> Table:
|
|
37
|
+
metadata = MetaData()
|
|
38
|
+
exists = inspect(engine).has_table(self.table)
|
|
39
|
+
if exists and self.mode == "fail":
|
|
40
|
+
raise ConnectorError(f"Destination table '{self.table}' already exists")
|
|
41
|
+
if exists and self.mode == "replace":
|
|
42
|
+
Table(self.table, metadata, autoload_with=engine).drop(engine)
|
|
43
|
+
metadata.clear()
|
|
44
|
+
exists = False
|
|
45
|
+
if not exists:
|
|
46
|
+
from sqlalchemy import Column, Float, Integer, String
|
|
47
|
+
|
|
48
|
+
def column_type(value: Any) -> Any:
|
|
49
|
+
if isinstance(value, bool):
|
|
50
|
+
return Integer()
|
|
51
|
+
if isinstance(value, int):
|
|
52
|
+
return Integer()
|
|
53
|
+
if isinstance(value, float):
|
|
54
|
+
return Float()
|
|
55
|
+
return String()
|
|
56
|
+
|
|
57
|
+
table = Table(
|
|
58
|
+
self.table,
|
|
59
|
+
metadata,
|
|
60
|
+
*(Column(name, column_type(value)) for name, value in rows[0].items()),
|
|
61
|
+
)
|
|
62
|
+
metadata.create_all(engine)
|
|
63
|
+
return table
|
|
64
|
+
return Table(self.table, metadata, autoload_with=engine)
|
|
65
|
+
|
|
66
|
+
def write(self, rows: Rows) -> int:
|
|
67
|
+
if not rows:
|
|
68
|
+
raise ConnectorError("Cannot write an empty dataset to SQL")
|
|
69
|
+
try:
|
|
70
|
+
table = self._prepare_table(self.engine, rows)
|
|
71
|
+
with self.engine.begin() as connection:
|
|
72
|
+
connection.execute(table.insert(), rows)
|
|
73
|
+
return len(rows)
|
|
74
|
+
except ConnectorError:
|
|
75
|
+
raise
|
|
76
|
+
except SQLAlchemyError as exc:
|
|
77
|
+
raise ConnectorError(
|
|
78
|
+
"Could not write to SQL destination. Check the connection, table, and write mode."
|
|
79
|
+
) from exc
|
|
80
|
+
finally:
|
|
81
|
+
self.engine.dispose()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _create_sql_engine(connection_url: str) -> Engine:
|
|
85
|
+
try:
|
|
86
|
+
return create_engine(connection_url)
|
|
87
|
+
except (ImportError, SQLAlchemyError) as exc:
|
|
88
|
+
raise ConnectorError(
|
|
89
|
+
"Could not configure SQL connector. Check the connection URL and database driver."
|
|
90
|
+
) from exc
|