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 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
@@ -0,0 +1,3 @@
1
+ """Nexolith public package."""
2
+
3
+ __version__ = "0.2.0"
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.
@@ -0,0 +1,3 @@
1
+ from nexolith.cli.app import app
2
+
3
+ __all__ = ["app"]
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,4 @@
1
+ from nexolith.config.loader import load_pipeline
2
+ from nexolith.config.models import PipelineConfig
3
+
4
+ __all__ = ["PipelineConfig", "load_pipeline"]
@@ -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,3 @@
1
+ from nexolith.connectors.registry import ConnectorRegistry, default_connector_registry
2
+
3
+ __all__ = ["ConnectorRegistry", "default_connector_registry"]
@@ -0,0 +1,11 @@
1
+ from typing import Protocol
2
+
3
+ from nexolith.types import Rows
4
+
5
+
6
+ class SourceConnector(Protocol):
7
+ def read(self) -> Rows: ...
8
+
9
+
10
+ class DestinationConnector(Protocol):
11
+ def write(self, rows: Rows) -> int: ...
@@ -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