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.
- framework/__init__.py +250 -0
- framework/__main__.py +89 -0
- framework/_assets/hooks/pre-commit +59 -0
- framework/_assets/init.sql +63 -0
- framework/_assets/schema/layer.v1.json +605 -0
- framework/_assets/schema/layers.v1.json +791 -0
- framework/_assets/schema/spec.v1.json +604 -0
- framework/_log.py +29 -0
- framework/_paths.py +45 -0
- framework/_secretservice.py +69 -0
- framework/_time.py +11 -0
- framework/_wincred.py +105 -0
- framework/_yaml.py +90 -0
- framework/ai.py +204 -0
- framework/bootstrap.py +206 -0
- framework/cache.py +335 -0
- framework/catalog.py +193 -0
- framework/cloud.py +899 -0
- framework/config_home.py +194 -0
- framework/connections.py +442 -0
- framework/console.py +41 -0
- framework/create.py +579 -0
- framework/data.py +519 -0
- framework/db.py +222 -0
- framework/dotenv.py +79 -0
- framework/errors.py +39 -0
- framework/global_config.py +238 -0
- framework/hooks.py +364 -0
- framework/integrity.py +22 -0
- framework/notebooks.py +384 -0
- framework/outputs.py +324 -0
- framework/packages.py +276 -0
- framework/project.py +52 -0
- framework/py.typed +0 -0
- framework/quarto.py +163 -0
- framework/scaffold.py +269 -0
- framework/scratch.py +108 -0
- framework/security.py +646 -0
- framework/settings.py +439 -0
- framework/spec/__init__.py +0 -0
- framework/spec/check.py +211 -0
- framework/spec/merge.py +195 -0
- framework/spec/resolve.py +254 -0
- framework/storage.py +516 -0
- framework/templates/defaults.yml +144 -0
- framework/templates/gitignore/course +120 -0
- framework/templates/gitignore/presentation +114 -0
- framework/templates/gitignore/project +108 -0
- framework/templates/gitignore/sensitive +139 -0
- framework/templates/skills/framework-data/SKILL.md +47 -0
- framework/templates/skills/framework-outputs/SKILL.md +68 -0
- framework/templates/skills/framework-packages/SKILL.md +33 -0
- framework/templates/skills/framework-sensitive-data/SKILL.md +38 -0
- framework/templates/skills/framework-workflow/SKILL.md +58 -0
- framework/templates/stubs/notebook-default.ipynb +34 -0
- framework/templates/stubs/notebook-default.qmd +43 -0
- framework/templates/stubs/notebook-minimal.qmd +15 -0
- framework/templates/stubs/notebook-revealjs.qmd +34 -0
- framework/templates/stubs/script-default.py +18 -0
- framework_py-0.1.0.dev0.dist-info/METADATA +111 -0
- framework_py-0.1.0.dev0.dist-info/RECORD +63 -0
- framework_py-0.1.0.dev0.dist-info/WHEEL +4 -0
- framework_py-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
framework/config_home.py
ADDED
|
@@ -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"])
|
framework/connections.py
ADDED
|
@@ -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()
|