sql-lineage-extractor 0.1.1__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,30 @@
1
+ """sql_lineage — static column-to-column SQL lineage extractor.
2
+
3
+ Pipeline: sources -> parsing/lineage -> graph -> manifest (JSON).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from .config import LineageConfig
9
+ from .models import ColumnLineage, ColumnSource, ObjectNode, ParseError, SQLUnit
10
+
11
+ __all__ = [
12
+ "SQLUnit",
13
+ "ColumnSource",
14
+ "ColumnLineage",
15
+ "ObjectNode",
16
+ "ParseError",
17
+ "LineageConfig",
18
+ ]
19
+
20
+
21
+ def _resolve_version() -> str:
22
+ from importlib.metadata import PackageNotFoundError, version
23
+
24
+ try:
25
+ return version("sql-lineage-extractor")
26
+ except PackageNotFoundError: # pragma: no cover - source checkout without install
27
+ return "0.0.0+unknown"
28
+
29
+
30
+ __version__ = _resolve_version()
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.1'
22
+ __version_tuple__ = version_tuple = (0, 1, 1)
23
+
24
+ __commit_id__ = commit_id = None
sql_lineage/cli.py ADDED
@@ -0,0 +1,217 @@
1
+ """Command-line interface (click): ``sql-lineage extract`` and ``validate``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import click
12
+
13
+ from .config import LineageConfig
14
+ from .export import CsvExporter, SqlDbExporter, SqliteExporter, build_export_tables
15
+ from .manifest import validate_manifest, write_manifest
16
+ from .pipeline import run_extraction
17
+ from .sources import FileSQLSource, LiveSQLEndpointSource, NotebookSQLSource
18
+
19
+
20
+ @click.group()
21
+ @click.version_option(package_name="sql-lineage-extractor")
22
+ def cli() -> None:
23
+ """Static column-to-column SQL lineage extractor."""
24
+
25
+
26
+ @cli.command()
27
+ @click.option(
28
+ "--source",
29
+ "source_kind",
30
+ type=click.Choice(["files", "notebooks", "live"]),
31
+ required=True,
32
+ help="Where the SQL comes from.",
33
+ )
34
+ @click.option("--path", type=click.Path(), default=None, help="Folder to scan (files/notebooks).")
35
+ @click.option(
36
+ "--dialect",
37
+ default=None,
38
+ help="sqlglot dialect (défaut: tsql, ou default_dialect du fichier de config).",
39
+ )
40
+ @click.option("--out", "out_path", type=click.Path(), required=True, help="Manifest output path.")
41
+ @click.option("--conn-string", default=None, help="DB-API connection string (live mode).")
42
+ @click.option("--glob", "glob_pattern", default=None, help="Override the file glob pattern.")
43
+ @click.option(
44
+ "--config",
45
+ "config_path",
46
+ type=click.Path(exists=True),
47
+ default=None,
48
+ help="Fichier de conventions (.toml/.json) ; les flags ci-dessous le surchargent.",
49
+ )
50
+ @click.option(
51
+ "--id-comment-pattern",
52
+ default=None,
53
+ help="Regex du commentaire d'id (1 groupe de capture). Défaut: '-- lineage:id=(...)'.",
54
+ )
55
+ @click.option(
56
+ "--var-prefix",
57
+ "var_prefixes",
58
+ multiple=True,
59
+ help="Préfixe de variable à retirer pour dériver l'id (répétable). Défaut: 'df_'.",
60
+ )
61
+ @click.option(
62
+ "--param-token",
63
+ default=None,
64
+ help="Jeton de neutralisation des interpolations f-string. Défaut: '__PARAM__'.",
65
+ )
66
+ def extract(
67
+ source_kind: str,
68
+ path: str | None,
69
+ dialect: str | None,
70
+ out_path: str,
71
+ conn_string: str | None,
72
+ glob_pattern: str | None,
73
+ config_path: str | None,
74
+ id_comment_pattern: str | None,
75
+ var_prefixes: tuple[str, ...],
76
+ param_token: str | None,
77
+ ) -> None:
78
+ """Extract lineage and write a manifest JSON."""
79
+ try:
80
+ config = _build_config(
81
+ config_path, id_comment_pattern, var_prefixes, param_token, dialect
82
+ )
83
+ except (ValueError, RuntimeError) as err:
84
+ raise click.UsageError(f"Configuration invalide: {err}") from err
85
+
86
+ source = _build_source(source_kind, path, conn_string, glob_pattern, config)
87
+ nodes, errors = run_extraction(source)
88
+ write_manifest(out_path, nodes, errors)
89
+
90
+ click.echo(
91
+ f"{len(nodes)} objet(s) analysé(s), {len(errors)} erreur(s) -> {out_path}"
92
+ )
93
+ for parse_err in errors:
94
+ click.echo(
95
+ f" ! {parse_err.id} ({parse_err.origine}): {parse_err.message}", err=True
96
+ )
97
+
98
+
99
+ @cli.command()
100
+ @click.argument("manifest_path", type=click.Path(exists=True))
101
+ def validate(manifest_path: str) -> None:
102
+ """Validate a manifest against the published JSON Schema."""
103
+ manifest = json.loads(Path(manifest_path).read_text(encoding="utf-8"))
104
+ try:
105
+ validate_manifest(manifest)
106
+ except Exception as err: # jsonschema.ValidationError and friends
107
+ click.echo(f"INVALIDE: {err}", err=True)
108
+ sys.exit(1)
109
+ click.echo("VALIDE: le manifest est conforme au schéma.")
110
+
111
+
112
+ @cli.command()
113
+ @click.argument("manifest_path", type=click.Path(exists=True))
114
+ @click.option(
115
+ "--format",
116
+ "fmt",
117
+ type=click.Choice(["csv", "sqlite", "sqlserver"]),
118
+ required=True,
119
+ help="Backend cible pour les tables BI.",
120
+ )
121
+ @click.option(
122
+ "--out", "out_path", type=click.Path(), default=None,
123
+ help="Dossier (csv) ou fichier (sqlite).",
124
+ )
125
+ @click.option("--conn-string", default=None, help="Connexion DB-API (format sqlserver).")
126
+ @click.option(
127
+ "--run-id", default=None,
128
+ help="Identifiant de run (défaut: generated_at du manifest).",
129
+ )
130
+ def export(
131
+ fmt: str,
132
+ manifest_path: str,
133
+ out_path: str | None,
134
+ conn_string: str | None,
135
+ run_id: str | None,
136
+ ) -> None:
137
+ """Dériver les tables BI (étoile) d'un manifest vers CSV / SQLite / SQL Server."""
138
+ manifest = json.loads(Path(manifest_path).read_text(encoding="utf-8"))
139
+ tables = build_export_tables(manifest, run_id=run_id)
140
+
141
+ if fmt == "csv":
142
+ if not out_path:
143
+ raise click.UsageError("--out (dossier) est requis pour --format csv.")
144
+ CsvExporter(out_path).export(tables)
145
+ target = out_path
146
+ elif fmt == "sqlite":
147
+ if not out_path:
148
+ raise click.UsageError("--out (fichier .db) est requis pour --format sqlite.")
149
+ with SqliteExporter(out_path) as sqlite_exporter:
150
+ sqlite_exporter.export(tables)
151
+ target = out_path
152
+ else: # sqlserver
153
+ if not conn_string:
154
+ raise click.UsageError("--conn-string est requis pour --format sqlserver.")
155
+ SqlDbExporter(_open_pyodbc(conn_string)).export(tables)
156
+ target = "<connexion SQL>"
157
+
158
+ counts = ", ".join(f"{name}={len(rows)}" for name, rows in tables.items())
159
+ click.echo(f"Tables exportées vers {target} : {counts}")
160
+
161
+
162
+ def _build_config(
163
+ config_path: str | None,
164
+ id_comment_pattern: str | None,
165
+ var_prefixes: tuple[str, ...],
166
+ param_token: str | None,
167
+ dialect: str | None,
168
+ ) -> LineageConfig:
169
+ """Build the effective config: file (if any), then per-flag overrides."""
170
+ base = LineageConfig.from_file(config_path) if config_path else LineageConfig()
171
+ overrides: dict[str, Any] = {}
172
+ if id_comment_pattern is not None:
173
+ overrides["id_comment_pattern"] = id_comment_pattern
174
+ if var_prefixes: # non-empty tuple => the flag was provided
175
+ overrides["var_prefixes"] = tuple(var_prefixes)
176
+ if param_token is not None:
177
+ overrides["param_token"] = param_token
178
+ if dialect is not None:
179
+ overrides["default_dialect"] = dialect
180
+ return dataclasses.replace(base, **overrides) if overrides else base
181
+
182
+
183
+ def _build_source(
184
+ source_kind: str,
185
+ path: str | None,
186
+ conn_string: str | None,
187
+ glob_pattern: str | None,
188
+ config: LineageConfig,
189
+ ):
190
+ if source_kind == "files":
191
+ if not path:
192
+ raise click.UsageError("--path est requis pour --source files.")
193
+ return FileSQLSource(path, pattern=glob_pattern or "**/*.sql", config=config)
194
+ if source_kind == "notebooks":
195
+ if not path:
196
+ raise click.UsageError("--path est requis pour --source notebooks.")
197
+ return NotebookSQLSource(path, pattern=glob_pattern or "**/*.ipynb", config=config)
198
+ if source_kind == "live":
199
+ if not conn_string:
200
+ raise click.UsageError("--conn-string est requis pour --source live.")
201
+ return LiveSQLEndpointSource(_open_pyodbc(conn_string), config=config)
202
+ raise click.UsageError(f"Source inconnue: {source_kind}")
203
+
204
+
205
+ def _open_pyodbc(conn_string: str):
206
+ try:
207
+ import pyodbc # noqa: PLC0415 - optional dependency, imported lazily
208
+ except ImportError as err: # pragma: no cover - depends on optional extra
209
+ raise click.UsageError(
210
+ "Le mode live nécessite l'extra 'live' (pip install "
211
+ "sql-lineage-extractor[live])."
212
+ ) from err
213
+ return pyodbc.connect(conn_string)
214
+
215
+
216
+ if __name__ == "__main__": # pragma: no cover
217
+ cli()
sql_lineage/config.py ADDED
@@ -0,0 +1,145 @@
1
+ """Configuration of the *conventions* the extractor relies on.
2
+
3
+ The library must stay **generic**: nothing project- or client-specific may be
4
+ hard-coded. Every convention used to resolve a notebook object's id, to strip a
5
+ dataframe-variable prefix, to neutralize f-string interpolations, or to pick a
6
+ default SQL dialect lives here, in an immutable :class:`LineageConfig` that the
7
+ caller can override — programmatically, via CLI flags, or from a TOML/JSON file.
8
+
9
+ The defaults reproduce the historical behaviour exactly, so existing callers are
10
+ unaffected.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any, Mapping
19
+
20
+ # -- historical defaults (reproduce the previous hard-coded behaviour) ---------
21
+
22
+ #: Convention comment naming a unit explicitly, e.g. ``-- lineage:id=silver_x``.
23
+ DEFAULT_ID_COMMENT_PATTERN = r"--\s*lineage:id\s*=\s*([A-Za-z0-9_.]+)"
24
+ #: Dataframe-variable prefixes stripped when deriving an id (``df_x`` -> ``x``).
25
+ DEFAULT_VAR_PREFIXES: tuple[str, ...] = ("df_",)
26
+ #: Token substituted for every f-string interpolation before parsing.
27
+ DEFAULT_PARAM_TOKEN = "__PARAM__"
28
+ #: Dialect used when a source is created without an explicit one.
29
+ DEFAULT_DIALECT = "tsql"
30
+
31
+ #: Keys accepted in a config mapping / file, mapped to the dataclass fields.
32
+ _CONFIG_KEYS = frozenset(
33
+ {"id_comment_pattern", "var_prefixes", "param_token", "default_dialect"}
34
+ )
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class LineageConfig:
39
+ """Immutable set of conventions driving id resolution and neutralization.
40
+
41
+ All fields are optional; each defaults to the historical behaviour.
42
+ """
43
+
44
+ id_comment_pattern: str = DEFAULT_ID_COMMENT_PATTERN
45
+ var_prefixes: tuple[str, ...] = DEFAULT_VAR_PREFIXES
46
+ param_token: str = DEFAULT_PARAM_TOKEN
47
+ default_dialect: str = DEFAULT_DIALECT
48
+ #: Compiled form of ``id_comment_pattern`` (built once, never part of eq/repr).
49
+ id_comment_re: "re.Pattern[str]" = field(init=False, compare=False, repr=False)
50
+
51
+ def __post_init__(self) -> None:
52
+ # Normalize a list/other iterable of prefixes to a tuple (hashable, frozen).
53
+ object.__setattr__(self, "var_prefixes", tuple(self.var_prefixes))
54
+
55
+ try:
56
+ compiled = re.compile(self.id_comment_pattern)
57
+ except re.error as err:
58
+ raise ValueError(f"id_comment_pattern invalide: {err}") from err
59
+ if compiled.groups < 1:
60
+ raise ValueError(
61
+ "id_comment_pattern doit contenir au moins un groupe de capture "
62
+ "délimitant l'id (ex. '-- lineage:id=(...)')."
63
+ )
64
+ object.__setattr__(self, "id_comment_re", compiled)
65
+
66
+ if not self.param_token:
67
+ raise ValueError("param_token ne peut pas être vide.")
68
+ if not self.default_dialect:
69
+ raise ValueError("default_dialect ne peut pas être vide.")
70
+
71
+ # -- id-resolution helpers (shared by notebook and file sources) ----------
72
+
73
+ def id_from_comment(self, text: str) -> str | None:
74
+ """Return the id declared by an ``-- lineage:id=`` comment, if any."""
75
+ match = self.id_comment_re.search(text)
76
+ return match.group(1) if match else None
77
+
78
+ def strip_var_prefix(self, var_name: str) -> str:
79
+ """Strip the first matching dataframe-variable prefix (``df_x`` -> ``x``)."""
80
+ for prefix in self.var_prefixes:
81
+ if prefix and var_name.startswith(prefix):
82
+ return var_name[len(prefix) :]
83
+ return var_name
84
+
85
+ # -- constructors ---------------------------------------------------------
86
+
87
+ @classmethod
88
+ def from_mapping(cls, data: Mapping[str, Any]) -> "LineageConfig":
89
+ """Build a config from a plain mapping, rejecting unknown keys."""
90
+ unknown = set(data) - _CONFIG_KEYS
91
+ if unknown:
92
+ raise ValueError(
93
+ f"Clé(s) de configuration inconnue(s): {sorted(unknown)}. "
94
+ f"Clés valides: {sorted(_CONFIG_KEYS)}."
95
+ )
96
+ kwargs: dict[str, Any] = dict(data)
97
+ if kwargs.get("var_prefixes") is not None:
98
+ kwargs["var_prefixes"] = tuple(kwargs["var_prefixes"])
99
+ return cls(**kwargs)
100
+
101
+ @classmethod
102
+ def from_file(cls, path: str | Path) -> "LineageConfig":
103
+ """Load a config from a ``.toml`` or ``.json`` file.
104
+
105
+ The settings may sit at the top level, under a ``[sql_lineage]`` table,
106
+ or under ``[tool.sql_lineage]`` (so a shared ``pyproject.toml`` works).
107
+ """
108
+ path = Path(path)
109
+ text = path.read_text(encoding="utf-8")
110
+ suffix = path.suffix.lower()
111
+ if suffix == ".json":
112
+ import json
113
+
114
+ data: Any = json.loads(text)
115
+ elif suffix == ".toml":
116
+ data = _load_toml(text)
117
+ else:
118
+ raise ValueError(
119
+ f"Format de configuration non supporté: '{suffix}' "
120
+ "(attendu .toml ou .json)."
121
+ )
122
+
123
+ if not isinstance(data, dict):
124
+ raise ValueError("Le fichier de configuration doit contenir une table.")
125
+ # Unwrap the conventional nesting if present.
126
+ tool = data.get("tool")
127
+ if isinstance(tool, dict) and isinstance(tool.get("sql_lineage"), dict):
128
+ data = tool["sql_lineage"]
129
+ elif isinstance(data.get("sql_lineage"), dict):
130
+ data = data["sql_lineage"]
131
+ return cls.from_mapping(data)
132
+
133
+
134
+ def _load_toml(text: str) -> dict[str, Any]:
135
+ try:
136
+ import tomllib # Python >= 3.11
137
+ except ModuleNotFoundError: # pragma: no cover - only on 3.10
138
+ try:
139
+ import tomli as tomllib # type: ignore[no-redef]
140
+ except ModuleNotFoundError as err: # pragma: no cover
141
+ raise RuntimeError(
142
+ "La lecture d'un fichier TOML requiert Python 3.11+ ou le paquet "
143
+ "'tomli' (installé automatiquement sur 3.10 via l'extra du package)."
144
+ ) from err
145
+ return tomllib.loads(text)
@@ -0,0 +1,29 @@
1
+ """BI serving layer: derive flat star-schema tables from a manifest.
2
+
3
+ The manifest JSON stays the canonical, diffable source of truth. This package
4
+ *derives* tabular tables from it (a pure, replayable step) and writes them to a
5
+ pluggable backend: CSV, SQLite, or any injected DB-API connection (SQL Server /
6
+ Fabric Warehouse).
7
+
8
+ Tables (all carry ``run_id`` so runs are appended, never overwritten):
9
+
10
+ * ``dim_run`` one row per extraction run
11
+ * ``dim_object`` one row per SQL object
12
+ * ``dim_column`` one row per output column
13
+ * ``fact_column_edge`` central fact: one row per source->target column edge
14
+ * ``object_edge`` object-level direct dependency (1 hop)
15
+ * ``closure`` transitive ancestor->descendant reachability (N hops)
16
+ * ``errors`` one row per parse error
17
+ """
18
+
19
+ from .backends import CsvExporter, SqlDbExporter, SqliteExporter
20
+ from .tables import TABLE_COLUMNS, build_export_tables, default_layer
21
+
22
+ __all__ = [
23
+ "build_export_tables",
24
+ "default_layer",
25
+ "TABLE_COLUMNS",
26
+ "CsvExporter",
27
+ "SqliteExporter",
28
+ "SqlDbExporter",
29
+ ]
@@ -0,0 +1,122 @@
1
+ """Pluggable export backends: CSV files, a SQLite file, or an injected DB-API
2
+ connection (SQL Server / Fabric Warehouse).
3
+
4
+ All backends are *append by run*: writing a run adds its rows. The database
5
+ backends first delete any existing rows for the same ``run_id`` so re-running a
6
+ given run is idempotent; the CSV backend appends (it never rewrites history).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import csv
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .tables import INT_COLUMNS, TABLE_COLUMNS
16
+
17
+
18
+ class CsvExporter:
19
+ """Write one ``<table>.csv`` per table under ``out_dir`` (append mode)."""
20
+
21
+ def __init__(self, out_dir: str | Path) -> None:
22
+ self.out_dir = Path(out_dir)
23
+
24
+ def export(self, tables: dict[str, list[dict[str, Any]]]) -> None:
25
+ self.out_dir.mkdir(parents=True, exist_ok=True)
26
+ for name, cols in TABLE_COLUMNS.items():
27
+ path = self.out_dir / f"{name}.csv"
28
+ write_header = not path.exists()
29
+ with path.open("a", newline="", encoding="utf-8") as fh:
30
+ writer = csv.DictWriter(fh, fieldnames=cols)
31
+ if write_header:
32
+ writer.writeheader()
33
+ for row in tables.get(name, []):
34
+ writer.writerow({c: row.get(c, "") for c in cols})
35
+
36
+
37
+ class _DbApiExporter:
38
+ """Shared logic for any DB-API connection using ``?`` (qmark) parameters."""
39
+
40
+ # Backends override the column type used in CREATE TABLE.
41
+ TEXT_TYPE = "TEXT"
42
+ INT_TYPE = "INTEGER"
43
+
44
+ def __init__(self, connection: Any) -> None:
45
+ self.connection = connection
46
+
47
+ def export(self, tables: dict[str, list[dict[str, Any]]]) -> None:
48
+ cursor = self.connection.cursor()
49
+ try:
50
+ for name, cols in TABLE_COLUMNS.items():
51
+ self._create_table(cursor, name, cols)
52
+ rows = tables.get(name, [])
53
+ self._delete_runs(cursor, name, rows)
54
+ self._insert_rows(cursor, name, cols, rows)
55
+ self.connection.commit()
56
+ finally:
57
+ close = getattr(cursor, "close", None)
58
+ if callable(close):
59
+ close()
60
+
61
+ # -- overridable DDL ------------------------------------------------------
62
+
63
+ def _create_table(self, cursor: Any, name: str, cols: list[str]) -> None:
64
+ defs = ", ".join(f"{c} {self._col_type(c)}" for c in cols)
65
+ cursor.execute(f"CREATE TABLE IF NOT EXISTS {name} ({defs})")
66
+
67
+ def _col_type(self, col: str) -> str:
68
+ return self.INT_TYPE if col in INT_COLUMNS else self.TEXT_TYPE
69
+
70
+ # -- data -----------------------------------------------------------------
71
+
72
+ def _delete_runs(self, cursor: Any, name: str, rows: list[dict[str, Any]]) -> None:
73
+ run_ids = {r["run_id"] for r in rows}
74
+ for run_id in run_ids:
75
+ cursor.execute(f"DELETE FROM {name} WHERE run_id = ?", (run_id,))
76
+
77
+ def _insert_rows(
78
+ self, cursor: Any, name: str, cols: list[str], rows: list[dict[str, Any]]
79
+ ) -> None:
80
+ if not rows:
81
+ return
82
+ placeholders = ", ".join("?" for _ in cols)
83
+ sql = f"INSERT INTO {name} ({', '.join(cols)}) VALUES ({placeholders})"
84
+ cursor.executemany(sql, [tuple(r.get(c) for c in cols) for r in rows])
85
+
86
+
87
+ class SqliteExporter(_DbApiExporter):
88
+ """Write to a single SQLite file (stdlib ``sqlite3``, no dependency)."""
89
+
90
+ def __init__(self, db_path: str | Path) -> None:
91
+ import sqlite3
92
+
93
+ Path(db_path).parent.mkdir(parents=True, exist_ok=True)
94
+ super().__init__(sqlite3.connect(str(db_path)))
95
+
96
+ def close(self) -> None:
97
+ self.connection.close()
98
+
99
+ def __enter__(self) -> "SqliteExporter":
100
+ return self
101
+
102
+ def __exit__(self, *exc: object) -> None:
103
+ self.close()
104
+
105
+
106
+ class SqlDbExporter(_DbApiExporter):
107
+ """Write to an injected DB-API connection (SQL Server / Fabric Warehouse).
108
+
109
+ The connection is provided by the caller (auth and driver are out of scope),
110
+ exactly like :class:`~sql_lineage.sources.LiveSQLEndpointSource`.
111
+ """
112
+
113
+ TEXT_TYPE = "NVARCHAR(4000)"
114
+ INT_TYPE = "INT"
115
+
116
+ def _create_table(self, cursor: Any, name: str, cols: list[str]) -> None:
117
+ # T-SQL has no CREATE TABLE IF NOT EXISTS before recent versions.
118
+ defs = ", ".join(f"{c} {self._col_type(c)}" for c in cols)
119
+ cursor.execute(
120
+ f"IF OBJECT_ID(N'{name}', N'U') IS NULL "
121
+ f"CREATE TABLE {name} ({defs})"
122
+ )