singularitysql 0.1.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.
@@ -0,0 +1,18 @@
1
+ """Singularity — SQL Server stored procedure introspection and Pydantic model generation."""
2
+
3
+ from singularity.exceptions import SingularityError, SpNotFoundError
4
+ from singularity.introspector import SQLServerIntrospector
5
+ from singularity.model_generator import generate_model
6
+ from singularity.types import ColumnInfo, Parameter, SPMetadata
7
+ from singularity.version import ServerVersion
8
+
9
+ __all__ = [
10
+ "SQLServerIntrospector",
11
+ "generate_model",
12
+ "SPMetadata",
13
+ "ColumnInfo",
14
+ "Parameter",
15
+ "ServerVersion",
16
+ "SingularityError",
17
+ "SpNotFoundError",
18
+ ]
@@ -0,0 +1,8 @@
1
+ """Singularity CLI — Typer app and commands."""
2
+
3
+ from singularity.cli._app import _main, app
4
+
5
+ __all__ = ["app", "main"]
6
+
7
+ # main is an alias for _main for entry point consistency
8
+ main = _main
@@ -0,0 +1,25 @@
1
+ """Singularity Typer app instance — isolated to avoid circular imports."""
2
+
3
+ import sys
4
+
5
+ import typer
6
+
7
+
8
+ def _main() -> None:
9
+ """Entry point with UTF-8 encoding support for Windows consoles."""
10
+ if sys.platform == "win32":
11
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
12
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
13
+ app()
14
+
15
+
16
+ app = typer.Typer(
17
+ name="singularity",
18
+ help="SQL Server stored procedure introspection and Pydantic model generator.",
19
+ no_args_is_help=True,
20
+ invoke_without_command=True,
21
+ )
22
+
23
+ # Register commands — import triggers @app.command() decorators
24
+ import singularity.cli.docs_generate # noqa: E402, F401 — registers docs command
25
+ import singularity.cli.generate # noqa: E402, F401 — registers generate command
@@ -0,0 +1,142 @@
1
+ """TOML configuration loading and Pydantic config models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ from pydantic import BaseModel, field_validator
9
+
10
+
11
+ class ConnectionConfig(BaseModel):
12
+ """SQL Server connection settings."""
13
+
14
+ server: str
15
+ """Server hostname or IP address (required)."""
16
+
17
+ database: str
18
+ """Database name (required)."""
19
+
20
+ driver: str = "ODBC Driver 18 for SQL Server"
21
+ """ODBC driver name."""
22
+
23
+ trusted_connection: bool = True
24
+ """Use Windows Authentication when True."""
25
+
26
+ username: str | None = None
27
+ """SQL Server login username (used when trusted_connection is False)."""
28
+
29
+ password: str | None = None
30
+ """SQL Server login password (used when trusted_connection is False)."""
31
+
32
+ def build_connection_string(self) -> str:
33
+ """Build a pyodbc-compatible connection string from the config.
34
+
35
+ Returns:
36
+ A DRIVER=SERVER=DATABASE=... connection string.
37
+ """
38
+ parts = [
39
+ f"DRIVER={{{self.driver}}}",
40
+ f"SERVER={self.server}",
41
+ f"DATABASE={self.database}",
42
+ ]
43
+ if self.trusted_connection:
44
+ parts.append("Trusted_Connection=yes")
45
+ else:
46
+ if self.username:
47
+ parts.append(f"UID={self.username}")
48
+ if self.password:
49
+ parts.append(f"PWD={self.password}")
50
+ return ";".join(parts)
51
+
52
+
53
+ class SpSelectionConfig(BaseModel):
54
+ """Stored procedure selection criteria."""
55
+
56
+ procedures: list[str] | None = None
57
+ """Explicit list of stored procedure names to introspect."""
58
+
59
+ pattern: str | None = None
60
+ """Wildcard pattern (e.g. 'usp_%') to resolve against the database."""
61
+
62
+ @field_validator("procedures", "pattern")
63
+ @classmethod
64
+ def _at_least_one(cls, v: list[str] | str | None) -> list[str] | str | None:
65
+ return v
66
+
67
+
68
+ class OutputConfig(BaseModel):
69
+ """Output preferences for generated models."""
70
+
71
+ directory: str = "."
72
+ """Output directory for generated files."""
73
+
74
+ mode: Literal["dynamic", "source"] = "source"
75
+ """Generation mode: 'dynamic' for runtime models, 'source' for .py files."""
76
+
77
+ file_naming: str = "{sp_name}.py"
78
+ """File naming template with {sp_name}, {schema}, {database} variables."""
79
+
80
+ naming_convention: Literal["snake_case", "camelCase", "PascalCase"] = "snake_case"
81
+ """Field naming convention for generated models."""
82
+
83
+ docs_directory: str | None = None
84
+ """Output directory for generated Markdown documentation.
85
+ If set, ``singularity docs`` writes docs here. Defaults to ``docs/``."""
86
+
87
+ @field_validator("mode")
88
+ @classmethod
89
+ def _validate_mode(cls, v: str) -> str:
90
+ allowed = {"dynamic", "source"}
91
+ if v not in allowed:
92
+ raise ValueError(f"mode must be one of {allowed}, got '{v}'")
93
+ return v
94
+
95
+ @field_validator("naming_convention")
96
+ @classmethod
97
+ def _validate_convention(cls, v: str) -> str:
98
+ allowed = {"snake_case", "camelCase", "PascalCase"}
99
+ if v not in allowed:
100
+ raise ValueError(
101
+ f"naming_convention must be one of {allowed}, got '{v}'"
102
+ )
103
+ return v
104
+
105
+
106
+ class SingularityConfig(BaseModel):
107
+ """Top-level configuration model."""
108
+
109
+ connection: ConnectionConfig
110
+ """SQL Server connection settings."""
111
+
112
+ sp_selection: SpSelectionConfig
113
+ """Stored procedure selection criteria."""
114
+
115
+ output: OutputConfig = OutputConfig()
116
+ """Output preferences (defaults apply if section omitted)."""
117
+
118
+
119
+ def load_config(path: str | Path) -> SingularityConfig:
120
+ """Load and validate a TOML configuration file.
121
+
122
+ Args:
123
+ path: Path to the TOML config file.
124
+
125
+ Returns:
126
+ A validated SingularityConfig instance.
127
+
128
+ Raises:
129
+ FileNotFoundError: If the config file does not exist.
130
+ pydantic.ValidationError: If the config is invalid.
131
+ """
132
+ import tomli
133
+
134
+ path = Path(path)
135
+
136
+ if not path.exists():
137
+ raise FileNotFoundError(f"Config file not found: {path}")
138
+
139
+ raw = path.read_text(encoding="utf-8")
140
+ data = tomli.loads(raw)
141
+
142
+ return SingularityConfig(**data)
@@ -0,0 +1,196 @@
1
+ """CLI docs command — generates Markdown documentation for stored procedures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from singularity.cli._app import app
11
+ from singularity.cli.config import load_config
12
+ from singularity.introspector import SQLServerIntrospector
13
+ from singularity.model_generator import _generate_source
14
+ from singularity.types import SPMetadata
15
+
16
+ console = Console()
17
+
18
+
19
+ @app.command()
20
+ def docs(
21
+ config: str = typer.Option(
22
+ ...,
23
+ "--config",
24
+ "-c",
25
+ help="Path to TOML configuration file.",
26
+ ),
27
+ output: str | None = typer.Option(
28
+ None,
29
+ "--output",
30
+ "-o",
31
+ help="Output directory for documentation. Overrides config.",
32
+ ),
33
+ ) -> None:
34
+ """Generate Markdown documentation for stored procedures.
35
+
36
+ Introspects the configured stored procedures and writes a Markdown
37
+ file per SP with parameter tables, result set columns, descriptions
38
+ (from ``sys.extended_properties``), and the generated Pydantic model
39
+ source code.
40
+ """
41
+ # --- Load config -------------------------------------------------------
42
+ cfg_path = Path(config)
43
+ if not cfg_path.exists():
44
+ console.print(f"[red]Error:[/red] Config file not found: [bold]{cfg_path}[/bold]")
45
+ raise typer.Exit(code=1)
46
+
47
+ try:
48
+ cfg = load_config(str(cfg_path))
49
+ except Exception as exc:
50
+ console.print(f"[red]Error loading config:[/red] {exc}")
51
+ raise typer.Exit(code=1) from exc
52
+
53
+ # --- Resolve output directory ------------------------------------------
54
+ docs_dir: Path
55
+ if output:
56
+ docs_dir = Path(output)
57
+ elif cfg.output.docs_directory:
58
+ docs_dir = Path(cfg.output.docs_directory)
59
+ else:
60
+ docs_dir = Path("docs")
61
+
62
+ docs_dir.mkdir(parents=True, exist_ok=True)
63
+
64
+ # --- Connect to SQL Server ---------------------------------------------
65
+ conn_str = cfg.connection.build_connection_string()
66
+ introspector = SQLServerIntrospector(conn_str)
67
+
68
+ try:
69
+ introspector.connect()
70
+ version = introspector.detect_version()
71
+ console.print(f"[green]Connected.[/green] Detected version: [bold]{version.value}[/bold]")
72
+ except Exception as exc:
73
+ console.print(f"[red]Error connecting to SQL Server:[/red] {exc}")
74
+ raise typer.Exit(code=1) from exc
75
+
76
+ # --- Resolve SPs -------------------------------------------------------
77
+ sp_names: list[str] = []
78
+
79
+ if cfg.sp_selection.procedures:
80
+ sp_names.extend(cfg.sp_selection.procedures)
81
+
82
+ if cfg.sp_selection.pattern:
83
+ try:
84
+ cursor = introspector._connection.cursor() # type: ignore[union-attr]
85
+ cursor.execute(
86
+ """
87
+ SELECT ROUTINE_NAME
88
+ FROM INFORMATION_SCHEMA.ROUTINES
89
+ WHERE ROUTINE_TYPE = 'PROCEDURE'
90
+ AND ROUTINE_NAME LIKE ?
91
+ """,
92
+ (cfg.sp_selection.pattern,),
93
+ )
94
+ sp_names.extend(row[0] for row in cursor.fetchall())
95
+ except Exception as exc:
96
+ console.print(
97
+ f"[yellow]Error resolving SP pattern '{cfg.sp_selection.pattern}':[/yellow] {exc}"
98
+ )
99
+
100
+ if not sp_names:
101
+ console.print(
102
+ "[yellow]No stored procedures selected.[/yellow] "
103
+ "Check your [bold][sp_selection][/bold] config."
104
+ )
105
+ raise typer.Exit(code=0)
106
+
107
+ # --- Generate docs -----------------------------------------------------
108
+ success_count = 0
109
+ error_count = 0
110
+
111
+ for sp_name in sp_names:
112
+ try:
113
+ meta = introspector.introspect(sp_name)
114
+ doc_content = _render_doc(meta)
115
+ doc_path = docs_dir / f"{sp_name}.md"
116
+ doc_path.write_text(doc_content, encoding="utf-8")
117
+ console.print(f" [green]✓[/green] {sp_name} → {doc_path}")
118
+ success_count += 1
119
+ except Exception as exc:
120
+ console.print(f" [red]✗[/red] {sp_name}: {exc}")
121
+ error_count += 1
122
+
123
+ console.print(
124
+ f"\nDone. [green]{success_count} docs generated[/green], "
125
+ f"[red]{error_count} failed[/red]."
126
+ )
127
+ if error_count > 0:
128
+ raise typer.Exit(code=1)
129
+
130
+
131
+ def _render_doc(meta: SPMetadata) -> str:
132
+ """Render a stored procedure's documentation as Markdown.
133
+
134
+ Args:
135
+ meta: Fully introspected SP metadata (includes descriptions).
136
+
137
+ Returns:
138
+ A Markdown string suitable for writing to a .md file.
139
+ """
140
+ lines: list[str] = []
141
+ lines.append(f"# {meta.name}")
142
+ lines.append("")
143
+
144
+ # Schema
145
+ schema = meta.name.split(".")[0] if "." in meta.name else "dbo"
146
+ lines.append(f"**Schema:** {schema}")
147
+ lines.append("")
148
+
149
+ # --- Parameters table --------------------------------------------------
150
+ if meta.parameters:
151
+ lines.append("## Parameters")
152
+ lines.append("")
153
+ lines.append("| Name | Type | Direction | Default | Nullable | Description |")
154
+ lines.append("|------|------|-----------|---------|----------|-------------|")
155
+ for p in meta.parameters:
156
+ name = p.name
157
+ sql_type = p.sql_type
158
+ direction = p.direction
159
+ default = p.default or "—"
160
+ nullable = "YES" if p.nullable else "NO"
161
+ desc = p.description or "—"
162
+ lines.append(
163
+ f"| {name} | {sql_type} | {direction} | {default} | {nullable} | {desc} |"
164
+ )
165
+ lines.append("")
166
+
167
+ # --- Result set(s) table -----------------------------------------------
168
+ if meta.columns:
169
+ lines.append("## Result Set")
170
+ lines.append("")
171
+ lines.append("| Column | Type | Nullable | Description |")
172
+ lines.append("|--------|------|----------|-------------|")
173
+ for col in meta.columns:
174
+ name = col.name
175
+ sql_type = col.sql_type
176
+ nullable = "YES" if col.nullable else "NO"
177
+ desc = col.description or "—"
178
+ lines.append(f"| {name} | {sql_type} | {nullable} | {desc} |")
179
+ lines.append("")
180
+
181
+ # --- Generated model ---------------------------------------------------
182
+ try:
183
+ source_code = _generate_source(meta)
184
+ lines.append("## Generated Model")
185
+ lines.append("")
186
+ lines.append("```python")
187
+ lines.append(source_code.rstrip("\n"))
188
+ lines.append("```")
189
+ lines.append("")
190
+ except Exception:
191
+ lines.append("## Generated Model")
192
+ lines.append("")
193
+ lines.append("*Model generation failed for this stored procedure.*")
194
+ lines.append("")
195
+
196
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,210 @@
1
+ """CLI generate command — orchestrates the full pipeline.
2
+
3
+ Delegates all introspection and model generation to the library.
4
+ Never reimplements library logic.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.progress import (
14
+ Progress,
15
+ SpinnerColumn,
16
+ TextColumn,
17
+ )
18
+ from rich.table import Table
19
+
20
+ from singularity.cli._app import app
21
+ from singularity.cli.config import load_config
22
+ from singularity.introspector import SQLServerIntrospector
23
+ from singularity.model_generator import generate_model
24
+
25
+ console = Console()
26
+
27
+
28
+ @app.command()
29
+ def generate(
30
+ config: str = typer.Option(
31
+ ...,
32
+ "--config",
33
+ "-c",
34
+ help="Path to TOML configuration file.",
35
+ ),
36
+ ) -> None:
37
+ """Introspect stored procedures and generate Pydantic models.
38
+
39
+ Reads configuration from a TOML file, connects to SQL Server,
40
+ introspects the specified stored procedures, and writes the
41
+ generated models to the output directory.
42
+ """
43
+ # --- Load config -------------------------------------------------------
44
+ cfg_path = Path(config)
45
+ if not cfg_path.exists():
46
+ console.print(f"[red]Error:[/red] Config file not found: [bold]{cfg_path}[/bold]")
47
+ raise typer.Exit(code=1)
48
+
49
+ try:
50
+ cfg = load_config(str(cfg_path))
51
+ except Exception as exc:
52
+ console.print(f"[red]Error loading config:[/red] {exc}")
53
+ raise typer.Exit(code=1) from exc
54
+
55
+ # --- Connect to SQL Server ---------------------------------------------
56
+ conn_str = cfg.connection.build_connection_string()
57
+ introspector = SQLServerIntrospector(conn_str)
58
+
59
+ try:
60
+ with Progress(
61
+ SpinnerColumn(),
62
+ TextColumn("[progress.description]{task.description}"),
63
+ console=console,
64
+ ) as progress:
65
+ progress.add_task("Connecting to SQL Server...", total=None)
66
+ introspector.connect()
67
+ version = introspector.detect_version()
68
+ console.print(f"[green]Connected.[/green] Detected version: [bold]{version.value}[/bold]")
69
+ except Exception as exc:
70
+ console.print(f"[red]Error connecting to SQL Server:[/red] {exc}")
71
+ raise typer.Exit(code=1) from exc
72
+
73
+ # --- Resolve SPs to introspect -----------------------------------------
74
+ sp_names: list[str] = []
75
+
76
+ if cfg.sp_selection.procedures:
77
+ sp_names.extend(cfg.sp_selection.procedures)
78
+
79
+ if cfg.sp_selection.pattern:
80
+ try:
81
+ cursor = introspector._connection.cursor() # type: ignore[union-attr]
82
+ cursor.execute(
83
+ """
84
+ SELECT ROUTINE_NAME
85
+ FROM INFORMATION_SCHEMA.ROUTINES
86
+ WHERE ROUTINE_TYPE = 'PROCEDURE'
87
+ AND ROUTINE_NAME LIKE ?
88
+ """,
89
+ (cfg.sp_selection.pattern,),
90
+ )
91
+ sp_names.extend(row[0] for row in cursor.fetchall())
92
+ except Exception as exc:
93
+ console.print(
94
+ f"[yellow]Error resolving SP pattern '{cfg.sp_selection.pattern}':[/yellow] {exc}"
95
+ )
96
+
97
+ if not sp_names:
98
+ console.print(
99
+ "[yellow]No stored procedures selected.[/yellow] "
100
+ "Check your [bold][sp_selection][/bold] config."
101
+ )
102
+ raise typer.Exit(code=0)
103
+
104
+ # --- Introspect each SP and generate models ----------------------------
105
+ out_dir = Path(cfg.output.directory)
106
+ out_dir.mkdir(parents=True, exist_ok=True)
107
+
108
+ naming_convention = cfg.output.naming_convention
109
+ mode = cfg.output.mode
110
+ file_naming = cfg.output.file_naming
111
+
112
+ summary_data: list[dict[str, str | int]] = []
113
+
114
+ with Progress(
115
+ SpinnerColumn(),
116
+ TextColumn("[progress.description]{task.description}"),
117
+ console=console,
118
+ ) as progress:
119
+ task = progress.add_task(
120
+ f"Introspecting [bold]{len(sp_names)}[/bold] stored procedure(s)...",
121
+ total=len(sp_names),
122
+ )
123
+
124
+ for sp_name in sp_names:
125
+ try:
126
+ meta = introspector.introspect(sp_name)
127
+
128
+ result = generate_model(
129
+ meta,
130
+ mode=mode,
131
+ naming_convention=naming_convention,
132
+ )
133
+
134
+ if mode == "source":
135
+ parts = sp_name.split(".")
136
+ schema = parts[0] if len(parts) > 1 else "dbo"
137
+ filename = file_naming.format(
138
+ sp_name=sp_name,
139
+ schema=schema,
140
+ database=cfg.connection.database,
141
+ )
142
+ filepath = out_dir / filename
143
+
144
+ if isinstance(result, str):
145
+ filepath.write_text(result, encoding="utf-8")
146
+ else:
147
+ console.print(
148
+ f"[red] {sp_name}:[/red] unexpected type returned"
149
+ )
150
+ summary_data.append({
151
+ "sp_name": sp_name,
152
+ "params": len(meta.parameters),
153
+ "columns": len(meta.columns),
154
+ "status": "ERROR",
155
+ })
156
+ progress.advance(task)
157
+ continue
158
+ else:
159
+ pass # dynamic mode
160
+
161
+ summary_data.append({
162
+ "sp_name": sp_name,
163
+ "params": len(meta.parameters),
164
+ "columns": len(meta.columns),
165
+ "status": "OK",
166
+ })
167
+
168
+ except Exception as exc:
169
+ console.print(f"[red] Error processing {sp_name}:[/red] {exc}")
170
+ summary_data.append({
171
+ "sp_name": sp_name,
172
+ "params": 0,
173
+ "columns": 0,
174
+ "status": "ERROR",
175
+ })
176
+
177
+ progress.advance(task)
178
+
179
+ # --- Summary table -----------------------------------------------------
180
+ table = Table(title="Generation Summary")
181
+ table.add_column("SP Name", style="cyan")
182
+ table.add_column("Params", justify="right")
183
+ table.add_column("Columns", justify="right")
184
+ table.add_column("Status")
185
+
186
+ success_count = 0
187
+ error_count = 0
188
+ for row in summary_data:
189
+ status = row["status"]
190
+ if status == "OK":
191
+ status_style = "[green]OK[/green]"
192
+ success_count += 1
193
+ else:
194
+ status_style = "[red]ERROR[/red]"
195
+ error_count += 1
196
+ table.add_row(
197
+ str(row["sp_name"]),
198
+ str(row["params"]),
199
+ str(row["columns"]),
200
+ status_style,
201
+ )
202
+
203
+ console.print(table)
204
+ console.print(
205
+ f"\nDone. [green]{success_count} succeeded[/green], "
206
+ f"[red]{error_count} failed[/red]."
207
+ )
208
+
209
+ if error_count > 0:
210
+ raise typer.Exit(code=1)
@@ -0,0 +1,13 @@
1
+ """Custom exception hierarchy for Singularity."""
2
+
3
+
4
+ class SingularityError(Exception):
5
+ """Base exception for all Singularity errors."""
6
+
7
+
8
+ class SpNotFoundError(SingularityError):
9
+ """Raised when a requested stored procedure does not exist."""
10
+
11
+ def __init__(self, sp_name: str) -> None:
12
+ self.sp_name = sp_name
13
+ super().__init__(f"Stored procedure '{sp_name}' not found in the database.")