pgiscluster 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pgiscluster/__init__.py
ADDED
pgiscluster/connector.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import geopandas as gpd
|
|
8
|
+
import kubernetes as k8s
|
|
9
|
+
import sqlalchemy as sql
|
|
10
|
+
from sqlalchemy import event
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DBConnector(ABC):
|
|
14
|
+
"""Assembles a PostgreSQL connection address, then queries the database"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
database: str = "data_science",
|
|
19
|
+
target_resolver: Callable[[], tuple[str, int]] | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
self.drivername = "postgresql+psycopg"
|
|
22
|
+
self.database = database
|
|
23
|
+
self.host, self.port = (target_resolver or self._get_target)()
|
|
24
|
+
self.engine = self._create_engine()
|
|
25
|
+
|
|
26
|
+
def _get_target(self) -> tuple[str, int]:
|
|
27
|
+
"""Returns the (host, port) to connect to, resolved live from the cluster's CiliumLocalRedirectPolicy."""
|
|
28
|
+
k8s.config.load_kube_config()
|
|
29
|
+
crd_list = k8s.client.ApiextensionsV1Api().list_custom_resource_definition()
|
|
30
|
+
assert isinstance(crd_list, k8s.client.V1CustomResourceDefinitionList)
|
|
31
|
+
assert crd_list.items is not None
|
|
32
|
+
|
|
33
|
+
local_policy = next(
|
|
34
|
+
crd
|
|
35
|
+
for crd in crd_list.items
|
|
36
|
+
if crd.spec.names.kind == "CiliumLocalRedirectPolicy"
|
|
37
|
+
)
|
|
38
|
+
group = local_policy.spec.group
|
|
39
|
+
plural = local_policy.spec.names.plural
|
|
40
|
+
served_versions = [
|
|
41
|
+
version for version in local_policy.spec.versions if version.served
|
|
42
|
+
]
|
|
43
|
+
if not served_versions:
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
f"{local_policy.metadata.name} CRD has no served version"
|
|
46
|
+
)
|
|
47
|
+
storage_version_options = (
|
|
48
|
+
version for version in served_versions if version.storage
|
|
49
|
+
)
|
|
50
|
+
storage_version = next(storage_version_options, None)
|
|
51
|
+
if storage_version is None:
|
|
52
|
+
raise RuntimeError(
|
|
53
|
+
f"{local_policy.metadata.name} CRD has no storage version among its served versions"
|
|
54
|
+
)
|
|
55
|
+
if next(storage_version_options, None) is not None:
|
|
56
|
+
raise RuntimeError(
|
|
57
|
+
f"{local_policy.metadata.name} CRD has multiple storage versions"
|
|
58
|
+
)
|
|
59
|
+
raw_result = k8s.client.CustomObjectsApi().list_cluster_custom_object(
|
|
60
|
+
group=group,
|
|
61
|
+
plural=plural,
|
|
62
|
+
version=storage_version.name,
|
|
63
|
+
label_selector="app.kubernetes.io/name=postgis-cluster",
|
|
64
|
+
)
|
|
65
|
+
# kubernetes client lacks type hints until v37; drop this on its release
|
|
66
|
+
assert isinstance(raw_result, dict)
|
|
67
|
+
result: dict[str, Any] = raw_result
|
|
68
|
+
if len(result["items"]) != 1:
|
|
69
|
+
raise RuntimeError(
|
|
70
|
+
f"expected exactly one CiliumLocalRedirectPolicy matching label "
|
|
71
|
+
f"app.kubernetes.io/name=postgis-cluster, found {len(result['items'])}"
|
|
72
|
+
)
|
|
73
|
+
address_matcher = result["items"][0]["spec"]["redirectFrontend"][
|
|
74
|
+
"addressMatcher"
|
|
75
|
+
]
|
|
76
|
+
host = address_matcher["ip"]
|
|
77
|
+
port = int(address_matcher["toPorts"][0]["port"])
|
|
78
|
+
return (host, port)
|
|
79
|
+
|
|
80
|
+
@abstractmethod
|
|
81
|
+
def _get_credentials(self) -> tuple[str, str]:
|
|
82
|
+
"""Returns a fresh (username, password) pair."""
|
|
83
|
+
raise NotImplementedError
|
|
84
|
+
|
|
85
|
+
def _create_engine(self) -> sql.Engine:
|
|
86
|
+
"""Uses DBConnector attributes to return database address."""
|
|
87
|
+
engine = sql.create_engine(f"{self.drivername}://")
|
|
88
|
+
|
|
89
|
+
@event.listens_for(engine, "do_connect")
|
|
90
|
+
def _inject_credentials(*args):
|
|
91
|
+
*_, cparams = args
|
|
92
|
+
username, password = self._get_credentials()
|
|
93
|
+
cparams["user"] = username
|
|
94
|
+
cparams["password"] = password
|
|
95
|
+
cparams["host"] = self.host
|
|
96
|
+
cparams["port"] = self.port
|
|
97
|
+
cparams["dbname"] = self.database
|
|
98
|
+
|
|
99
|
+
return engine
|
|
100
|
+
|
|
101
|
+
def db_query(
|
|
102
|
+
self,
|
|
103
|
+
query_file: str | Path | None = None,
|
|
104
|
+
*,
|
|
105
|
+
query: str | None = None,
|
|
106
|
+
geom_col: str,
|
|
107
|
+
) -> gpd.GeoDataFrame:
|
|
108
|
+
"""Runs a SQL query against the PostGIS database, returning a GeoDataFrame.
|
|
109
|
+
|
|
110
|
+
Provide query_file (a path to a .sql file) or query (a literal SQL string).
|
|
111
|
+
"""
|
|
112
|
+
if query is None:
|
|
113
|
+
if query_file is None:
|
|
114
|
+
raise ValueError("either query_file or query must be given")
|
|
115
|
+
query = Path(query_file).read_text()
|
|
116
|
+
geodataframe = gpd.read_postgis(
|
|
117
|
+
sql=query,
|
|
118
|
+
con=self.engine,
|
|
119
|
+
# NOTE: GeoDataFrame can only analyze one geometry column at a time
|
|
120
|
+
geom_col=geom_col,
|
|
121
|
+
)
|
|
122
|
+
return geodataframe
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class HostDBConnector(DBConnector):
|
|
126
|
+
"""Connects to the database from outside the cluster, using
|
|
127
|
+
the VSO-synced dynamic-credentials Secret."""
|
|
128
|
+
|
|
129
|
+
SECRET_NAME = "postgis-app-dynamic-credentials"
|
|
130
|
+
SECRET_NAMESPACE = "databases"
|
|
131
|
+
|
|
132
|
+
def _get_credentials(self) -> tuple[str, str]:
|
|
133
|
+
"""Returns a fresh (username, password) pair decoded from the VSO-synced Secret."""
|
|
134
|
+
secret = k8s.client.CoreV1Api().read_namespaced_secret(
|
|
135
|
+
self.SECRET_NAME, self.SECRET_NAMESPACE
|
|
136
|
+
)
|
|
137
|
+
assert isinstance(secret, k8s.client.V1Secret)
|
|
138
|
+
assert secret.data is not None
|
|
139
|
+
missing_keys = [
|
|
140
|
+
key for key in ("username", "password") if key not in secret.data
|
|
141
|
+
]
|
|
142
|
+
if missing_keys:
|
|
143
|
+
raise RuntimeError(
|
|
144
|
+
f"{self.SECRET_NAME} Secret missing key(s): {', '.join(missing_keys)}"
|
|
145
|
+
)
|
|
146
|
+
username = base64.b64decode(secret.data["username"]).decode()
|
|
147
|
+
password = base64.b64decode(secret.data["password"]).decode()
|
|
148
|
+
return (username, password)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class HostAdminDBConnector(HostDBConnector):
|
|
152
|
+
"""Connects to the database from outside the cluster using the Postgres
|
|
153
|
+
superuser credential, for schema/DDL changes rather than routine queries.
|
|
154
|
+
|
|
155
|
+
Unlike HostDBConnector's dynamic lease, this credential is static
|
|
156
|
+
(doesn't expire or rotate) and unscoped to a single database — it's the
|
|
157
|
+
same credential CloudNativePG itself uses for cluster bootstrap and
|
|
158
|
+
reconciliation. Use deliberately, not as a default."""
|
|
159
|
+
|
|
160
|
+
SECRET_NAME = "postgis-app-credentials"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Scripts to turn raw data into features for modeling."""
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pgiscluster
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python classes intended to orchestrate Extract, Transform, & Load (ETL) Data Pipelines and Machine Learning (ML) tasks.
|
|
5
|
+
Project-URL: Homepage, https://github.com/DragonBishop/pgiscluster
|
|
6
|
+
Project-URL: Repository, https://github.com/DragonBishop/pgiscluster
|
|
7
|
+
Project-URL: Issues, https://github.com/DragonBishop/pgiscluster/issues
|
|
8
|
+
Author-email: DragonBishop <276789280+DragonBishop@users.noreply.github.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Requires-Python: >=3.14.7
|
|
15
|
+
Requires-Dist: geopandas>=1.1.4
|
|
16
|
+
Requires-Dist: hvac>=2.4.0
|
|
17
|
+
Requires-Dist: kubernetes>=36.0.3
|
|
18
|
+
Requires-Dist: pandas>=3.0.5
|
|
19
|
+
Requires-Dist: psycopg[binary]>=3.3.4
|
|
20
|
+
Requires-Dist: sqlalchemy>=2.0.52
|
|
21
|
+
Provides-Extra: common
|
|
22
|
+
Requires-Dist: ipykernel>=7.3.0; extra == 'common'
|
|
23
|
+
Requires-Dist: prefect>=3.8.5; extra == 'common'
|
|
24
|
+
Provides-Extra: etl
|
|
25
|
+
Requires-Dist: dbt-core>=1.12.3; extra == 'etl'
|
|
26
|
+
Requires-Dist: dbt-postgres>=1.11.0; extra == 'etl'
|
|
27
|
+
Requires-Dist: dlt>=1.30.0; extra == 'etl'
|
|
28
|
+
Requires-Dist: ipykernel>=7.3.0; extra == 'etl'
|
|
29
|
+
Requires-Dist: prefect>=3.8.5; extra == 'etl'
|
|
30
|
+
Provides-Extra: ml
|
|
31
|
+
Requires-Dist: bentoml>=1.2.0; extra == 'ml'
|
|
32
|
+
Requires-Dist: ipykernel>=7.3.0; extra == 'ml'
|
|
33
|
+
Requires-Dist: mlflow>=3.16.0; extra == 'ml'
|
|
34
|
+
Requires-Dist: prefect>=3.8.5; extra == 'ml'
|
|
35
|
+
Requires-Dist: scikit-learn>=1.3.2; extra == 'ml'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# pgiscluster
|
|
39
|
+
|
|
40
|
+
[](https://github.com/DragonBishop/pgiscluster/blob/main/LICENSE)
|
|
41
|
+
[](https://www.python.org/downloads/)
|
|
42
|
+
|
|
43
|
+
Python classes for connecting to and querying a PostGIS database, with credentials resolved dynamically at runtime.
|
|
44
|
+
|
|
45
|
+
**Status: Alpha.** Only `HostDBConnector` is implemented. `ClusterDBConnector` is planned but not built. Its default address and credential discovery assume specific cluster infrastructure, but both are plain, overridable methods.
|
|
46
|
+
|
|
47
|
+
## Key features
|
|
48
|
+
|
|
49
|
+
- Fetches credentials from a Kubernetes Secret on every connection, so rotation is picked up automatically.
|
|
50
|
+
- Resolves the database address live from the cluster.
|
|
51
|
+
- Query results returned as GeoDataFrames for downstream GIS/analytics work.
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install pgiscluster
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Requirements
|
|
60
|
+
|
|
61
|
+
- Python >= 3.14.7
|
|
62
|
+
- A `kubectl` context with:
|
|
63
|
+
- Cluster-scoped read access to CustomResourceDefinitions and their custom objects, for address discovery.
|
|
64
|
+
- Read access to Secrets in the namespace holding the database credentials.
|
|
65
|
+
- A reachable host:port for the database. By default, resolved via a Cilium `CiliumLocalRedirectPolicy` labeled `app.kubernetes.io/name=postgis-cluster`.
|
|
66
|
+
- A Kubernetes Secret with base64-encoded `username`/`password` keys for the database login.
|
|
67
|
+
- See [Configuration](#configuration) for alternatives to defaults.
|
|
68
|
+
|
|
69
|
+
## Usage
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from pgiscluster import HostDBConnector
|
|
73
|
+
|
|
74
|
+
connector = HostDBConnector()
|
|
75
|
+
df = connector.db_query(
|
|
76
|
+
"queries/nearby.sql", geom_col="geom"
|
|
77
|
+
) # from a .sql file (default)
|
|
78
|
+
df = connector.db_query(query="SELECT * FROM my_table", geom_col="geom") # literal SQL
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`HostDBConnector` resolves the database host/port at construction, then fetches a fresh username/password pair on every connection attempt.
|
|
82
|
+
|
|
83
|
+
### Configuration
|
|
84
|
+
|
|
85
|
+
#### `HostDBConnector`
|
|
86
|
+
|
|
87
|
+
`HostDBConnector(database="data_science", target_resolver=None)`: `database` is the target database name, defaulting to `"data_science"`.
|
|
88
|
+
|
|
89
|
+
By default, `HostDBConnector` resolves host/port from the cluster's `CiliumLocalRedirectPolicy`, reading the address from its `spec.redirectFrontend.addressMatcher`. For a different mechanism (a NodePort/LoadBalancer Service, a port-forward, a socat proxy), override `_get_target` in a subclass to reuse the same logic across many instances, or pass `target_resolver`, a zero-argument callable returning `(host, port)`, for a one-off:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
connector = HostDBConnector(target_resolver=lambda: ("localhost", 5432))
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Credentials come from a Kubernetes Secret. Its name and namespace are set by the class attributes `HostDBConnector.SECRET_NAME` and `HostDBConnector.SECRET_NAMESPACE`. You can override them to point elsewhere. The reference cluster populates that Secret via the Vault Secrets Operator, syncing a dynamically-generated HashiCorp Vault credential. However, any mechanism works as long as the Secret has `username`/`password` keys.
|
|
96
|
+
|
|
97
|
+
`db_query(query_file=None, *, query=None, geom_col)` runs a SQL query and returns a [GeoDataFrame](https://geopandas.org/), decoding one geometry column per call. One of either `query_file` (a path to a `.sql` file, the default), or `query`, a literal SQL string.
|
|
98
|
+
|
|
99
|
+
#### `HostAdminDBConnector`
|
|
100
|
+
|
|
101
|
+
A `HostDBConnector` subclass that reads the cluster's Postgres **superuser** credential from a static secret, instead of the scoped `app_readwrite` dynamic lease, for schema/DDL changes:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from pgiscluster import HostAdminDBConnector
|
|
105
|
+
|
|
106
|
+
connector = HostAdminDBConnector()
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
See [MIT LICENSE](https://github.com/DragonBishop/pgiscluster/blob/main/LICENSE).
|
|
112
|
+
|
|
113
|
+
## Links
|
|
114
|
+
|
|
115
|
+
- [Source](https://github.com/DragonBishop/pgiscluster)
|
|
116
|
+
- [Issues](https://github.com/DragonBishop/pgiscluster/issues)
|
|
117
|
+
- [Changelog](https://github.com/DragonBishop/pgiscluster/blob/main/CHANGELOG.md)
|
|
118
|
+
|
|
119
|
+
`pgiscluster` was originally developed inside the [Data Science Cluster](https://github.com/DragonBishop/data_science_cluster) repo, the reference cluster its defaults are built against. If you want to contribute, open a development branch and then submit a pull request.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
pgiscluster/__init__.py,sha256=A5G9CiOBIzqhdZL5jS3GZSFxgywYqt02u8I1eGRXcpM,181
|
|
2
|
+
pgiscluster/connector.py,sha256=PUOvVhNWATjUtXRnAImVIu2CSerqo1em5QLKf7Y6hJ8,6188
|
|
3
|
+
pgiscluster/features/__init__.py,sha256=8-i_XeuERLebwhYIRb7ZxZRmB_k2LvZf21vZd4IrR40,59
|
|
4
|
+
pgiscluster-0.2.0.dist-info/METADATA,sha256=6kqtV9cl7AAv7JD6naPHNwvHDs36Xp-15FxAPJra8b4,5754
|
|
5
|
+
pgiscluster-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
6
|
+
pgiscluster-0.2.0.dist-info/licenses/LICENSE,sha256=BXfwJsVlvEUEUc4kokFcApRorrBWqnukpLLtinVJuS0,1069
|
|
7
|
+
pgiscluster-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DragonBishop
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|