framework-py 0.1.0.dev0__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.
Files changed (63) hide show
  1. framework/__init__.py +250 -0
  2. framework/__main__.py +89 -0
  3. framework/_assets/hooks/pre-commit +59 -0
  4. framework/_assets/init.sql +63 -0
  5. framework/_assets/schema/layer.v1.json +605 -0
  6. framework/_assets/schema/layers.v1.json +791 -0
  7. framework/_assets/schema/spec.v1.json +604 -0
  8. framework/_log.py +29 -0
  9. framework/_paths.py +45 -0
  10. framework/_secretservice.py +69 -0
  11. framework/_time.py +11 -0
  12. framework/_wincred.py +105 -0
  13. framework/_yaml.py +90 -0
  14. framework/ai.py +204 -0
  15. framework/bootstrap.py +206 -0
  16. framework/cache.py +335 -0
  17. framework/catalog.py +193 -0
  18. framework/cloud.py +899 -0
  19. framework/config_home.py +194 -0
  20. framework/connections.py +442 -0
  21. framework/console.py +41 -0
  22. framework/create.py +579 -0
  23. framework/data.py +519 -0
  24. framework/db.py +222 -0
  25. framework/dotenv.py +79 -0
  26. framework/errors.py +39 -0
  27. framework/global_config.py +238 -0
  28. framework/hooks.py +364 -0
  29. framework/integrity.py +22 -0
  30. framework/notebooks.py +384 -0
  31. framework/outputs.py +324 -0
  32. framework/packages.py +276 -0
  33. framework/project.py +52 -0
  34. framework/py.typed +0 -0
  35. framework/quarto.py +163 -0
  36. framework/scaffold.py +269 -0
  37. framework/scratch.py +108 -0
  38. framework/security.py +646 -0
  39. framework/settings.py +439 -0
  40. framework/spec/__init__.py +0 -0
  41. framework/spec/check.py +211 -0
  42. framework/spec/merge.py +195 -0
  43. framework/spec/resolve.py +254 -0
  44. framework/storage.py +516 -0
  45. framework/templates/defaults.yml +144 -0
  46. framework/templates/gitignore/course +120 -0
  47. framework/templates/gitignore/presentation +114 -0
  48. framework/templates/gitignore/project +108 -0
  49. framework/templates/gitignore/sensitive +139 -0
  50. framework/templates/skills/framework-data/SKILL.md +47 -0
  51. framework/templates/skills/framework-outputs/SKILL.md +68 -0
  52. framework/templates/skills/framework-packages/SKILL.md +33 -0
  53. framework/templates/skills/framework-sensitive-data/SKILL.md +38 -0
  54. framework/templates/skills/framework-workflow/SKILL.md +58 -0
  55. framework/templates/stubs/notebook-default.ipynb +34 -0
  56. framework/templates/stubs/notebook-default.qmd +43 -0
  57. framework/templates/stubs/notebook-minimal.qmd +15 -0
  58. framework/templates/stubs/notebook-revealjs.qmd +34 -0
  59. framework/templates/stubs/script-default.py +18 -0
  60. framework_py-0.1.0.dev0.dist-info/METADATA +111 -0
  61. framework_py-0.1.0.dev0.dist-info/RECORD +63 -0
  62. framework_py-0.1.0.dev0.dist-info/WHEEL +4 -0
  63. framework_py-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,194 @@
1
+ """The global config folder and syncing it with the R package's folder.
2
+
3
+ Python and the ``fw`` CLI share one folder. The R package keeps its own,
4
+ because CRAN does not allow a package to write outside
5
+ ``tools::R_user_dir()``. ``config_sync()`` copies settings and saved logins
6
+ from R's folder when someone uses both languages.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import shutil
13
+ import sys
14
+ from dataclasses import dataclass, field
15
+ from datetime import date
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import pandas as pd
20
+
21
+ from framework import _yaml
22
+
23
+ # Files config_sync() copies. Token files hold a secret and are written 0600.
24
+ _SYNC_FILES = ("settings.yml", "cloud.yml")
25
+ _TOKEN_PREFIX = "cloud-token"
26
+
27
+
28
+ def config_dir() -> Path:
29
+ """The global Framework config folder for Python and ``fw``.
30
+
31
+ ``FW_CONFIG_HOME`` when set, otherwise ``$XDG_CONFIG_HOME/framework``,
32
+ otherwise ``~/.config/framework`` (on Windows too). The folder is not
33
+ created until Framework writes to it.
34
+ """
35
+ custom = os.environ.get("FW_CONFIG_HOME")
36
+ if custom:
37
+ return Path(custom).expanduser()
38
+ xdg = os.environ.get("XDG_CONFIG_HOME")
39
+ base = Path(xdg).expanduser() if xdg else Path.home() / ".config"
40
+ return base / "framework"
41
+
42
+
43
+ def r_config_dir() -> Path:
44
+ """The R package's config folder, ``tools::R_user_dir("framework", "config")``.
45
+
46
+ This ignores ``FW_CONFIG_HOME``, which points both tools at the same
47
+ folder when it is set.
48
+ """
49
+ custom = os.environ.get("R_USER_CONFIG_DIR") or os.environ.get("XDG_CONFIG_HOME")
50
+ if custom:
51
+ base = Path(custom).expanduser()
52
+ elif sys.platform == "win32":
53
+ base = Path(os.environ.get("APPDATA", str(Path.home()))) / "R" / "config"
54
+ elif sys.platform == "darwin":
55
+ base = Path.home() / "Library" / "Preferences" / "org.R-project.R"
56
+ else:
57
+ base = Path.home() / ".config"
58
+ return base / "R" / "framework"
59
+
60
+
61
+ def ensure_config_dir() -> Path:
62
+ """Create the config folder if needed and return it."""
63
+ folder = config_dir()
64
+ folder.mkdir(parents=True, exist_ok=True)
65
+ return folder
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class SyncResult:
70
+ """What ``config_sync()`` copied, or would copy with ``dry_run=True``."""
71
+
72
+ source: Path
73
+ target: Path
74
+ copied: list[str] = field(default_factory=list)
75
+ skipped: list[str] = field(default_factory=list)
76
+ dry_run: bool = False
77
+
78
+ def __str__(self) -> str:
79
+ verb = "Would copy" if self.dry_run else "Copied"
80
+ if not self.copied:
81
+ return f"Nothing to copy from {self.source} (Python's folder is up to date)."
82
+ return f"{verb} {', '.join(self.copied)} from {self.source} to {self.target}."
83
+
84
+
85
+ def _syncable(path: Path) -> bool:
86
+ name = path.name
87
+ if name.startswith(".") or not path.is_file():
88
+ return False
89
+ return name in _SYNC_FILES or name.startswith(_TOKEN_PREFIX)
90
+
91
+
92
+ def config_sync(from_dir: str | Path | None = None, *, dry_run: bool = False) -> SyncResult:
93
+ """Copy settings and saved logins from the R package's config folder.
94
+
95
+ A file is copied when Python's folder does not have it or R's copy is
96
+ newer. The files are ``settings.yml``, ``cloud.yml``, and the
97
+ ``cloud-token*`` files used when no system keyring is available.
98
+ Modification times are kept, token files are made readable only by you,
99
+ and nothing is written to R's folder.
100
+
101
+ Args:
102
+ from_dir: The folder to copy from. Default: ``r_config_dir()``.
103
+ dry_run: Report what would be copied without copying.
104
+ """
105
+ source = Path(from_dir).expanduser() if from_dir is not None else r_config_dir()
106
+ target = config_dir()
107
+ copied: list[str] = []
108
+ skipped: list[str] = []
109
+
110
+ if source.resolve() == target.resolve() or not source.is_dir():
111
+ return SyncResult(source, target, copied, skipped, dry_run)
112
+
113
+ for path in sorted(source.iterdir()):
114
+ if not _syncable(path):
115
+ continue
116
+ destination = target / path.name
117
+ if destination.exists() and destination.stat().st_mtime >= path.stat().st_mtime:
118
+ skipped.append(path.name)
119
+ continue
120
+ copied.append(path.name)
121
+ if dry_run:
122
+ continue
123
+ target.mkdir(parents=True, exist_ok=True)
124
+ shutil.copy2(path, destination)
125
+ if path.name.startswith(_TOKEN_PREFIX):
126
+ destination.chmod(0o600)
127
+
128
+ return SyncResult(source, target, copied, skipped, dry_run)
129
+
130
+
131
+ # The project registry -----------------------------------------------------------
132
+
133
+
134
+ def _global_settings_path() -> Path:
135
+ return config_dir() / "settings.yml"
136
+
137
+
138
+ def global_settings() -> dict[str, Any]:
139
+ """The user's global settings (``settings.yml`` in the config folder), or ``{}``."""
140
+ path = _global_settings_path()
141
+ if not path.is_file():
142
+ return {}
143
+ data = _yaml.read(path)
144
+ return data if isinstance(data, dict) else {}
145
+
146
+
147
+ def _save_global_settings(settings: dict[str, Any]) -> None:
148
+ ensure_config_dir()
149
+ _yaml.write(_global_settings_path(), settings)
150
+
151
+
152
+ def project_register(path: str | Path) -> int:
153
+ """Add a project to the registry in the global settings. Returns its id."""
154
+ settings = global_settings()
155
+ projects = settings.get("projects") or []
156
+ resolved = str(Path(path).expanduser().resolve())
157
+ for project in projects:
158
+ if project.get("path") == resolved:
159
+ return int(project.get("id", 0))
160
+ next_id = max((int(p.get("id", 0)) for p in projects), default=0) + 1
161
+ projects.append({"id": next_id, "path": resolved, "created": f"{date.today():%Y-%m-%d}"})
162
+ settings["projects"] = projects
163
+ _save_global_settings(settings)
164
+ return next_id
165
+
166
+
167
+ def project_unregister(project: int | str | Path) -> bool:
168
+ """Remove a project from the registry by id or path. Returns whether it was there."""
169
+ settings = global_settings()
170
+ projects = settings.get("projects") or []
171
+ if isinstance(project, int):
172
+ kept = [p for p in projects if int(p.get("id", 0)) != project]
173
+ else:
174
+ resolved = str(Path(project).expanduser().resolve())
175
+ kept = [p for p in projects if p.get("path") != resolved]
176
+ if len(kept) == len(projects):
177
+ return False
178
+ settings["projects"] = kept
179
+ _save_global_settings(settings)
180
+ return True
181
+
182
+
183
+ def project_list() -> pd.DataFrame:
184
+ """Registered projects, with columns id, path, and created."""
185
+ projects = global_settings().get("projects") or []
186
+ rows = [
187
+ {
188
+ "id": int(p.get("id", 0)),
189
+ "path": str(p.get("path", "")),
190
+ "created": str(p.get("created", "")),
191
+ }
192
+ for p in projects
193
+ ]
194
+ return pd.DataFrame(rows, columns=["id", "path", "created"])
@@ -0,0 +1,442 @@
1
+ """Database connections defined in the project settings.
2
+
3
+ Connections live under ``connections`` in the settings (usually
4
+ ``settings/connections.yml``)::
5
+
6
+ connections:
7
+ default_database: framework
8
+ databases:
9
+ framework: {driver: sqlite, database: framework.db}
10
+ warehouse:
11
+ driver: postgres
12
+ host: env("POSTGRES_HOST")
13
+ port: 5432
14
+ database: analytics
15
+ user: env("POSTGRES_USER")
16
+ password: env("POSTGRES_PASSWORD")
17
+
18
+ Credentials come from ``.env`` through ``env()``, so they never sit in the
19
+ settings. Drivers: sqlite (built in), postgres (``postgres`` extra), mysql or
20
+ mariadb (``mysql``), duckdb (``duckdb``), and sqlserver (``sqlserver``).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ import sqlite3
27
+ from collections.abc import Iterator, Mapping, Sequence
28
+ from contextlib import contextmanager
29
+ from importlib import import_module, metadata
30
+ from types import TracebackType
31
+ from typing import Any
32
+
33
+ import pandas as pd
34
+
35
+ from framework._paths import in_root
36
+ from framework.errors import FrameworkError
37
+ from framework.project import project_root
38
+ from framework.settings import settings_read
39
+
40
+ _HOST = re.compile(r"^[a-zA-Z0-9.-]+$")
41
+
42
+ # Driver name -> (canonical driver, Python module, extra)
43
+ DRIVERS: dict[str, tuple[str, str, str | None]] = {
44
+ "sqlite": ("sqlite", "sqlite3", None),
45
+ "postgres": ("postgres", "psycopg", "postgres"),
46
+ "postgresql": ("postgres", "psycopg", "postgres"),
47
+ "mysql": ("mysql", "pymysql", "mysql"),
48
+ "mariadb": ("mysql", "pymysql", "mysql"),
49
+ "duckdb": ("duckdb", "duckdb", "duckdb"),
50
+ "sqlserver": ("sqlserver", "pyodbc", "sqlserver"),
51
+ "mssql": ("sqlserver", "pyodbc", "sqlserver"),
52
+ }
53
+ _DISTRIBUTIONS = {
54
+ "sqlite3": None,
55
+ "psycopg": "psycopg",
56
+ "pymysql": "PyMySQL",
57
+ "duckdb": "duckdb",
58
+ "pyodbc": "pyodbc",
59
+ }
60
+
61
+
62
+ # Finding connections ------------------------------------------------------------
63
+
64
+
65
+ def _section(config: Mapping[str, Any], key: str) -> dict[str, Any]:
66
+ value = config.get(key)
67
+ return value if isinstance(value, dict) else {}
68
+
69
+
70
+ def database_configs(config: Mapping[str, Any] | None = None) -> dict[str, dict[str, Any]]:
71
+ """Every database connection in the settings, by name.
72
+
73
+ Looked up in ``connections.databases``, then in ``connections`` itself
74
+ (the older flat form), then in a top-level ``databases`` section, where
75
+ projects created by R before 2.0 have it.
76
+ """
77
+ config = config if config is not None else settings_read()
78
+ connections = _section(config, "connections")
79
+ found: dict[str, dict[str, Any]] = {}
80
+ for source in (
81
+ _section(config, "databases"),
82
+ {k: v for k, v in connections.items() if isinstance(v, dict) and "driver" in v},
83
+ _section(connections, "databases"),
84
+ ):
85
+ found.update(
86
+ {name: dict(entry) for name, entry in source.items() if isinstance(entry, dict)}
87
+ )
88
+ return found
89
+
90
+
91
+ def default_database(config: Mapping[str, Any] | None = None) -> str | None:
92
+ config = config if config is not None else settings_read()
93
+ value = _section(config, "connections").get("default_database") or config.get(
94
+ "default_database"
95
+ )
96
+ return str(value) if value else None
97
+
98
+
99
+ def _database_config(name: str) -> dict[str, Any]:
100
+ configs = database_configs()
101
+ if name not in configs:
102
+ available = ", ".join(configs) or "none"
103
+ raise FrameworkError(
104
+ f"No connection configuration found for '{name}' (available: {available})"
105
+ )
106
+ entry = configs[name]
107
+ if not entry.get("driver"):
108
+ raise FrameworkError(f"No driver specified for connection '{name}'")
109
+ return entry
110
+
111
+
112
+ # Connecting -----------------------------------------------------------------------
113
+
114
+
115
+ def _require(module: str, extra: str | None, what: str) -> Any:
116
+ try:
117
+ return import_module(module)
118
+ except ImportError as error:
119
+ hint = f" Install it with: pip install framework-py[{extra}]" if extra else ""
120
+ raise FrameworkError(f"{what} connections need the {module} package.{hint}") from error
121
+
122
+
123
+ def _missing(config: Mapping[str, Any], fields: Sequence[str], what: str) -> None:
124
+ missing = [field for field in fields if config.get(field) in (None, "")]
125
+ if missing:
126
+ raise FrameworkError(f"{what} configuration missing required fields: {', '.join(missing)}")
127
+
128
+
129
+ def _port(value: Any, default: int | None = None) -> int:
130
+ if value in (None, "") and default is not None:
131
+ return default
132
+ try:
133
+ port = int(str(value).strip())
134
+ except ValueError:
135
+ port = 0
136
+ if not 1 <= port <= 65535:
137
+ raise FrameworkError(f"Invalid port number: {value}")
138
+ return port
139
+
140
+
141
+ def _host(value: Any) -> str:
142
+ host = str(value)
143
+ if not _HOST.match(host):
144
+ raise FrameworkError(f"Invalid host name: {host}")
145
+ return host
146
+
147
+
148
+ def _file_path(config: Mapping[str, Any], what: str) -> str:
149
+ if not config.get("database"):
150
+ raise FrameworkError(f"{what} configuration must include 'database' path")
151
+ path = in_root(str(config["database"]), project_root())
152
+ if not path.parent.is_dir():
153
+ raise FrameworkError(f"Database directory does not exist: {path.parent}")
154
+ return str(path)
155
+
156
+
157
+ def _connect_sqlite(config: Mapping[str, Any]) -> Any:
158
+ return sqlite3.connect(_file_path(config, "SQLite"))
159
+
160
+
161
+ def _connect_postgres(config: Mapping[str, Any]) -> Any:
162
+ psycopg = _require("psycopg", "postgres", "PostgreSQL")
163
+ _missing(config, ("host", "port", "database", "user", "password"), "PostgreSQL")
164
+ host, port = _host(config["host"]), _port(config["port"])
165
+ connection = psycopg.connect(
166
+ host=host,
167
+ port=port,
168
+ dbname=config["database"],
169
+ user=config["user"],
170
+ password=config["password"],
171
+ connect_timeout=10,
172
+ )
173
+ schema = config.get("schema") or "public"
174
+ sql = import_module("psycopg.sql")
175
+ with connection.cursor() as cursor:
176
+ cursor.execute(sql.SQL("SET search_path TO {}").format(sql.Identifier(schema)))
177
+ connection.commit()
178
+ return connection
179
+
180
+
181
+ def _connect_mysql(config: Mapping[str, Any]) -> Any:
182
+ pymysql = _require("pymysql", "mysql", "MySQL/MariaDB")
183
+ _missing(config, ("host", "port", "database", "user", "password"), "MySQL/MariaDB")
184
+ host, port = _host(config["host"]), _port(config["port"])
185
+ return pymysql.connect(
186
+ host=host,
187
+ port=port,
188
+ database=config["database"],
189
+ user=config["user"],
190
+ password=config["password"],
191
+ connect_timeout=10,
192
+ )
193
+
194
+
195
+ def _connect_duckdb(config: Mapping[str, Any]) -> Any:
196
+ duckdb = _require("duckdb", "duckdb", "DuckDB")
197
+ options = {
198
+ key: config[key] for key in ("memory_limit", "threads") if config.get(key) is not None
199
+ }
200
+ return duckdb.connect(
201
+ _file_path(config, "DuckDB"), read_only=bool(config.get("read_only")), config=options
202
+ )
203
+
204
+
205
+ def _odbc_value(value: Any) -> str:
206
+ """An ODBC connection string value, braced so ';' and '}' cannot break out of it."""
207
+ return "{" + str(value).replace("}", "}}") + "}"
208
+
209
+
210
+ def _connect_sqlserver(config: Mapping[str, Any]) -> Any:
211
+ pyodbc = _require("pyodbc", "sqlserver", "SQL Server")
212
+ _missing(config, ("host", "database", "user", "password"), "SQL Server")
213
+ port = _port(config.get("port"), default=1433)
214
+ host = _host(config["host"])
215
+ server = host if port == 1433 else f"{host},{port}"
216
+ parts = {
217
+ "DRIVER": config.get("odbc_driver") or "ODBC Driver 17 for SQL Server",
218
+ "SERVER": server,
219
+ "DATABASE": config["database"],
220
+ "UID": config["user"],
221
+ "PWD": config["password"],
222
+ # As in R. ODBC Driver 18 otherwise refuses servers with self-signed
223
+ # certificates, which local and test servers have.
224
+ "TrustServerCertificate": "yes"
225
+ if config.get("trust_server_certificate", True) is not False
226
+ else "no",
227
+ }
228
+ text = ";".join(f"{key}={_odbc_value(value)}" for key, value in parts.items())
229
+ return pyodbc.connect(text, timeout=10)
230
+
231
+
232
+ _CONNECTORS = {
233
+ "sqlite": _connect_sqlite,
234
+ "postgres": _connect_postgres,
235
+ "mysql": _connect_mysql,
236
+ "duckdb": _connect_duckdb,
237
+ "sqlserver": _connect_sqlserver,
238
+ }
239
+
240
+
241
+ class Connection:
242
+ """A DB-API connection from ``db_connect()``.
243
+
244
+ It behaves like the driver's own connection (``cursor()``, ``commit()``,
245
+ and so on) and closes when a ``with`` block ends::
246
+
247
+ with fw.db_connect("warehouse") as conn:
248
+ cursor = conn.cursor()
249
+
250
+ Attributes:
251
+ name: The connection name in the settings.
252
+ driver: sqlite, postgres, mysql, duckdb, or sqlserver.
253
+ raw: The driver's connection object.
254
+ """
255
+
256
+ def __init__(self, name: str, driver: str, raw: Any) -> None:
257
+ self.name = name
258
+ self.driver = driver
259
+ self.raw = raw
260
+ self._in_transaction = False
261
+
262
+ def __getattr__(self, attribute: str) -> Any:
263
+ return getattr(self.raw, attribute)
264
+
265
+ def __enter__(self) -> Connection:
266
+ return self
267
+
268
+ def __exit__(
269
+ self,
270
+ kind: type[BaseException] | None,
271
+ error: BaseException | None,
272
+ traceback: TracebackType | None,
273
+ ) -> None:
274
+ self.raw.close()
275
+
276
+ def __repr__(self) -> str:
277
+ return f"<framework Connection {self.name!r} ({self.driver})>"
278
+
279
+
280
+ def db_connect(name: str) -> Connection:
281
+ """Open a database connection defined in the settings.
282
+
283
+ Raises:
284
+ FrameworkError: The connection is not configured, its driver package
285
+ is not installed, or connecting failed.
286
+ """
287
+ entry = _database_config(name)
288
+ driver_name = str(entry["driver"]).lower()
289
+ if driver_name not in DRIVERS:
290
+ raise FrameworkError(f"Unsupported database driver for '{name}': {entry['driver']}")
291
+ driver = DRIVERS[driver_name][0]
292
+ try:
293
+ raw = _CONNECTORS[driver](entry)
294
+ except FrameworkError:
295
+ raise
296
+ except Exception as error:
297
+ raise FrameworkError(f"Failed to connect to '{name}': {error}") from error
298
+ return Connection(name, driver, raw)
299
+
300
+
301
+ # Queries ------------------------------------------------------------------------------
302
+
303
+
304
+ def _frame(cursor: Any) -> pd.DataFrame:
305
+ columns = [column[0] for column in cursor.description or []]
306
+ return pd.DataFrame.from_records([tuple(row) for row in cursor.fetchall()], columns=columns)
307
+
308
+
309
+ def db_query(
310
+ query: str, connection_name: str, params: Sequence[Any] | Mapping[str, Any] | None = None
311
+ ) -> pd.DataFrame:
312
+ """Run a query and return the rows as a DataFrame.
313
+
314
+ Pass values in ``params``, never by formatting them into the SQL. The
315
+ placeholder style is the driver's: ``?`` for sqlite, duckdb, and
316
+ sqlserver, and ``%s`` for postgres and mysql.
317
+ """
318
+ with db_connect(connection_name) as connection:
319
+ cursor = connection.cursor()
320
+ try:
321
+ cursor.execute(query, params) if params is not None else cursor.execute(query)
322
+ return _frame(cursor)
323
+ except Exception as error:
324
+ raise FrameworkError(
325
+ f"Failed to execute query on connection '{connection_name}': {error}"
326
+ ) from error
327
+ finally:
328
+ cursor.close()
329
+
330
+
331
+ def db_execute(
332
+ query: str, connection_name: str, params: Sequence[Any] | Mapping[str, Any] | None = None
333
+ ) -> int:
334
+ """Run a statement, commit it, and return the number of rows affected."""
335
+ with db_connect(connection_name) as connection:
336
+ cursor = connection.cursor()
337
+ try:
338
+ cursor.execute(query, params) if params is not None else cursor.execute(query)
339
+ count = cursor.rowcount
340
+ connection.commit()
341
+ return int(count if count is not None else -1)
342
+ except Exception as error:
343
+ raise FrameworkError(
344
+ f"Failed to execute statement on connection '{connection_name}': {error}"
345
+ ) from error
346
+ finally:
347
+ cursor.close()
348
+
349
+
350
+ @contextmanager
351
+ def db_transaction(connection: Connection) -> Iterator[Connection]:
352
+ """Commit the block's statements together, or roll all of them back on an error.
353
+
354
+ A transaction inside another reuses the outer one::
355
+
356
+ with fw.db_connect("warehouse") as conn, fw.db_transaction(conn):
357
+ conn.cursor().execute("UPDATE ...")
358
+ """
359
+ if connection._in_transaction:
360
+ yield connection
361
+ return
362
+ connection._in_transaction = True
363
+ try:
364
+ yield connection
365
+ connection.commit()
366
+ except Exception as error:
367
+ try:
368
+ connection.rollback()
369
+ except Exception as rollback_error: # pragma: no cover (driver failure)
370
+ raise FrameworkError(f"Failed to roll back transaction: {rollback_error}") from error
371
+ raise FrameworkError(f"Transaction failed and was rolled back: {error}") from error
372
+ finally:
373
+ connection._in_transaction = False
374
+
375
+
376
+ # Listing ------------------------------------------------------------------------------
377
+
378
+
379
+ def db_list() -> pd.DataFrame:
380
+ """Database connections in the settings: name, driver, host, port, and database."""
381
+ rows = [
382
+ {
383
+ "name": name,
384
+ "driver": entry.get("driver"),
385
+ "host": entry.get("host"),
386
+ "port": entry.get("port"),
387
+ "database": entry.get("database") or entry.get("dbname"),
388
+ }
389
+ for name, entry in database_configs().items()
390
+ ]
391
+ return pd.DataFrame(rows, columns=["name", "driver", "host", "port", "database"])
392
+
393
+
394
+ def connections_list() -> pd.DataFrame:
395
+ """Every connection: databases and storage buckets, with the defaults marked."""
396
+ from framework.storage import bucket_configs, default_bucket
397
+
398
+ config = settings_read()
399
+ rows = [
400
+ {
401
+ "kind": "database",
402
+ "name": name,
403
+ "driver": entry.get("driver"),
404
+ "location": entry.get("host") or entry.get("database"),
405
+ "default": name == default_database(config),
406
+ }
407
+ for name, entry in database_configs(config).items()
408
+ ]
409
+ rows += [
410
+ {
411
+ "kind": "storage",
412
+ "name": name,
413
+ "driver": entry.get("provider") or entry.get("driver") or "s3",
414
+ "location": entry.get("bucket"),
415
+ "default": name == default_bucket(config),
416
+ }
417
+ for name, entry in bucket_configs(config).items()
418
+ ]
419
+ return pd.DataFrame(rows, columns=["kind", "name", "driver", "location", "default"])
420
+
421
+
422
+ def db_drivers_status() -> pd.DataFrame:
423
+ """Which database drivers are installed, and the extra that installs each one."""
424
+ rows = []
425
+ for module in dict.fromkeys(entry[1] for entry in DRIVERS.values()):
426
+ drivers = sorted({name for name, entry in DRIVERS.items() if entry[1] == module})
427
+ extra = next(entry[2] for entry in DRIVERS.values() if entry[1] == module)
428
+ distribution = _DISTRIBUTIONS[module]
429
+ try:
430
+ version = metadata.version(distribution) if distribution else sqlite3.sqlite_version
431
+ except metadata.PackageNotFoundError:
432
+ version = None
433
+ rows.append(
434
+ {
435
+ "drivers": ", ".join(drivers),
436
+ "package": distribution or "sqlite3 (built in)",
437
+ "installed": version is not None,
438
+ "version": version,
439
+ "install": f"pip install framework-py[{extra}]" if extra else "",
440
+ }
441
+ )
442
+ return pd.DataFrame(rows, columns=["drivers", "package", "installed", "version", "install"])
framework/console.py ADDED
@@ -0,0 +1,41 @@
1
+ """Capturing printed output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ from collections.abc import Iterator
7
+ from contextlib import contextmanager, redirect_stderr, redirect_stdout
8
+ from dataclasses import dataclass, field
9
+
10
+
11
+ @dataclass
12
+ class CapturedOutput:
13
+ """Text printed inside a ``capture_output()`` block, stdout and stderr together."""
14
+
15
+ output: str = ""
16
+ _buffer: io.StringIO = field(default_factory=io.StringIO, repr=False)
17
+
18
+ @property
19
+ def lines(self) -> list[str]:
20
+ return self.output.splitlines()
21
+
22
+
23
+ @contextmanager
24
+ def capture_output() -> Iterator[CapturedOutput]:
25
+ """Capture everything printed to stdout and stderr inside the block.
26
+
27
+ ::
28
+
29
+ with fw.capture_output() as captured:
30
+ model.summary()
31
+ captured.lines
32
+
33
+ Exceptions are not caught: the output printed before one is still
34
+ available in ``captured.output``.
35
+ """
36
+ captured = CapturedOutput()
37
+ try:
38
+ with redirect_stdout(captured._buffer), redirect_stderr(captured._buffer):
39
+ yield captured
40
+ finally:
41
+ captured.output = captured._buffer.getvalue()