sqlcsv-exporter 1.0.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.
- sqlcsv_exporter-1.0.0/LICENSE +21 -0
- sqlcsv_exporter-1.0.0/PKG-INFO +84 -0
- sqlcsv_exporter-1.0.0/README.md +59 -0
- sqlcsv_exporter-1.0.0/pyproject.toml +40 -0
- sqlcsv_exporter-1.0.0/src/sqlcsv_exporter/__init__.py +5 -0
- sqlcsv_exporter-1.0.0/src/sqlcsv_exporter/__main__.py +5 -0
- sqlcsv_exporter-1.0.0/src/sqlcsv_exporter/cli.py +118 -0
- sqlcsv_exporter-1.0.0/src/sqlcsv_exporter/config.py +69 -0
- sqlcsv_exporter-1.0.0/src/sqlcsv_exporter/connection.py +42 -0
- sqlcsv_exporter-1.0.0/src/sqlcsv_exporter/exporter.py +205 -0
- sqlcsv_exporter-1.0.0/src/sqlcsv_exporter/sql_rewriter.py +30 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jordi Corbilla
|
|
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,84 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: sqlcsv-exporter
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: CLI utility to stream SQL Server query results into CSV files.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: sql-server,csv,cli,export,pyodbc
|
|
7
|
+
Author: Your Team
|
|
8
|
+
Author-email: team@example.com
|
|
9
|
+
Requires-Python: >=3.10,<4.0
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Database
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Dist: pyodbc (>=5.0.1)
|
|
22
|
+
Requires-Dist: rich (>=13.7.0)
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# sqlcsv-exporter
|
|
26
|
+
|
|
27
|
+
`sqlcsv-exporter` is a publishable CLI package for streaming SQL Server query results into CSV files without loading the full result set into memory.
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- Reads SQL from a `.sql` file
|
|
32
|
+
- Rewrites a declared `@InAsOfDate` value when present
|
|
33
|
+
- Supports trusted or SQL authentication
|
|
34
|
+
- Streams rows with chunked `fetchmany()` and chunked CSV writes
|
|
35
|
+
- Shows live Rich progress while the export runs
|
|
36
|
+
- Ships with pytest coverage for core behavior
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
poetry install
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Run
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
poetry run sqlcsv-exporter \
|
|
48
|
+
--sql ./queries/report.sql \
|
|
49
|
+
--output ./exports/report.csv \
|
|
50
|
+
--server my-sql-server \
|
|
51
|
+
--database Reporting
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
With SQL authentication:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
poetry run sqlcsv-exporter \
|
|
58
|
+
--sql ./queries/report.sql \
|
|
59
|
+
--output ./exports/report.csv \
|
|
60
|
+
--server my-sql-server \
|
|
61
|
+
--database Reporting \
|
|
62
|
+
--sql-auth \
|
|
63
|
+
--username reporting_user \
|
|
64
|
+
--password secret
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Override the as-of date used in a declared `@InAsOfDate` variable:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
poetry run sqlcsv-exporter \
|
|
71
|
+
--sql ./queries/report.sql \
|
|
72
|
+
--output ./exports/report.csv \
|
|
73
|
+
--server my-sql-server \
|
|
74
|
+
--database Reporting \
|
|
75
|
+
--date 2026-03-26
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Publish
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
poetry build
|
|
82
|
+
poetry publish
|
|
83
|
+
```
|
|
84
|
+
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# sqlcsv-exporter
|
|
2
|
+
|
|
3
|
+
`sqlcsv-exporter` is a publishable CLI package for streaming SQL Server query results into CSV files without loading the full result set into memory.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Reads SQL from a `.sql` file
|
|
8
|
+
- Rewrites a declared `@InAsOfDate` value when present
|
|
9
|
+
- Supports trusted or SQL authentication
|
|
10
|
+
- Streams rows with chunked `fetchmany()` and chunked CSV writes
|
|
11
|
+
- Shows live Rich progress while the export runs
|
|
12
|
+
- Ships with pytest coverage for core behavior
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
poetry install
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Run
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
poetry run sqlcsv-exporter \
|
|
24
|
+
--sql ./queries/report.sql \
|
|
25
|
+
--output ./exports/report.csv \
|
|
26
|
+
--server my-sql-server \
|
|
27
|
+
--database Reporting
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
With SQL authentication:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
poetry run sqlcsv-exporter \
|
|
34
|
+
--sql ./queries/report.sql \
|
|
35
|
+
--output ./exports/report.csv \
|
|
36
|
+
--server my-sql-server \
|
|
37
|
+
--database Reporting \
|
|
38
|
+
--sql-auth \
|
|
39
|
+
--username reporting_user \
|
|
40
|
+
--password secret
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Override the as-of date used in a declared `@InAsOfDate` variable:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
poetry run sqlcsv-exporter \
|
|
47
|
+
--sql ./queries/report.sql \
|
|
48
|
+
--output ./exports/report.csv \
|
|
49
|
+
--server my-sql-server \
|
|
50
|
+
--database Reporting \
|
|
51
|
+
--date 2026-03-26
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Publish
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
poetry build
|
|
58
|
+
poetry publish
|
|
59
|
+
```
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["poetry-core>=1.9.0"]
|
|
3
|
+
build-backend = "poetry.core.masonry.api"
|
|
4
|
+
|
|
5
|
+
[tool.poetry]
|
|
6
|
+
name = "sqlcsv-exporter"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "CLI utility to stream SQL Server query results into CSV files."
|
|
9
|
+
authors = ["Your Team <team@example.com>"]
|
|
10
|
+
license = "MIT"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
packages = [{ include = "sqlcsv_exporter", from = "src" }]
|
|
13
|
+
keywords = ["sql-server", "csv", "cli", "export", "pyodbc"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Topic :: Database",
|
|
24
|
+
"Topic :: Utilities",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.poetry.dependencies]
|
|
28
|
+
python = ">=3.10,<4.0"
|
|
29
|
+
pyodbc = ">=5.0.1"
|
|
30
|
+
rich = ">=13.7.0"
|
|
31
|
+
|
|
32
|
+
[tool.poetry.group.dev.dependencies]
|
|
33
|
+
pytest = ">=8.0.0"
|
|
34
|
+
|
|
35
|
+
[tool.poetry.scripts]
|
|
36
|
+
sqlcsv-exporter = "sqlcsv_exporter.cli:main"
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Sequence
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from sqlcsv_exporter import __version__
|
|
10
|
+
from sqlcsv_exporter.config import (
|
|
11
|
+
DEFAULT_CHUNK_SIZE,
|
|
12
|
+
DEFAULT_DATE_PARAMETER,
|
|
13
|
+
DEFAULT_DRIVER,
|
|
14
|
+
DEFAULT_ENCODING,
|
|
15
|
+
DEFAULT_LOGIN_TIMEOUT_SECONDS,
|
|
16
|
+
DEFAULT_QUERY_TIMEOUT_SECONDS,
|
|
17
|
+
ConfigError,
|
|
18
|
+
ExportConfig,
|
|
19
|
+
resolve_as_of_date,
|
|
20
|
+
)
|
|
21
|
+
from sqlcsv_exporter.exporter import ExportError, execute_query_to_csv
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
25
|
+
parser = argparse.ArgumentParser(
|
|
26
|
+
prog="sqlcsv-exporter",
|
|
27
|
+
description="Execute a SQL Server query from a file and stream the result into CSV.",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument("--sql", required=True, help="Path to the .sql file to execute.")
|
|
30
|
+
parser.add_argument("--output", required=True, help="Destination CSV file path.")
|
|
31
|
+
parser.add_argument("--server", required=True, help="SQL Server hostname or instance.")
|
|
32
|
+
parser.add_argument("--database", required=True, help="Database name.")
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--date",
|
|
35
|
+
help="As-of date injected into a declared @InAsOfDate parameter. Defaults to yesterday.",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--date-parameter",
|
|
39
|
+
default=DEFAULT_DATE_PARAMETER,
|
|
40
|
+
help=f"Declared SQL variable to rewrite. Default: {DEFAULT_DATE_PARAMETER}.",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--chunk-size",
|
|
44
|
+
type=int,
|
|
45
|
+
default=DEFAULT_CHUNK_SIZE,
|
|
46
|
+
help=f"Rows to fetch and write per batch. Default: {DEFAULT_CHUNK_SIZE}.",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--delimiter",
|
|
50
|
+
default=",",
|
|
51
|
+
help="Single-character CSV delimiter. Default: ','.",
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--encoding",
|
|
55
|
+
default=DEFAULT_ENCODING,
|
|
56
|
+
help=f"Output file encoding. Default: {DEFAULT_ENCODING}.",
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument("--no-header", action="store_true", help="Do not write CSV column headers.")
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--driver",
|
|
61
|
+
default=DEFAULT_DRIVER,
|
|
62
|
+
help=f"ODBC driver name. Default: {DEFAULT_DRIVER}.",
|
|
63
|
+
)
|
|
64
|
+
parser.add_argument("--sql-auth", action="store_true", help="Use SQL authentication instead of trusted connection.")
|
|
65
|
+
parser.add_argument("--username", help="SQL username for --sql-auth.")
|
|
66
|
+
parser.add_argument("--password", help="SQL password for --sql-auth.")
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"--login-timeout",
|
|
69
|
+
type=int,
|
|
70
|
+
default=DEFAULT_LOGIN_TIMEOUT_SECONDS,
|
|
71
|
+
help=f"Connection timeout in seconds. Default: {DEFAULT_LOGIN_TIMEOUT_SECONDS}.",
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--query-timeout",
|
|
75
|
+
type=int,
|
|
76
|
+
default=DEFAULT_QUERY_TIMEOUT_SECONDS,
|
|
77
|
+
help=f"Query timeout in seconds. Default: {DEFAULT_QUERY_TIMEOUT_SECONDS}.",
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
80
|
+
return parser
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def build_config(args: argparse.Namespace) -> ExportConfig:
|
|
84
|
+
return ExportConfig(
|
|
85
|
+
sql_file=Path(args.sql),
|
|
86
|
+
output_csv=Path(args.output),
|
|
87
|
+
server=args.server,
|
|
88
|
+
database=args.database,
|
|
89
|
+
as_of_date=resolve_as_of_date(args.date),
|
|
90
|
+
chunk_size=args.chunk_size,
|
|
91
|
+
delimiter=args.delimiter,
|
|
92
|
+
encoding=args.encoding,
|
|
93
|
+
include_header=not args.no_header,
|
|
94
|
+
trusted_connection=not args.sql_auth,
|
|
95
|
+
username=args.username,
|
|
96
|
+
password=args.password,
|
|
97
|
+
driver=args.driver,
|
|
98
|
+
date_parameter_name=args.date_parameter,
|
|
99
|
+
login_timeout_seconds=args.login_timeout,
|
|
100
|
+
query_timeout_seconds=args.query_timeout,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
105
|
+
console = Console(stderr=True)
|
|
106
|
+
parser = build_parser()
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
args = parser.parse_args(argv)
|
|
110
|
+
config = build_config(args)
|
|
111
|
+
execute_query_to_csv(config, console=console)
|
|
112
|
+
return 0
|
|
113
|
+
except (ConfigError, ExportError, FileNotFoundError, RuntimeError) as exc:
|
|
114
|
+
console.print(f"[bold red]Error:[/bold red] {exc}")
|
|
115
|
+
return 1
|
|
116
|
+
except KeyboardInterrupt:
|
|
117
|
+
console.print("[bold red]Error:[/bold red] Export cancelled.")
|
|
118
|
+
return 130
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
DEFAULT_CHUNK_SIZE = 50_000
|
|
8
|
+
DEFAULT_DATE_PARAMETER = "@InAsOfDate"
|
|
9
|
+
DEFAULT_DRIVER = "ODBC Driver 17 for SQL Server"
|
|
10
|
+
DEFAULT_ENCODING = "utf-8-sig"
|
|
11
|
+
DEFAULT_LOGIN_TIMEOUT_SECONDS = 15
|
|
12
|
+
DEFAULT_QUERY_TIMEOUT_SECONDS = 600
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfigError(ValueError):
|
|
16
|
+
"""Raised when CLI options cannot be turned into a valid config."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_as_of_date(value: str | None, *, now: datetime | None = None) -> str:
|
|
20
|
+
if value:
|
|
21
|
+
try:
|
|
22
|
+
return datetime.strptime(value, "%Y-%m-%d").strftime("%Y-%m-%d")
|
|
23
|
+
except ValueError as exc:
|
|
24
|
+
raise ConfigError("Date must use YYYY-MM-DD format.") from exc
|
|
25
|
+
|
|
26
|
+
current_time = now or datetime.now()
|
|
27
|
+
return (current_time - timedelta(days=1)).strftime("%Y-%m-%d")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ExportConfig:
|
|
32
|
+
sql_file: Path
|
|
33
|
+
output_csv: Path
|
|
34
|
+
server: str
|
|
35
|
+
database: str
|
|
36
|
+
as_of_date: str
|
|
37
|
+
chunk_size: int = DEFAULT_CHUNK_SIZE
|
|
38
|
+
delimiter: str = ","
|
|
39
|
+
encoding: str = DEFAULT_ENCODING
|
|
40
|
+
include_header: bool = True
|
|
41
|
+
trusted_connection: bool = True
|
|
42
|
+
username: str | None = None
|
|
43
|
+
password: str | None = field(default=None, repr=False)
|
|
44
|
+
driver: str = DEFAULT_DRIVER
|
|
45
|
+
date_parameter_name: str = DEFAULT_DATE_PARAMETER
|
|
46
|
+
login_timeout_seconds: int = DEFAULT_LOGIN_TIMEOUT_SECONDS
|
|
47
|
+
query_timeout_seconds: int = DEFAULT_QUERY_TIMEOUT_SECONDS
|
|
48
|
+
|
|
49
|
+
def __post_init__(self) -> None:
|
|
50
|
+
if not self.server.strip():
|
|
51
|
+
raise ConfigError("Server is required.")
|
|
52
|
+
if not self.database.strip():
|
|
53
|
+
raise ConfigError("Database is required.")
|
|
54
|
+
if self.chunk_size <= 0:
|
|
55
|
+
raise ConfigError("Chunk size must be greater than zero.")
|
|
56
|
+
if len(self.delimiter) != 1:
|
|
57
|
+
raise ConfigError("Delimiter must be exactly one character.")
|
|
58
|
+
if self.login_timeout_seconds <= 0:
|
|
59
|
+
raise ConfigError("Login timeout must be greater than zero.")
|
|
60
|
+
if self.query_timeout_seconds <= 0:
|
|
61
|
+
raise ConfigError("Query timeout must be greater than zero.")
|
|
62
|
+
if not self.date_parameter_name.startswith("@"):
|
|
63
|
+
raise ConfigError("Date parameter name must start with '@'.")
|
|
64
|
+
|
|
65
|
+
if not self.trusted_connection:
|
|
66
|
+
if not self.username:
|
|
67
|
+
raise ConfigError("Username is required when SQL authentication is enabled.")
|
|
68
|
+
if not self.password:
|
|
69
|
+
raise ConfigError("Password is required when SQL authentication is enabled.")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from types import ModuleType
|
|
4
|
+
|
|
5
|
+
from sqlcsv_exporter.config import ExportConfig
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_pyodbc() -> ModuleType:
|
|
9
|
+
try:
|
|
10
|
+
import pyodbc # type: ignore
|
|
11
|
+
except ImportError as exc:
|
|
12
|
+
raise RuntimeError(
|
|
13
|
+
"pyodbc is required to connect to SQL Server. Install system ODBC drivers and the pyodbc package."
|
|
14
|
+
) from exc
|
|
15
|
+
|
|
16
|
+
return pyodbc
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_connection_string(config: ExportConfig) -> str:
|
|
20
|
+
driver_name = config.driver.strip().strip("{}")
|
|
21
|
+
parts = [
|
|
22
|
+
f"DRIVER={{{driver_name}}}",
|
|
23
|
+
f"SERVER={config.server}",
|
|
24
|
+
f"DATABASE={config.database}",
|
|
25
|
+
"APP=sqlcsv-exporter",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
if config.trusted_connection:
|
|
29
|
+
parts.append("Trusted_Connection=yes")
|
|
30
|
+
else:
|
|
31
|
+
parts.append(f"UID={config.username}")
|
|
32
|
+
parts.append(f"PWD={config.password}")
|
|
33
|
+
|
|
34
|
+
return ";".join(parts) + ";"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def open_connection(config: ExportConfig):
|
|
38
|
+
pyodbc = load_pyodbc()
|
|
39
|
+
return pyodbc.connect(
|
|
40
|
+
build_connection_string(config),
|
|
41
|
+
timeout=config.login_timeout_seconds,
|
|
42
|
+
)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import time
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Callable, Iterable, Iterator, Sequence
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.progress import (
|
|
12
|
+
BarColumn,
|
|
13
|
+
Progress,
|
|
14
|
+
SpinnerColumn,
|
|
15
|
+
TaskProgressColumn,
|
|
16
|
+
TextColumn,
|
|
17
|
+
TimeElapsedColumn,
|
|
18
|
+
)
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
|
|
21
|
+
from sqlcsv_exporter.config import ExportConfig
|
|
22
|
+
from sqlcsv_exporter.connection import open_connection
|
|
23
|
+
from sqlcsv_exporter.sql_rewriter import read_sql_file, replace_declared_date_parameter
|
|
24
|
+
|
|
25
|
+
ProgressCallback = Callable[[int, int], None]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ExportError(RuntimeError):
|
|
29
|
+
"""Raised when the export pipeline cannot complete successfully."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class ExportResult:
|
|
34
|
+
output_csv: Path
|
|
35
|
+
row_count: int
|
|
36
|
+
column_count: int
|
|
37
|
+
file_size_bytes: int
|
|
38
|
+
duration_seconds: float
|
|
39
|
+
date_parameter_replaced: bool
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def iter_row_chunks(cursor, chunk_size: int) -> Iterator[Sequence[Sequence[object]]]:
|
|
43
|
+
while True:
|
|
44
|
+
rows = cursor.fetchmany(chunk_size)
|
|
45
|
+
if not rows:
|
|
46
|
+
break
|
|
47
|
+
yield rows
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def write_rows_to_csv(
|
|
51
|
+
output_path: Path,
|
|
52
|
+
columns: Sequence[str],
|
|
53
|
+
row_chunks: Iterable[Sequence[Sequence[object]]],
|
|
54
|
+
*,
|
|
55
|
+
delimiter: str,
|
|
56
|
+
encoding: str,
|
|
57
|
+
include_header: bool,
|
|
58
|
+
progress_callback: ProgressCallback | None = None,
|
|
59
|
+
) -> tuple[int, int]:
|
|
60
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
|
|
62
|
+
total_rows = 0
|
|
63
|
+
with output_path.open("w", newline="", encoding=encoding) as handle:
|
|
64
|
+
writer = csv.writer(handle, delimiter=delimiter)
|
|
65
|
+
|
|
66
|
+
if include_header:
|
|
67
|
+
writer.writerow(columns)
|
|
68
|
+
|
|
69
|
+
for chunk in row_chunks:
|
|
70
|
+
writer.writerows(chunk)
|
|
71
|
+
total_rows += len(chunk)
|
|
72
|
+
|
|
73
|
+
if progress_callback is not None:
|
|
74
|
+
progress_callback(total_rows, handle.tell())
|
|
75
|
+
|
|
76
|
+
return total_rows, output_path.stat().st_size
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _format_size(num_bytes: int) -> str:
|
|
80
|
+
if num_bytes < 1024:
|
|
81
|
+
return f"{num_bytes} B"
|
|
82
|
+
if num_bytes < 1024 * 1024:
|
|
83
|
+
return f"{num_bytes / 1024:.1f} KiB"
|
|
84
|
+
return f"{num_bytes / (1024 * 1024):.1f} MiB"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _render_run_summary(console: Console, config: ExportConfig, date_replaced: bool) -> None:
|
|
88
|
+
summary = Table.grid(padding=(0, 2))
|
|
89
|
+
summary.add_column(style="cyan")
|
|
90
|
+
summary.add_column()
|
|
91
|
+
summary.add_row("SQL file", str(config.sql_file))
|
|
92
|
+
summary.add_row("Output", str(config.output_csv))
|
|
93
|
+
summary.add_row("Server", config.server)
|
|
94
|
+
summary.add_row("Database", config.database)
|
|
95
|
+
summary.add_row("As-of date", config.as_of_date)
|
|
96
|
+
summary.add_row("Chunk size", f"{config.chunk_size:,}")
|
|
97
|
+
summary.add_row("Date rewritten", "yes" if date_replaced else "no")
|
|
98
|
+
console.print(summary)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _apply_query_timeout(connection: object, cursor: object, timeout_seconds: int) -> None:
|
|
102
|
+
if hasattr(cursor, "timeout"):
|
|
103
|
+
with suppress(Exception):
|
|
104
|
+
setattr(cursor, "timeout", timeout_seconds)
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
if hasattr(connection, "timeout"):
|
|
108
|
+
with suppress(Exception):
|
|
109
|
+
setattr(connection, "timeout", timeout_seconds)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def execute_query_to_csv(config: ExportConfig, *, console: Console | None = None) -> ExportResult:
|
|
113
|
+
console = console or Console(stderr=True)
|
|
114
|
+
sql_text = read_sql_file(config.sql_file)
|
|
115
|
+
sql_text, date_replaced = replace_declared_date_parameter(
|
|
116
|
+
sql_text,
|
|
117
|
+
config.as_of_date,
|
|
118
|
+
parameter_name=config.date_parameter_name,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
_render_run_summary(console, config, date_replaced)
|
|
122
|
+
|
|
123
|
+
started_at = time.perf_counter()
|
|
124
|
+
connection = None
|
|
125
|
+
cursor = None
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
with console.status("Connecting to SQL Server..."):
|
|
129
|
+
connection = open_connection(config)
|
|
130
|
+
cursor = connection.cursor()
|
|
131
|
+
cursor.arraysize = config.chunk_size
|
|
132
|
+
_apply_query_timeout(connection, cursor, config.query_timeout_seconds)
|
|
133
|
+
|
|
134
|
+
with console.status("Executing query..."):
|
|
135
|
+
cursor.execute(sql_text)
|
|
136
|
+
|
|
137
|
+
if cursor.description is None:
|
|
138
|
+
raise ExportError(
|
|
139
|
+
"The SQL completed without returning a result set. Ensure the script ends with a SELECT query."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
columns = [column[0] or f"column_{index + 1}" for index, column in enumerate(cursor.description)]
|
|
143
|
+
console.print(f"Columns detected: [bold]{len(columns)}[/bold]")
|
|
144
|
+
|
|
145
|
+
progress = Progress(
|
|
146
|
+
SpinnerColumn(),
|
|
147
|
+
TextColumn("[progress.description]{task.description}"),
|
|
148
|
+
BarColumn(),
|
|
149
|
+
TaskProgressColumn(),
|
|
150
|
+
TextColumn("{task.fields[row_text]}"),
|
|
151
|
+
TextColumn("{task.fields[size_text]}"),
|
|
152
|
+
TimeElapsedColumn(),
|
|
153
|
+
console=console,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
with progress:
|
|
157
|
+
task_id = progress.add_task(
|
|
158
|
+
"Writing CSV",
|
|
159
|
+
total=None,
|
|
160
|
+
row_text="0 rows",
|
|
161
|
+
size_text="0 B",
|
|
162
|
+
)
|
|
163
|
+
completed_rows = 0
|
|
164
|
+
|
|
165
|
+
def on_progress(total_rows: int, size_bytes: int) -> None:
|
|
166
|
+
nonlocal completed_rows
|
|
167
|
+
progress.update(
|
|
168
|
+
task_id,
|
|
169
|
+
advance=0,
|
|
170
|
+
row_text=f"{total_rows:,} rows",
|
|
171
|
+
size_text=_format_size(size_bytes),
|
|
172
|
+
)
|
|
173
|
+
progress.advance(task_id, total_rows - completed_rows)
|
|
174
|
+
completed_rows = total_rows
|
|
175
|
+
|
|
176
|
+
row_count, file_size_bytes = write_rows_to_csv(
|
|
177
|
+
config.output_csv,
|
|
178
|
+
columns,
|
|
179
|
+
iter_row_chunks(cursor, config.chunk_size),
|
|
180
|
+
delimiter=config.delimiter,
|
|
181
|
+
encoding=config.encoding,
|
|
182
|
+
include_header=config.include_header,
|
|
183
|
+
progress_callback=on_progress,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
duration_seconds = time.perf_counter() - started_at
|
|
187
|
+
console.print(
|
|
188
|
+
f"Export complete: [bold]{row_count:,}[/bold] rows, "
|
|
189
|
+
f"[bold]{len(columns)}[/bold] columns, {_format_size(file_size_bytes)} written."
|
|
190
|
+
)
|
|
191
|
+
return ExportResult(
|
|
192
|
+
output_csv=config.output_csv,
|
|
193
|
+
row_count=row_count,
|
|
194
|
+
column_count=len(columns),
|
|
195
|
+
file_size_bytes=file_size_bytes,
|
|
196
|
+
duration_seconds=duration_seconds,
|
|
197
|
+
date_parameter_replaced=date_replaced,
|
|
198
|
+
)
|
|
199
|
+
finally:
|
|
200
|
+
if cursor is not None:
|
|
201
|
+
with suppress(Exception):
|
|
202
|
+
cursor.close()
|
|
203
|
+
if connection is not None:
|
|
204
|
+
with suppress(Exception):
|
|
205
|
+
connection.close()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def read_sql_file(path: Path) -> str:
|
|
8
|
+
if not path.exists():
|
|
9
|
+
raise FileNotFoundError(f"SQL file not found: {path}")
|
|
10
|
+
return path.read_text(encoding="utf-8")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def replace_declared_date_parameter(
|
|
14
|
+
sql_text: str,
|
|
15
|
+
date_value: str,
|
|
16
|
+
*,
|
|
17
|
+
parameter_name: str,
|
|
18
|
+
) -> tuple[str, bool]:
|
|
19
|
+
escaped_name = re.escape(parameter_name)
|
|
20
|
+
pattern = (
|
|
21
|
+
rf"((?:DECLARE\s+{escaped_name}\s+(?:DATE|DATETIME|DATETIME2)"
|
|
22
|
+
rf"|SET\s+{escaped_name})\s*=\s*')([^']*)(')"
|
|
23
|
+
)
|
|
24
|
+
rewritten_sql, replacements = re.subn(
|
|
25
|
+
pattern,
|
|
26
|
+
rf"\g<1>{date_value}\g<3>",
|
|
27
|
+
sql_text,
|
|
28
|
+
flags=re.IGNORECASE,
|
|
29
|
+
)
|
|
30
|
+
return rewritten_sql, replacements > 0
|