interloper-sql 0.110.2__tar.gz → 0.111.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,24 +1,31 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: interloper-sql
3
- Version: 0.110.2
4
- Summary: Interloper SQL integration: a SQLAlchemy-backed destination for PostgreSQL and other SQL databases
3
+ Version: 0.111.0
4
+ Summary: Interloper SQL integration: portable views over SQL warehouses, and a SQLAlchemy-backed destination for PostgreSQL and other SQL databases
5
5
  Author: Guillaume Onfroy
6
6
  Author-email: Guillaume Onfroy <guillaume@digitlcloud.com>
7
+ Requires-Dist: ibis-framework>=12,<13
7
8
  Requires-Dist: interloper-core
8
- Requires-Dist: psycopg[binary]>=3.1
9
- Requires-Dist: sqlalchemy>=2.0
9
+ Requires-Dist: psycopg[binary]>=3.1 ; extra == 'postgres'
10
+ Requires-Dist: sqlalchemy>=2.0 ; extra == 'postgres'
10
11
  Requires-Python: >=3.10
12
+ Provides-Extra: postgres
11
13
  Description-Content-Type: text/markdown
12
14
 
13
15
  # interloper-sql
14
16
 
15
- A SQL database destination for interloper: `SQLDestination`, a
16
- `DatabaseDestination` over SQLAlchemy Core, and the `SQLConnection` that holds
17
- the database URL.
17
+ SQL for interloper: `ibis_view`, portable views authored as Ibis expressions,
18
+ and behind the `postgres` extra a SQL database destination, `SQLDestination`,
19
+ a `DatabaseDestination` over SQLAlchemy Core with the `SQLConnection` that
20
+ holds the database URL.
21
+
22
+ The base package depends on Ibis only, so a package that defines `ibis_view`s
23
+ pulls in no database driver. `interloper-sql[postgres]` adds SQLAlchemy and
24
+ `psycopg[binary]`; the destination and its connection exist only with it.
18
25
 
19
26
  One backend serves every SQLAlchemy dialect whose driver is installed.
20
- PostgreSQL works out of the box (the package depends on `psycopg[binary]`);
21
- MySQL, SQLite, Redshift, SQL Server and the rest need their own driver.
27
+ PostgreSQL works with the extra; MySQL, SQLite, Redshift, SQL Server and the
28
+ rest need their own driver on top.
22
29
 
23
30
  ## Setup
24
31
 
@@ -26,7 +33,7 @@ The connection is a single SQLAlchemy URL:
26
33
 
27
34
  | Database | URL | Driver to install |
28
35
  |----------|-----|-------------------|
29
- | PostgreSQL | `postgresql+psycopg://user:password@host:5432/db` | none, ships with the package |
36
+ | PostgreSQL | `postgresql+psycopg://user:password@host:5432/db` | `interloper-sql[postgres]` |
30
37
  | MySQL / MariaDB | `mysql+pymysql://user:password@host:3306/db` | `pymysql` |
31
38
  | SQL Server | `mssql+pyodbc://user:password@host/db?driver=ODBC+Driver+18+for+SQL+Server` | `pyodbc` (and the ODBC driver) |
32
39
  | Redshift | `redshift+redshift_connector://user:password@host:5439/db` | `sqlalchemy-redshift`, `redshift_connector` |
@@ -1,12 +1,17 @@
1
1
  # interloper-sql
2
2
 
3
- A SQL database destination for interloper: `SQLDestination`, a
4
- `DatabaseDestination` over SQLAlchemy Core, and the `SQLConnection` that holds
5
- the database URL.
3
+ SQL for interloper: `ibis_view`, portable views authored as Ibis expressions,
4
+ and behind the `postgres` extra a SQL database destination, `SQLDestination`,
5
+ a `DatabaseDestination` over SQLAlchemy Core with the `SQLConnection` that
6
+ holds the database URL.
7
+
8
+ The base package depends on Ibis only, so a package that defines `ibis_view`s
9
+ pulls in no database driver. `interloper-sql[postgres]` adds SQLAlchemy and
10
+ `psycopg[binary]`; the destination and its connection exist only with it.
6
11
 
7
12
  One backend serves every SQLAlchemy dialect whose driver is installed.
8
- PostgreSQL works out of the box (the package depends on `psycopg[binary]`);
9
- MySQL, SQLite, Redshift, SQL Server and the rest need their own driver.
13
+ PostgreSQL works with the extra; MySQL, SQLite, Redshift, SQL Server and the
14
+ rest need their own driver on top.
10
15
 
11
16
  ## Setup
12
17
 
@@ -14,7 +19,7 @@ The connection is a single SQLAlchemy URL:
14
19
 
15
20
  | Database | URL | Driver to install |
16
21
  |----------|-----|-------------------|
17
- | PostgreSQL | `postgresql+psycopg://user:password@host:5432/db` | none, ships with the package |
22
+ | PostgreSQL | `postgresql+psycopg://user:password@host:5432/db` | `interloper-sql[postgres]` |
18
23
  | MySQL / MariaDB | `mysql+pymysql://user:password@host:3306/db` | `pymysql` |
19
24
  | SQL Server | `mssql+pyodbc://user:password@host/db?driver=ODBC+Driver+18+for+SQL+Server` | `pyodbc` (and the ODBC driver) |
20
25
  | Redshift | `redshift+redshift_connector://user:password@host:5439/db` | `sqlalchemy-redshift`, `redshift_connector` |
@@ -1,19 +1,24 @@
1
1
  [project]
2
2
  name = "interloper-sql"
3
- version = "0.110.2"
4
- description = "Interloper SQL integration: a SQLAlchemy-backed destination for PostgreSQL and other SQL databases"
3
+ version = "0.111.0"
4
+ description = "Interloper SQL integration: portable views over SQL warehouses, and a SQLAlchemy-backed destination for PostgreSQL and other SQL databases"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  dependencies = [
8
+ "ibis-framework>=12,<13",
8
9
  "interloper-core",
9
- "psycopg[binary]>=3.1",
10
- "sqlalchemy>=2.0",
11
10
  ]
12
11
 
13
12
  [[project.authors]]
14
13
  name = "Guillaume Onfroy"
15
14
  email = "guillaume@digitlcloud.com"
16
15
 
16
+ [project.optional-dependencies]
17
+ postgres = [
18
+ "psycopg[binary]>=3.1",
19
+ "sqlalchemy>=2.0",
20
+ ]
21
+
17
22
  [project.entry-points."interloper.components"]
18
23
  sql = "interloper_sql"
19
24
 
@@ -3,17 +3,19 @@
3
3
  # ###############
4
4
  [project]
5
5
  name = "interloper-sql"
6
- version = "0.110.2"
7
- description = "Interloper SQL integration: a SQLAlchemy-backed destination for PostgreSQL and other SQL databases"
6
+ version = "0.111.0"
7
+ description = "Interloper SQL integration: portable views over SQL warehouses, and a SQLAlchemy-backed destination for PostgreSQL and other SQL databases"
8
8
  readme = "README.md"
9
9
  authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
10
10
  requires-python = ">=3.10"
11
11
  dependencies = [
12
+ "ibis-framework>=12,<13",
12
13
  "interloper-core",
13
- "psycopg[binary]>=3.1",
14
- "sqlalchemy>=2.0",
15
14
  ]
16
15
 
16
+ [project.optional-dependencies]
17
+ postgres = ["psycopg[binary]>=3.1", "sqlalchemy>=2.0"]
18
+
17
19
  [project.entry-points."interloper.components"]
18
20
  sql = "interloper_sql"
19
21
 
@@ -0,0 +1,24 @@
1
+ """Interloper SQL integration: portable views, and a SQLAlchemy-backed destination behind the postgres extra."""
2
+
3
+ from interloper_sql.view import IbisView, ibis_view, union
4
+
5
+ try:
6
+ from interloper_sql.connection import SQLConnection
7
+ from interloper_sql.destination import SQLDestination
8
+ except ModuleNotFoundError as error:
9
+ # the destination and its connection need the postgres extra; the views do not
10
+ if error.name != "sqlalchemy":
11
+ raise
12
+ __all__ = [
13
+ "IbisView",
14
+ "ibis_view",
15
+ "union",
16
+ ]
17
+ else:
18
+ __all__ = [
19
+ "IbisView",
20
+ "SQLConnection",
21
+ "SQLDestination",
22
+ "ibis_view",
23
+ "union",
24
+ ]
@@ -22,7 +22,7 @@ class SQLConnection(Connection):
22
22
  """Connection resource holding the URL of a SQL database.
23
23
 
24
24
  Any SQLAlchemy dialect whose driver is installed works; PostgreSQL through
25
- psycopg ships with the package.
25
+ psycopg ships with the ``postgres`` extra.
26
26
  """
27
27
 
28
28
  model_config = SettingsConfigDict(env_prefix="sql_")
@@ -31,7 +31,7 @@ class SQLConnection(Connection):
31
31
  label="Database URL",
32
32
  description="SQLAlchemy URL, e.g. postgresql+psycopg://user:password@host:5432/db",
33
33
  info=(
34
- "The URL carries the password, so it is stored as a secret. PostgreSQL works out of the box "
34
+ "The URL carries the password, so it is stored as a secret. PostgreSQL works with the postgres extra "
35
35
  "(postgresql+psycopg://); other databases need their SQLAlchemy driver installed, e.g. "
36
36
  "mysql+pymysql:// or mssql+pyodbc://."
37
37
  ),
@@ -0,0 +1,7 @@
1
+ """Portable views: Ibis expressions over typed upstream tables, compiled per warehouse dialect."""
2
+
3
+ from interloper_sql.view.base import IbisDefinition, IbisView, union
4
+ from interloper_sql.view.decorator import ibis_view
5
+ from interloper_sql.view.types import IBIS_DIALECTS, ibis_schema
6
+
7
+ __all__ = ["IBIS_DIALECTS", "IbisDefinition", "IbisView", "ibis_schema", "ibis_view", "union"]
@@ -0,0 +1,166 @@
1
+ """IbisView: a view authored as an Ibis expression over its upstreams' declared schemas."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Sequence
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import ibis
9
+ from interloper.asset import Upstream, View
10
+ from interloper.destination.database import TableReference
11
+ from interloper.errors import ConfigError
12
+
13
+ from interloper_sql.view.types import IBIS_DIALECTS, ibis_schema
14
+
15
+ if TYPE_CHECKING:
16
+ from interloper.asset import Asset
17
+ from interloper.dag.base import DAG
18
+ from interloper.partitioning.base import Partition, PartitionWindow
19
+
20
+
21
+ class IbisDefinition:
22
+ """A view definition that compiles an Ibis expression for a dialect."""
23
+
24
+ def __init__(self, expression: ibis.Table) -> None:
25
+ """Wrap an expression.
26
+
27
+ Args:
28
+ expression: The view's query as an Ibis table expression.
29
+ """
30
+ self.expression = expression
31
+
32
+ def compile(self, dialect: str) -> str:
33
+ """Render the expression as SQL for a warehouse dialect.
34
+
35
+ Args:
36
+ dialect: The destination's dialect name.
37
+
38
+ Returns:
39
+ The SQL text.
40
+
41
+ Raises:
42
+ ConfigError: If the dialect has no Ibis counterpart.
43
+ """
44
+ if dialect not in IBIS_DIALECTS:
45
+ raise ConfigError(f"No Ibis dialect for '{dialect}'")
46
+ return str(ibis.to_sql(self.expression, dialect=IBIS_DIALECTS[dialect]))
47
+
48
+
49
+ class IbisView(View):
50
+ """A view whose ``data()`` receives Ibis tables and returns an Ibis expression.
51
+
52
+ Each upstream's ``data`` is an ``ibis.Table`` named after the table the
53
+ destination references and typed from the upstream's declared schema,
54
+ so the expression is checked as it is built. The result must carry the
55
+ view's declared columns with their declared types; the destination then
56
+ compiles it for its own dialect.
57
+ """
58
+
59
+ async def _read_upstreams(
60
+ self,
61
+ name: str,
62
+ targets: list[Asset],
63
+ dag: DAG | None,
64
+ partition_or_window: Partition | PartitionWindow | None,
65
+ metadata: dict[str, Any],
66
+ ) -> list[Upstream]:
67
+ """Turn every table reference into a typed Ibis table.
68
+
69
+ Args:
70
+ name: The ``data()`` parameter the upstreams are built for.
71
+ targets: The bound upstream assets.
72
+ dag: Forwarded to `View._read_upstreams`.
73
+ partition_or_window: Forwarded.
74
+ metadata: Forwarded.
75
+
76
+ Returns:
77
+ One `Upstream` per target, its ``data`` an ``ibis.Table``.
78
+
79
+ Raises:
80
+ ConfigError: If an upstream declares no schema, or its reference is not a table.
81
+ """
82
+ upstreams = await super()._read_upstreams(name, targets, dag, partition_or_window, metadata)
83
+ tables: list[Upstream] = []
84
+ for upstream in upstreams:
85
+ reference = upstream.data
86
+ if not isinstance(reference, TableReference):
87
+ raise ConfigError(f"Upstream '{upstream.asset.qualified_key}' is not a table: {reference!r}")
88
+ if reference.schema is None:
89
+ raise ConfigError(f"Upstream '{upstream.asset.qualified_key}' declares no schema to type its table")
90
+ *outer, table = reference.parts
91
+ location = {}
92
+ if outer:
93
+ location["database"] = outer[-1]
94
+ if len(outer) > 1:
95
+ location["catalog"] = outer[-2]
96
+ tables.append(
97
+ Upstream(asset=upstream.asset, data=ibis.table(ibis_schema(reference.schema), name=table, **location))
98
+ )
99
+ return tables
100
+
101
+ def _definition(self, result: Any) -> IbisDefinition:
102
+ """Check the expression against the declared schema and wrap it.
103
+
104
+ Args:
105
+ result: The value ``data()`` returned.
106
+
107
+ Returns:
108
+ The compilable definition.
109
+
110
+ Raises:
111
+ ConfigError: If the result is not an Ibis table, the view
112
+ declares no schema, or the expression's columns differ from
113
+ the declared ones.
114
+ """
115
+ if not isinstance(result, ibis.Table):
116
+ raise ConfigError(f"View '{self.qualified_key}' returned {type(result).__name__}, not an Ibis table")
117
+ if self.schema is None:
118
+ raise ConfigError(f"View '{self.qualified_key}' declares no schema")
119
+ declared = {name: str(dtype) for name, dtype in ibis_schema(self.schema).items()}
120
+ produced = {name: str(dtype) for name, dtype in result.schema().items()}
121
+ if declared != produced:
122
+ missing = sorted(set(declared) - set(produced))
123
+ extra = sorted(set(produced) - set(declared))
124
+ retyped = sorted(name for name in declared.keys() & produced.keys() if declared[name] != produced[name])
125
+ raise ConfigError(
126
+ f"View '{self.qualified_key}' declares columns its expression does not produce: "
127
+ f"missing {missing}, extra {extra}, retyped {retyped}"
128
+ )
129
+ return IbisDefinition(result)
130
+
131
+
132
+ def union(upstreams: Sequence[Upstream], *columns: str, **stamps: Callable[[Upstream], Any]) -> ibis.Table:
133
+ """The UNION ALL of every upstream's table, optionally stamping a column per upstream.
134
+
135
+ Each table is projected to *columns*, or to every declared column, before
136
+ the union, so the branches line up by name: a bare ``SELECT *`` unions by
137
+ position, and two accounts' tables created at different times need not
138
+ share a column order.
139
+
140
+ Args:
141
+ upstreams: The bound upstreams of a many-valued relation, each carrying an ``ibis.Table``.
142
+ *columns: The columns to keep from every table, in this order; every declared column by default.
143
+ **stamps: Column name to a function of the upstream giving that
144
+ upstream's constant value, such as the account it came from.
145
+
146
+ Returns:
147
+ One table over every upstream.
148
+
149
+ Raises:
150
+ ConfigError: If no upstream is bound; a view over nothing has no columns.
151
+ """
152
+ if not upstreams:
153
+ raise ConfigError("no upstream bound: a view needs at least one")
154
+ names = list(columns or upstreams[0].data.columns)
155
+ tables = []
156
+ for upstream in upstreams:
157
+ # a projection keeping every column in order compiles to SELECT *, which unions by
158
+ # position; selecting in reverse forces the column list, and the outer select restores it
159
+ table = upstream.data.select(*reversed(names))
160
+ values = {}
161
+ for name, stamp in stamps.items():
162
+ stamped = stamp(upstream)
163
+ is_integer = isinstance(stamped, int) and not isinstance(stamped, bool)
164
+ values[name] = ibis.literal(stamped, type="int64") if is_integer else ibis.literal(stamped)
165
+ tables.append(table.mutate(**values))
166
+ return ibis.union(*tables, distinct=False).select(*names, *stamps)
@@ -0,0 +1,84 @@
1
+ """Decorator for creating IbisView subclasses from functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any, cast, overload
7
+
8
+ from interloper.asset.decorator import build_asset_class
9
+ from interloper.component.decorator import decorate
10
+ from interloper.component.relation import Relation
11
+
12
+ from interloper_sql.view.base import IbisView
13
+
14
+
15
+ @overload
16
+ def ibis_view(fn: Callable[..., Any], /) -> type[IbisView]: ...
17
+ @overload
18
+ def ibis_view(
19
+ *,
20
+ relations: dict[str, Any] = ...,
21
+ **overrides: Any,
22
+ ) -> Callable[[Callable[..., Any]], type[IbisView]]: ...
23
+ def ibis_view(
24
+ fn: Callable[..., Any] | None = None,
25
+ /,
26
+ *,
27
+ relations: dict[str, Any] | None = None,
28
+ **overrides: Any,
29
+ ) -> type[IbisView] | Callable[..., type[IbisView]]:
30
+ """Create an IbisView subclass from a decorated function.
31
+
32
+ Like ``il.view``, but portable: each upstream parameter receives a typed
33
+ ``ibis.Table`` in ``data``, the function returns an Ibis expression, and
34
+ the destination compiles it for its own dialect:
35
+
36
+ ```py
37
+ @ibis_view(schema=Totals, relations={"orders": il.Relation("asset", "shop.orders", many=True)})
38
+ def totals(orders: list[il.Upstream]) -> ibis.Table:
39
+ rows = union(orders)
40
+ return rows.group_by(rows.date).aggregate(total=rows.total.sum())
41
+ ```
42
+
43
+ Args:
44
+ fn: The function to turn into a view, passed positionally when the
45
+ decorator is used bare. ``None`` in the parenthesised form.
46
+ relations: As for ``il.asset``.
47
+ **overrides: As for ``il.asset``; a schema is required.
48
+
49
+ Returns:
50
+ An IbisView subclass with the function as its ``data()`` method.
51
+ """
52
+ if fn is not None:
53
+ return decorate(IbisView, fn, build=_build_ibis_view_class, relations=relations, **overrides)
54
+
55
+ def wrapper(fn: Callable[..., Any]) -> type[IbisView]:
56
+ return decorate(IbisView, fn, build=_build_ibis_view_class, relations=relations, **overrides)
57
+
58
+ return wrapper
59
+
60
+
61
+ # -- Internals -----------------------------------------------------------------
62
+
63
+
64
+ def _build_ibis_view_class(
65
+ fn: Any,
66
+ *,
67
+ classvars: dict[str, Any],
68
+ fields: dict[str, Any],
69
+ relations: dict[str, Relation],
70
+ ) -> type[IbisView]:
71
+ """Build an IbisView subclass from a function or method.
72
+
73
+ Args:
74
+ fn: The function (or method) backing the view's ``data()``.
75
+ classvars: Class-level attributes to set on the generated subclass.
76
+ fields: Pydantic field values to set on the generated subclass.
77
+ relations: Relations the decorator declares, name to relation.
78
+
79
+ Returns:
80
+ A dynamically created IbisView subclass.
81
+ """
82
+ return cast(
83
+ "type[IbisView]", build_asset_class(fn, base=IbisView, classvars=classvars, fields=fields, relations=relations)
84
+ )
@@ -0,0 +1,46 @@
1
+ """Ibis's view of interloper's types: schema fields and warehouse dialects, in one place."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ from decimal import Decimal
7
+ from typing import Any
8
+
9
+ import ibis
10
+ from interloper.schema import Schema
11
+
12
+ IBIS_TYPES: dict[Any, str] = {
13
+ str: "string",
14
+ int: "int64",
15
+ float: "float64",
16
+ bool: "boolean",
17
+ dt.date: "date",
18
+ dt.datetime: "timestamp",
19
+ Decimal: "decimal",
20
+ bytes: "binary",
21
+ }
22
+
23
+ IBIS_DIALECTS: dict[str, str] = {
24
+ "bigquery": "bigquery",
25
+ "duckdb": "duckdb",
26
+ "snowflake": "snowflake",
27
+ "clickhouse": "clickhouse",
28
+ "databricks": "databricks",
29
+ "tsql": "mssql",
30
+ "postgres": "postgres",
31
+ }
32
+
33
+
34
+ def ibis_schema(schema: type[Schema]) -> ibis.Schema:
35
+ """The Ibis schema of a declared schema, every column nullable.
36
+
37
+ A field whose type has no Ibis counterpart (a nested model, ``Any``) is
38
+ typed ``string``: a view selects such a column through, never computes on it.
39
+
40
+ Args:
41
+ schema: The declared schema.
42
+
43
+ Returns:
44
+ The Ibis schema, in declaration order.
45
+ """
46
+ return ibis.schema({spec.name: IBIS_TYPES.get(spec.type, "string") for spec in schema.field_specs()})
@@ -1,9 +0,0 @@
1
- """Interloper SQL integration: a SQLAlchemy-backed destination and its connection."""
2
-
3
- from interloper_sql.connection import SQLConnection
4
- from interloper_sql.destination import SQLDestination
5
-
6
- __all__ = [
7
- "SQLConnection",
8
- "SQLDestination",
9
- ]