nl2data-postgres 0.1.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.
@@ -0,0 +1,26 @@
1
+ """PostgreSQL metadata discovery and governed SQL execution for nl2data-core.
2
+
3
+ The package implements the core ``MetadataDiscoverer`` and ``QueryAdapter``
4
+ ports using ``psycopg``. The driver is never imported at package import
5
+ time; it is loaded lazily when a discoverer or adapter is first used.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from .config import PostgresAdapterConfig
13
+
14
+ __all__ = ["PostgresAdapterConfig"]
15
+
16
+
17
+ def __getattr__(name: str) -> Any:
18
+ if name == "PostgresMetadataDiscoverer":
19
+ from .discovery import PostgresMetadataDiscoverer
20
+
21
+ return PostgresMetadataDiscoverer
22
+ if name == "PostgresQueryAdapter":
23
+ from .adapter import PostgresQueryAdapter
24
+
25
+ return PostgresQueryAdapter
26
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,215 @@
1
+ """PostgreSQL ``QueryAdapter`` port implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import replace
7
+ from typing import Any
8
+
9
+ from nl2data_core.adapters.models import (
10
+ AdapterCapabilities,
11
+ AdapterLimits,
12
+ AsyncMode,
13
+ CostEstimate,
14
+ ExecutionResult,
15
+ GeneratedArtifact,
16
+ ParsedArtifact,
17
+ ValidatedArtifact,
18
+ ValidationContext,
19
+ )
20
+ from nl2data_core.adapters.sql.guard import SQLGuardPolicy, assert_guarded
21
+ from nl2data_core.adapters.sql.parsing import parse_sql
22
+ from nl2data_core.metadata.protocol import MetadataDiscoveryCapability
23
+ from sqlglot import exp
24
+
25
+ from .config import PostgresAdapterConfig
26
+ from .errors import PostgresAdapterError
27
+ from .execution import PostgresExecutor
28
+
29
+
30
+ class PostgresQueryAdapter:
31
+ """Read-only, single-statement PostgreSQL adapter over the core contract."""
32
+
33
+ def __init__(
34
+ self,
35
+ config: PostgresAdapterConfig,
36
+ *,
37
+ allowed_objects: frozenset[str] = frozenset(),
38
+ allowed_columns: frozenset[str] | None = None,
39
+ require_limit: bool = True,
40
+ snapshot_fingerprint: str | None = None,
41
+ ) -> None:
42
+ self._config = config
43
+ self._policy = SQLGuardPolicy(
44
+ allowed_objects=allowed_objects,
45
+ allowed_columns=allowed_columns,
46
+ max_rows=config.max_rows,
47
+ require_limit=require_limit,
48
+ )
49
+ self._snapshot_fingerprint = snapshot_fingerprint
50
+ self._executor = PostgresExecutor(config)
51
+ self._parsed_by_id: dict[str, Any] = {}
52
+ self._sql_by_id: dict[str, str] = {}
53
+
54
+ def capabilities(self) -> AdapterCapabilities:
55
+ return AdapterCapabilities(
56
+ adapter_type="sql",
57
+ query_language="sql",
58
+ async_mode=AsyncMode.THREAD_OFFLOAD,
59
+ features=frozenset(
60
+ {
61
+ "read_only",
62
+ "single_statement",
63
+ "bounded_results",
64
+ "ast_validation",
65
+ "aggregation",
66
+ "ordering",
67
+ "list_ops",
68
+ "contains",
69
+ "metadata_discovery",
70
+ "join",
71
+ "multi_entity",
72
+ "cte",
73
+ "grouping",
74
+ "union",
75
+ "computed_expressions",
76
+ "prior_period",
77
+ }
78
+ ),
79
+ limits=AdapterLimits(
80
+ max_query_length=self._config.max_query_length,
81
+ max_result_rows=self._policy.max_rows,
82
+ ),
83
+ )
84
+
85
+ def metadata_discovery_capability(self) -> MetadataDiscoveryCapability:
86
+ return MetadataDiscoveryCapability(
87
+ backend="sql:postgresql",
88
+ supported=True,
89
+ max_objects=1_024,
90
+ max_fields_per_object=16_384,
91
+ supports_statistics=True,
92
+ supports_sampling=False,
93
+ description="bounded postgresql catalog introspection",
94
+ )
95
+
96
+ def parse(self, query: str, context: ValidationContext) -> ParsedArtifact:
97
+ parsed = parse_sql(
98
+ query,
99
+ dialect="postgres",
100
+ artifact_id=f"pg-{len(self._parsed_by_id) + 1}",
101
+ max_query_length=self._config.max_query_length,
102
+ )
103
+ self._parsed_by_id[parsed.artifact_id] = parsed
104
+ return ParsedArtifact(
105
+ artifact_id=parsed.artifact_id,
106
+ fingerprint=parsed.fingerprint,
107
+ parse_metadata={
108
+ "statement_type": parsed.statement_type,
109
+ "tables": ",".join(parsed.tables),
110
+ "dialect": parsed.dialect,
111
+ },
112
+ )
113
+
114
+ def _obligation_policy(self, context: ValidationContext) -> SQLGuardPolicy:
115
+ if not context.required_obligation_fingerprints:
116
+ return self._policy
117
+ return replace(
118
+ self._policy,
119
+ required_obligation_fingerprints=(
120
+ self._policy.required_obligation_fingerprints
121
+ | context.required_obligation_fingerprints
122
+ ),
123
+ )
124
+
125
+ def validate(self, artifact: ParsedArtifact, context: ValidationContext) -> ValidatedArtifact:
126
+ parsed = self._parsed_by_id.get(artifact.artifact_id)
127
+ if parsed is None:
128
+ raise PostgresAdapterError(
129
+ "cannot validate an artifact that was not parsed by this adapter",
130
+ details={"artifact_id": artifact.artifact_id},
131
+ )
132
+ if (
133
+ self._snapshot_fingerprint is not None
134
+ and context.snapshot_fingerprint is not None
135
+ and context.snapshot_fingerprint != self._snapshot_fingerprint
136
+ ):
137
+ raise PostgresAdapterError(
138
+ "the metadata snapshot does not match the adapter's bound snapshot",
139
+ details={"artifact_id": artifact.artifact_id},
140
+ )
141
+ guard = assert_guarded(
142
+ parsed,
143
+ self._obligation_policy(context),
144
+ statement=exp.maybe_parse(parsed.sql_text, dialect="postgres"),
145
+ field_bindings=context.field_bindings,
146
+ )
147
+ self._sql_by_id[artifact.artifact_id] = parsed.sql_text
148
+ return ValidatedArtifact(
149
+ artifact_id=artifact.artifact_id,
150
+ fingerprint=guard.fingerprint,
151
+ snapshot_fingerprint=context.snapshot_fingerprint,
152
+ validation_metadata={
153
+ "tables": ",".join(parsed.tables),
154
+ "limit": str(parsed.limit_value or 0),
155
+ "columns": ",".join(parsed.columns),
156
+ },
157
+ obligations_verified=guard.obligations_verified,
158
+ bounded_rows=parsed.limit_value,
159
+ )
160
+
161
+ async def generate(self, query: str, context: ValidationContext) -> GeneratedArtifact:
162
+ parsed = parse_sql(
163
+ query,
164
+ dialect="postgres",
165
+ artifact_id=f"pg-{len(self._parsed_by_id) + 1}",
166
+ max_query_length=self._config.max_query_length,
167
+ )
168
+ guard = assert_guarded(
169
+ parsed,
170
+ self._obligation_policy(context),
171
+ statement=exp.maybe_parse(parsed.sql_text, dialect="postgres"),
172
+ field_bindings=context.field_bindings,
173
+ )
174
+ self._parsed_by_id[parsed.artifact_id] = parsed
175
+ self._sql_by_id[parsed.artifact_id] = parsed.sql_text
176
+ return GeneratedArtifact(
177
+ artifact_id=parsed.artifact_id,
178
+ fingerprint=guard.fingerprint,
179
+ content_type="text/sql",
180
+ size_bytes=len(parsed.sql_text.encode("utf-8")),
181
+ metadata={"dialect": parsed.dialect},
182
+ )
183
+
184
+ async def estimate_cost(
185
+ self, artifact: ValidatedArtifact, context: ValidationContext
186
+ ) -> CostEstimate:
187
+ parsed = self._parsed_by_id.get(artifact.artifact_id)
188
+ if parsed is None:
189
+ raise PostgresAdapterError(
190
+ "cannot estimate an artifact that was not parsed by this adapter",
191
+ details={"artifact_id": artifact.artifact_id},
192
+ )
193
+ return CostEstimate(estimated_units=parsed.limit_value or self._policy.max_rows)
194
+
195
+ async def execute(
196
+ self, artifact: ValidatedArtifact, context: ValidationContext
197
+ ) -> ExecutionResult:
198
+ sql_text = self._sql_by_id.get(artifact.artifact_id)
199
+ if sql_text is None:
200
+ raise PostgresAdapterError(
201
+ "cannot execute an artifact that was not validated by this adapter",
202
+ details={"artifact_id": artifact.artifact_id},
203
+ )
204
+ return await asyncio.to_thread(
205
+ self._executor.execute,
206
+ sql_text,
207
+ max_rows=context.limits.max_result_rows if context.limits else self._policy.max_rows,
208
+ max_result_bytes=context.max_result_bytes,
209
+ timeout_seconds=context.execution_timeout_seconds,
210
+ )
211
+
212
+ async def close(self) -> None:
213
+ self._parsed_by_id.clear()
214
+ self._sql_by_id.clear()
215
+ self._executor.close()
@@ -0,0 +1,121 @@
1
+ """Lazy psycopg client loading and bounded read-only connection helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import threading
7
+ from collections.abc import Iterator
8
+ from importlib import import_module
9
+ from importlib.util import find_spec
10
+ from typing import Any
11
+
12
+ from nl2data.errors import NL2DataError
13
+ from nl2data_core.metadata.protocol import (
14
+ MetadataDiscoveryError,
15
+ MetadataUnauthorizedError,
16
+ MetadataUnavailableError,
17
+ )
18
+
19
+ from .config import PostgresAdapterConfig
20
+
21
+ _AUTH_SQLSTATES = frozenset({"28P01", "42501", "42502"})
22
+
23
+
24
+ def _pool_module() -> Any:
25
+ """Return the psycopg_pool module, raising a safe error if absent."""
26
+ if find_spec("psycopg_pool") is None:
27
+ raise MetadataUnavailableError(
28
+ "the psycopg pool driver is not installed; install the 'postgres' extra",
29
+ details={"cause_type": "ImportError"},
30
+ )
31
+ return import_module("psycopg_pool")
32
+
33
+
34
+ def _raise_normalized_discovery_error(error: BaseException, message: str) -> None:
35
+ """Normalize a psycopg failure into the safe discovery error family."""
36
+ cause_type = error.__class__.__name__
37
+ sqlstate = getattr(error, "sqlstate", None)
38
+ if sqlstate in _AUTH_SQLSTATES or cause_type in {
39
+ "InsufficientPrivilege",
40
+ "InvalidPassword",
41
+ "PasswordMismatch",
42
+ }:
43
+ raise MetadataUnauthorizedError(message, details={"cause_type": cause_type}) from error
44
+ if cause_type in {
45
+ "OperationalError",
46
+ "InterfaceError",
47
+ "ConnectionError",
48
+ "PoolTimeout",
49
+ }:
50
+ raise MetadataUnavailableError(message, details={"cause_type": cause_type}) from error
51
+ raise MetadataDiscoveryError(message, details={"cause_type": cause_type}) from error
52
+
53
+
54
+ class PostgresPool:
55
+ """A lazily-created bounded psycopg connection pool.
56
+
57
+ The pool is created only on first use and is closed idempotently.
58
+ DSN resolution and driver loading happen lazily, so importing this
59
+ module does not require ``psycopg``.
60
+ """
61
+
62
+ def __init__(self, config: PostgresAdapterConfig) -> None:
63
+ self._config = config
64
+ self._pool: Any | None = None
65
+ self._lock = threading.Lock()
66
+
67
+ def _connect_timeout(self) -> float:
68
+ return min(self._config.timeout_seconds, 5.0)
69
+
70
+ def _ensure_pool(self) -> Any:
71
+ if self._pool is not None:
72
+ return self._pool
73
+ with self._lock:
74
+ if self._pool is not None:
75
+ return self._pool
76
+ pool_module = _pool_module()
77
+ try:
78
+ dsn = self._config.resolve_dsn()
79
+ except ValueError as error:
80
+ raise MetadataUnavailableError(
81
+ "postgresql dsn reference could not be resolved",
82
+ details={"cause_type": type(error).__name__},
83
+ ) from error
84
+ try:
85
+ self._pool = pool_module.ConnectionPool(
86
+ dsn,
87
+ min_size=self._config.pool_min_size,
88
+ max_size=self._config.pool_max_size,
89
+ open=True,
90
+ kwargs={"connect_timeout": self._connect_timeout()},
91
+ )
92
+ self._pool.wait(timeout=self._connect_timeout())
93
+ except Exception as error:
94
+ self._pool = None
95
+ _raise_normalized_discovery_error(error, "could not connect to postgresql")
96
+ return self._pool
97
+
98
+ @contextlib.contextmanager
99
+ def connection(self) -> Iterator[Any]:
100
+ """Yield a read-only or standard connection from the pool."""
101
+ pool = self._ensure_pool()
102
+ connection: Any | None = None
103
+ try:
104
+ with pool.connection() as connection:
105
+ if self._config.read_only:
106
+ connection.execute("SET TRANSACTION READ ONLY")
107
+ yield connection
108
+ except NL2DataError:
109
+ raise
110
+ except Exception as error:
111
+ if connection is None:
112
+ _raise_normalized_discovery_error(error, "could not obtain a postgresql connection")
113
+ _raise_normalized_discovery_error(error, "postgresql operation failed")
114
+
115
+ def close(self) -> None:
116
+ """Release the pool idempotently."""
117
+ with self._lock:
118
+ if self._pool is not None:
119
+ with contextlib.suppress(Exception):
120
+ self._pool.close()
121
+ self._pool = None
@@ -0,0 +1,109 @@
1
+ """Bounded, credential-free PostgreSQL adapter configuration.
2
+
3
+ DSNs are host-injected references (for example ``env:VAR_NAME``) rather
4
+ than raw connection strings, so configuration stays fingerprintable and
5
+ safe to serialize. The package resolves the reference at runtime.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from typing import Any
12
+
13
+ from nl2data_core.canonical import strict_sha256_fingerprint
14
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
15
+
16
+ _MAX_QUERY_LENGTH = 10_000
17
+ _MAX_RESULT_ROWS = 100_000
18
+ _MAX_RESULT_BYTES = 10_485_760
19
+ _MAX_TIMEOUT_SECONDS = 3600.0
20
+ _MAX_OBJECTS = 1_024
21
+ _MAX_FIELDS_PER_OBJECT = 16_384
22
+ _MAX_STATISTICS = 8_192
23
+
24
+
25
+ class PostgresAdapterConfig(BaseModel):
26
+ """Bounded PostgreSQL discovery and execution configuration.
27
+
28
+ ``dsn_reference`` is a host-injected DSN reference such as
29
+ ``env:NL2DATA_POSTGRES_DSN``. The reference is resolved lazily at
30
+ runtime; the configuration object never carries a raw password or
31
+ connection string.
32
+ """
33
+
34
+ model_config = ConfigDict(frozen=True, extra="forbid")
35
+
36
+ dsn_reference: str = Field(min_length=1, max_length=512)
37
+ schema_name: str | None = Field(default=None, min_length=1, max_length=128)
38
+ allowed_objects: frozenset[str] = Field(default_factory=frozenset, max_length=_MAX_OBJECTS)
39
+ allowed_fields: frozenset[str] = Field(
40
+ default_factory=frozenset, max_length=_MAX_FIELDS_PER_OBJECT
41
+ )
42
+ max_objects: int = Field(default=256, ge=1, le=_MAX_OBJECTS)
43
+ max_fields_per_object: int = Field(default=1_024, ge=1, le=_MAX_FIELDS_PER_OBJECT)
44
+ max_statistics: int = Field(default=1_024, ge=0, le=_MAX_STATISTICS)
45
+ include_statistics: bool = True
46
+ timeout_seconds: float = Field(default=30.0, gt=0.0, le=_MAX_TIMEOUT_SECONDS)
47
+ max_query_length: int = Field(default=_MAX_QUERY_LENGTH, ge=1, le=1_000_000)
48
+ max_rows: int = Field(default=_MAX_RESULT_ROWS, ge=1, le=1_000_000_000)
49
+ max_result_bytes: int = Field(default=_MAX_RESULT_BYTES, ge=1, le=1_073_741_824)
50
+ pool_min_size: int = Field(default=1, ge=0, le=64)
51
+ pool_max_size: int = Field(default=4, ge=1, le=128)
52
+ read_only: bool = True
53
+ source_id: str | None = Field(default=None, min_length=1, max_length=128)
54
+
55
+ @field_validator("allowed_objects", "allowed_fields")
56
+ @classmethod
57
+ def _bounded_identifiers(cls, value: frozenset[str]) -> frozenset[str]:
58
+ import re
59
+
60
+ pattern = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_\-\.]{0,127}$")
61
+ for identifier in value:
62
+ if pattern.fullmatch(identifier) is None:
63
+ raise ValueError("allowlist entries must be bounded identifiers")
64
+ return value
65
+
66
+ @field_validator("pool_max_size")
67
+ @classmethod
68
+ def _pool_size_consistent(cls, value: int, info: Any) -> int:
69
+ if (info.data.get("pool_min_size") or 0) > value:
70
+ raise ValueError("pool_min_size cannot exceed pool_max_size")
71
+ return value
72
+
73
+ def resolve_dsn(self) -> str:
74
+ """Resolve the host-injected DSN reference to a connection string."""
75
+ reference = self.dsn_reference.strip()
76
+ if reference.startswith("env:"):
77
+ env_name = reference[4:]
78
+ env_value = os.environ.get(env_name)
79
+ if env_value is None:
80
+ raise ValueError(f"DSN environment variable '{env_name}' is not set")
81
+ return env_value
82
+ if reference.startswith("dsn:"):
83
+ return reference[4:]
84
+ return reference
85
+
86
+ def safe_payload(self) -> dict[str, Any]:
87
+ """Safe, credential-free serialization of bounded settings."""
88
+ return {
89
+ "dsn_reference": self.dsn_reference,
90
+ "schema_name": self.schema_name,
91
+ "allowed_objects": sorted(self.allowed_objects),
92
+ "allowed_fields": sorted(self.allowed_fields),
93
+ "max_objects": self.max_objects,
94
+ "max_fields_per_object": self.max_fields_per_object,
95
+ "max_statistics": self.max_statistics,
96
+ "include_statistics": self.include_statistics,
97
+ "timeout_seconds": self.timeout_seconds,
98
+ "max_query_length": self.max_query_length,
99
+ "max_rows": self.max_rows,
100
+ "max_result_bytes": self.max_result_bytes,
101
+ "pool_min_size": self.pool_min_size,
102
+ "pool_max_size": self.pool_max_size,
103
+ "read_only": self.read_only,
104
+ "source_id": self.source_id,
105
+ }
106
+
107
+ def fingerprint(self) -> str:
108
+ """Stable fingerprint of this configuration; no secrets included."""
109
+ return strict_sha256_fingerprint(self.safe_payload())
@@ -0,0 +1,351 @@
1
+ """PostgreSQL metadata discovery behind the provider-neutral core contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ import time
8
+ from typing import Any
9
+
10
+ from nl2data_core.canonical import strict_sha256_fingerprint
11
+ from nl2data_core.metadata.models import (
12
+ MetadataConstraint,
13
+ MetadataConstraintKind,
14
+ MetadataEvidence,
15
+ MetadataField,
16
+ MetadataFreshness,
17
+ MetadataObject,
18
+ MetadataObjectKind,
19
+ MetadataProvenance,
20
+ MetadataRelationship,
21
+ MetadataRelationshipKind,
22
+ MetadataSnapshot,
23
+ MetadataSourceReference,
24
+ MetadataStatistic,
25
+ MetadataStatisticKind,
26
+ MetadataTrustLevel,
27
+ )
28
+ from nl2data_core.metadata.protocol import (
29
+ MetadataDiscoveryCapability,
30
+ MetadataDiscoveryConfig,
31
+ MetadataDiscoveryError,
32
+ MetadataUnauthorizedError,
33
+ )
34
+
35
+ from .client import PostgresPool
36
+ from .config import PostgresAdapterConfig
37
+
38
+ _IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_\-\.]{0,127}$")
39
+
40
+
41
+ def _quote_ident(name: str) -> str:
42
+ return '"' + name.replace('"', '""') + '"'
43
+
44
+
45
+ def _sql_type(declared: str) -> str:
46
+ """Normalize a declared SQL type into a bounded canonical name."""
47
+ base = declared.split("(", 1)[0].strip().upper()
48
+ aliases = {
49
+ "CHARACTER VARYING": "VARCHAR",
50
+ "CHAR VARYING": "VARCHAR",
51
+ "CHARACTER": "CHAR",
52
+ "DOUBLE PRECISION": "DOUBLE",
53
+ "TIMESTAMP WITHOUT TIME ZONE": "TIMESTAMP",
54
+ "TIMESTAMP WITH TIME ZONE": "TIMESTAMPTZ",
55
+ }
56
+ base = aliases.get(base, base)
57
+ return base[:64] or "UNKNOWN"
58
+
59
+
60
+ class PostgresMetadataDiscoverer:
61
+ """Read-only PostgreSQL metadata discovery over the core contract."""
62
+
63
+ def __init__(
64
+ self,
65
+ config: PostgresAdapterConfig,
66
+ *,
67
+ allowed_objects: frozenset[str] = frozenset(),
68
+ allowed_fields: frozenset[str] = frozenset(),
69
+ ) -> None:
70
+ self._config = config
71
+ self._allowed_objects = allowed_objects
72
+ self._allowed_fields = allowed_fields
73
+ self._pool = PostgresPool(config)
74
+
75
+ def capability(self) -> MetadataDiscoveryCapability:
76
+ """Declare the discovery bounds this backend supports."""
77
+ return MetadataDiscoveryCapability(
78
+ backend="sql:postgresql",
79
+ supported=True,
80
+ max_objects=1_024,
81
+ max_fields_per_object=16_384,
82
+ supports_statistics=True,
83
+ supports_sampling=False,
84
+ description="bounded postgresql catalog introspection",
85
+ )
86
+
87
+ async def discover(self, config: MetadataDiscoveryConfig) -> MetadataSnapshot:
88
+ """Discover a bounded canonical snapshot of the configured source."""
89
+ effective_objects = (
90
+ self._allowed_objects & config.allowed_objects
91
+ if config.allowed_objects
92
+ else self._allowed_objects
93
+ )
94
+ if not effective_objects:
95
+ raise MetadataUnauthorizedError(
96
+ "no objects are authorized for metadata discovery",
97
+ details={"authorized_objects": str(len(self._allowed_objects))},
98
+ )
99
+ return await asyncio.to_thread(
100
+ self._discover_sync, config, effective_objects, self._allowed_fields
101
+ )
102
+
103
+ async def close(self) -> None:
104
+ """Release the underlying connection pool."""
105
+ self._pool.close()
106
+
107
+
108
+ def _discover_sync(
109
+ self,
110
+ config: MetadataDiscoveryConfig,
111
+ allowed_objects: frozenset[str],
112
+ allowed_fields: frozenset[str],
113
+ ) -> MetadataSnapshot:
114
+ """Synchronous PostgreSQL discovery using the discoverer's bounded pool."""
115
+ with self._pool.connection() as connection:
116
+ return _introspect(connection, self._config, config, allowed_objects, allowed_fields)
117
+
118
+
119
+ def _introspect(
120
+ connection: Any,
121
+ pg_config: PostgresAdapterConfig,
122
+ config: MetadataDiscoveryConfig,
123
+ allowed_objects: frozenset[str],
124
+ allowed_fields: frozenset[str],
125
+ ) -> MetadataSnapshot:
126
+ schema = pg_config.schema_name or "public"
127
+ objects: list[MetadataObject] = []
128
+ relationships: list[MetadataRelationship] = []
129
+ evidence: list[MetadataEvidence] = []
130
+ bounded_objects = False
131
+ bounded_fields = False
132
+ bounded_samples = False
133
+ statistic_count = 0
134
+ deadline = time.monotonic() + config.timeout_seconds
135
+
136
+ timeout_ms = max(1, int(config.timeout_seconds * 1000))
137
+ connection.execute(f"SET statement_timeout = {timeout_ms}")
138
+
139
+ rows = connection.execute(
140
+ "SELECT table_name, table_type FROM information_schema.tables "
141
+ "WHERE table_schema = %s AND table_type IN ('BASE TABLE', 'VIEW') "
142
+ "ORDER BY table_name",
143
+ (schema,),
144
+ ).fetchall()
145
+ catalog_objects = [
146
+ (str(name), str(kind))
147
+ for name, kind in rows
148
+ if _IDENTIFIER_PATTERN.fullmatch(str(name)) is not None
149
+ ]
150
+ selected = [(name, kind) for name, kind in catalog_objects if name in allowed_objects]
151
+ if len(selected) > config.max_objects:
152
+ selected = selected[: config.max_objects]
153
+ bounded_objects = True
154
+
155
+ for name, kind in selected:
156
+ if time.monotonic() >= deadline:
157
+ raise MetadataDiscoveryError(
158
+ "metadata discovery exceeded the authorized timeout",
159
+ details={"timeout_seconds": str(config.timeout_seconds)},
160
+ )
161
+ object_kind = MetadataObjectKind.VIEW if kind == "VIEW" else MetadataObjectKind.TABLE
162
+ columns = connection.execute(
163
+ "SELECT column_name, data_type, is_nullable "
164
+ "FROM information_schema.columns "
165
+ "WHERE table_schema = %s AND table_name = %s "
166
+ "ORDER BY ordinal_position",
167
+ (schema, name),
168
+ ).fetchall()
169
+ if len(columns) > config.max_fields_per_object:
170
+ columns = columns[: config.max_fields_per_object]
171
+ bounded_fields = True
172
+
173
+ fields: list[MetadataField] = []
174
+ for column in columns:
175
+ column_name = str(column[0])
176
+ if _IDENTIFIER_PATTERN.fullmatch(column_name) is None:
177
+ bounded_fields = True
178
+ continue
179
+ if config.allowed_fields and column_name not in config.allowed_fields:
180
+ continue
181
+ if allowed_fields and column_name not in allowed_fields:
182
+ continue
183
+ fields.append(
184
+ MetadataField(
185
+ field_id=column_name,
186
+ object_id=name,
187
+ path=column_name,
188
+ data_type=_sql_type(str(column[1] or "UNKNOWN")),
189
+ nullable=str(column[2]).lower() != "no",
190
+ trust_level=MetadataTrustLevel.DECLARED,
191
+ )
192
+ )
193
+ field_evidence = MetadataEvidence(
194
+ evidence_id=f"pg-obj-{name}",
195
+ kind="object",
196
+ reference=strict_sha256_fingerprint(
197
+ {"object": name, "fields": sorted(field.field_id for field in fields)}
198
+ ),
199
+ description="postgresql catalog object observation",
200
+ )
201
+ evidence.append(field_evidence)
202
+
203
+ constraints: list[MetadataConstraint] = []
204
+ pk_rows = connection.execute(
205
+ "SELECT kcu.column_name "
206
+ "FROM information_schema.table_constraints tc "
207
+ "JOIN information_schema.key_column_usage kcu "
208
+ " ON kcu.constraint_name = tc.constraint_name "
209
+ " AND kcu.table_schema = tc.table_schema "
210
+ "WHERE tc.table_schema = %s AND tc.table_name = %s "
211
+ " AND tc.constraint_type = 'PRIMARY KEY' "
212
+ "ORDER BY kcu.ordinal_position",
213
+ (schema, name),
214
+ ).fetchall()
215
+ primary_fields = [str(row[0]) for row in pk_rows if row[0] is not None]
216
+ if primary_fields:
217
+ constraints.append(
218
+ MetadataConstraint(
219
+ constraint_id=f"{name}_pk",
220
+ kind=MetadataConstraintKind.PRIMARY_KEY,
221
+ fields=frozenset(primary_fields),
222
+ trust_level=MetadataTrustLevel.DECLARED,
223
+ )
224
+ )
225
+ unique_by_name: dict[str, list[str]] = {}
226
+ for constraint_name, column_name in connection.execute(
227
+ "SELECT tc.constraint_name, kcu.column_name "
228
+ "FROM information_schema.table_constraints tc "
229
+ "JOIN information_schema.key_column_usage kcu "
230
+ " ON kcu.constraint_name = tc.constraint_name "
231
+ " AND kcu.table_schema = tc.table_schema "
232
+ "WHERE tc.table_schema = %s AND tc.table_name = %s "
233
+ " AND tc.constraint_type = 'UNIQUE' "
234
+ "ORDER BY tc.constraint_name, kcu.ordinal_position",
235
+ (schema, name),
236
+ ).fetchall():
237
+ unique_by_name.setdefault(str(constraint_name), []).append(str(column_name))
238
+ field_ids = {field.field_id for field in fields}
239
+ for constraint_name, unique_columns in unique_by_name.items():
240
+ bounded_columns = [column for column in unique_columns if column in field_ids]
241
+ if bounded_columns:
242
+ constraints.append(
243
+ MetadataConstraint(
244
+ constraint_id=f"{name}_{constraint_name}",
245
+ kind=MetadataConstraintKind.UNIQUE,
246
+ fields=frozenset(bounded_columns),
247
+ trust_level=MetadataTrustLevel.DECLARED,
248
+ )
249
+ )
250
+
251
+ statistics: list[MetadataStatistic] = []
252
+ if config.include_statistics and statistic_count < config.max_statistics:
253
+ count_row = connection.execute(
254
+ f"SELECT COUNT(*) FROM {_quote_ident(schema)}.{_quote_ident(name)}"
255
+ ).fetchone()
256
+ if count_row is not None:
257
+ statistics.append(
258
+ MetadataStatistic(
259
+ statistic_id=f"{name}_row_count",
260
+ kind=MetadataStatisticKind.ROW_COUNT,
261
+ scope_object_id=name,
262
+ value=float(count_row[0]),
263
+ trust_level=MetadataTrustLevel.DECLARED,
264
+ )
265
+ )
266
+ statistic_count += 1
267
+
268
+ objects.append(
269
+ MetadataObject(
270
+ object_id=name,
271
+ kind=object_kind,
272
+ name=name,
273
+ fields=tuple(fields),
274
+ constraints=tuple(constraints),
275
+ statistics=tuple(statistics),
276
+ trust_level=MetadataTrustLevel.DECLARED,
277
+ observed_incomplete=False,
278
+ )
279
+ )
280
+
281
+ for _constraint_name, from_column, to_column, target_table in (
282
+ connection.execute(
283
+ "SELECT c.conname, a.attname AS from_column, "
284
+ " fa.attname AS to_column, f.relname AS target_table "
285
+ "FROM pg_constraint c "
286
+ "JOIN pg_class t ON t.oid = c.conrelid "
287
+ "JOIN pg_namespace n ON n.oid = t.relnamespace "
288
+ "CROSS JOIN LATERAL unnest(c.conkey, c.confkey) "
289
+ " WITH ORDINALITY AS u(fk_attnum, ref_attnum, ord) "
290
+ "JOIN pg_attribute a "
291
+ " ON a.attrelid = c.conrelid AND a.attnum = u.fk_attnum "
292
+ "JOIN pg_attribute fa "
293
+ " ON fa.attrelid = c.confrelid AND fa.attnum = u.ref_attnum "
294
+ "JOIN pg_class f ON f.oid = c.confrelid "
295
+ "WHERE c.contype = 'f' AND n.nspname = %s AND t.relname = %s "
296
+ "ORDER BY c.conname, u.ord",
297
+ (schema, name),
298
+ ).fetchall()
299
+ ):
300
+ if str(target_table) not in allowed_objects or str(from_column) not in field_ids:
301
+ continue
302
+ relationship_id = f"{name}_{str(target_table)}_via_{str(from_column)}"
303
+ relationships.append(
304
+ MetadataRelationship(
305
+ relationship_id=relationship_id,
306
+ kind=MetadataRelationshipKind.FOREIGN_KEY,
307
+ source_object_id=name,
308
+ target_object_id=str(target_table),
309
+ source_fields=frozenset({str(from_column)}),
310
+ target_fields=frozenset({str(to_column)}),
311
+ trust_level=MetadataTrustLevel.DECLARED,
312
+ )
313
+ )
314
+ constraints.append(
315
+ MetadataConstraint(
316
+ constraint_id=f"{relationship_id}_fk",
317
+ kind=MetadataConstraintKind.FOREIGN_KEY,
318
+ fields=frozenset({str(from_column)}),
319
+ trust_level=MetadataTrustLevel.DECLARED,
320
+ )
321
+ )
322
+
323
+ source_digest = strict_sha256_fingerprint(
324
+ {
325
+ "objects": sorted(obj.object_id for obj in objects),
326
+ "fingerprints": sorted(evidence_item.reference for evidence_item in evidence),
327
+ }
328
+ )
329
+ return MetadataSnapshot(
330
+ snapshot_id=f"pg-{source_digest[-16:]}",
331
+ source=MetadataSourceReference(
332
+ source_id=pg_config.source_id or "postgresql",
333
+ catalog_fingerprint=source_digest,
334
+ description="read-only postgresql catalog",
335
+ ),
336
+ objects=tuple(objects),
337
+ relationships=tuple(relationships),
338
+ freshness=MetadataFreshness(
339
+ bounded_objects=bounded_objects,
340
+ bounded_fields=bounded_fields,
341
+ bounded_samples=bounded_samples,
342
+ sample_limit=config.max_samples,
343
+ ),
344
+ provenance=MetadataProvenance(
345
+ discovered_by_fingerprint=strict_sha256_fingerprint(
346
+ {"backend": "sql:postgresql", "schema": schema}
347
+ ),
348
+ method="postgresql_introspection",
349
+ evidence=tuple(evidence),
350
+ ),
351
+ )
@@ -0,0 +1,33 @@
1
+ """Safe normalized errors for the PostgreSQL adapter boundary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from nl2data.errors import ErrorCategory, ErrorCode, NL2DataError
8
+
9
+
10
+ class PostgresAdapterError(NL2DataError):
11
+ """Raised for PostgreSQL adapter misuse outside the guarded lifecycle."""
12
+
13
+ def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
14
+ super().__init__(
15
+ ErrorCategory.ADAPTER,
16
+ ErrorCode.SQL_REJECTED,
17
+ message,
18
+ retryable=False,
19
+ details=details,
20
+ )
21
+
22
+
23
+ class PostgresExecutionError(NL2DataError):
24
+ """Raised when PostgreSQL execution fails or produces unsupported values."""
25
+
26
+ def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
27
+ super().__init__(
28
+ ErrorCategory.ADAPTER,
29
+ ErrorCode.SQL_EXECUTION_FAILED,
30
+ message,
31
+ retryable=False,
32
+ details=details,
33
+ )
@@ -0,0 +1,148 @@
1
+ """Read-only PostgreSQL SQL execution with bounded protected results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import time
7
+ from decimal import Decimal
8
+ from typing import Any
9
+
10
+ from nl2data_core.adapters.models import ExecutionResult
11
+ from nl2data_core.canonical import strict_sha256_fingerprint
12
+ from nl2data_core.compilation.expansion import ZeroDivisionPolicyError
13
+
14
+ from .client import PostgresPool
15
+ from .config import PostgresAdapterConfig
16
+ from .errors import PostgresExecutionError
17
+
18
+ #: The protected public scalar set; everything else is unsupported.
19
+ _SCALAR_TYPES: tuple[type, ...] = (str, int, float, bool, type(None))
20
+
21
+
22
+ def _normalize_cell(value: Any, *, row_index: int, column_index: int) -> Any:
23
+ if isinstance(value, _SCALAR_TYPES):
24
+ return value
25
+ if isinstance(value, Decimal):
26
+ #: NUMERIC columns arrive as Decimal; normalize into the protected
27
+ #: float scalar so the same computed math (e.g. ROUND(100.0 * a / b))
28
+ #: yields SQLite-equivalent float64 values. Non-finite numerics
29
+ #: (NaN/Infinity) stay fail-closed: a poisoned float would break the
30
+ #: canonical JSON payload downstream.
31
+ converted = float(value)
32
+ if math.isfinite(converted):
33
+ return converted
34
+ raise PostgresExecutionError(
35
+ "database returned a value outside the protected scalar set",
36
+ details={
37
+ "row_index": str(row_index),
38
+ "column_index": str(column_index),
39
+ "value_type": type(value).__name__,
40
+ },
41
+ )
42
+
43
+
44
+ class PostgresExecutor:
45
+ """Bounded read-only executor for a single PostgreSQL adapter."""
46
+
47
+ def __init__(self, config: PostgresAdapterConfig) -> None:
48
+ self._config = config
49
+ self._pool = PostgresPool(config)
50
+
51
+ def close(self) -> None:
52
+ self._pool.close()
53
+
54
+ def execute(
55
+ self,
56
+ sql: str,
57
+ *,
58
+ max_rows: int | None = None,
59
+ max_columns: int | None = None,
60
+ max_result_bytes: int | None = None,
61
+ timeout_seconds: float | None = None,
62
+ ) -> ExecutionResult:
63
+ """Execute one validated read-only statement and map to protected scalars."""
64
+ effective_max_rows = max_rows if max_rows is not None else self._config.max_rows
65
+ effective_max_columns = max_columns if max_columns is not None else 1_000
66
+ effective_max_bytes = (
67
+ max_result_bytes if max_result_bytes is not None else self._config.max_result_bytes
68
+ )
69
+ effective_timeout = timeout_seconds or self._config.timeout_seconds
70
+ started = time.monotonic()
71
+ deadline = started + effective_timeout
72
+
73
+ with self._pool.connection() as connection:
74
+ timeout_ms = max(1, int(effective_timeout * 1000))
75
+ connection.execute(f"SET statement_timeout = {timeout_ms}")
76
+ try:
77
+ cursor = connection.execute(sql)
78
+ except Exception as error:
79
+ if getattr(error, "sqlstate", None) == "22012":
80
+ raise ZeroDivisionPolicyError(
81
+ "calculated-field division by zero",
82
+ details={"sqlstate": "22012"},
83
+ ) from error
84
+ raise PostgresExecutionError(
85
+ "postgresql execution failed",
86
+ details={"cause_type": type(error).__name__},
87
+ ) from error
88
+
89
+ columns = tuple(description[0] for description in cursor.description or ())
90
+ if len(columns) > effective_max_columns:
91
+ raise PostgresExecutionError(
92
+ "result column count exceeds the bounded maximum",
93
+ details={"max_columns": str(effective_max_columns)},
94
+ )
95
+
96
+ rows: list[tuple[Any, ...]] = []
97
+ for row_index, row in enumerate(cursor.fetchmany(effective_max_rows + 1)):
98
+ if row_index >= effective_max_rows:
99
+ raise PostgresExecutionError(
100
+ "result row count exceeds the bounded maximum",
101
+ details={"max_rows": str(effective_max_rows)},
102
+ )
103
+ rows.append(
104
+ tuple(
105
+ _normalize_cell(cell, row_index=row_index, column_index=col_index)
106
+ for col_index, cell in enumerate(row)
107
+ )
108
+ )
109
+ if effective_max_bytes is not None:
110
+ text_bytes = sum(
111
+ len(str(cell).encode("utf-8"))
112
+ for current_row in rows
113
+ for cell in current_row
114
+ )
115
+ result_bytes = sum(len(str(column).encode("utf-8")) for column in columns)
116
+ result_bytes += text_bytes
117
+ if result_bytes > effective_max_bytes:
118
+ raise PostgresExecutionError(
119
+ "result bytes exceed the authorized maximum",
120
+ details={
121
+ "max_result_bytes": str(effective_max_bytes),
122
+ "actual": str(result_bytes),
123
+ },
124
+ )
125
+ if time.monotonic() >= deadline:
126
+ raise PostgresExecutionError(
127
+ "SQL execution exceeded the authorized timeout",
128
+ details={"timeout_seconds": str(effective_timeout)},
129
+ )
130
+
131
+ # Rows are model-native tuples; the fingerprint payload carries them
132
+ # as JSON arrays so strict canonicalization stays fail-closed.
133
+ fingerprint = strict_sha256_fingerprint(
134
+ {
135
+ "columns": list(columns),
136
+ "rows": [list(row) for row in rows],
137
+ }
138
+ )
139
+ duration_ms = int((time.monotonic() - started) * 1000)
140
+ return ExecutionResult(
141
+ result_id=f"result-{fingerprint[-16:]}",
142
+ fingerprint=fingerprint,
143
+ row_count=len(rows),
144
+ columns=columns,
145
+ rows=tuple(rows),
146
+ duration_ms=duration_ms,
147
+ metadata={"dialect": "postgres"},
148
+ )
File without changes
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.4
2
+ Name: nl2data-postgres
3
+ Version: 0.1.0
4
+ Summary: PostgreSQL metadata discovery and governed SQL execution adapter for nl2data-core.
5
+ Author: NL2Data Contributors
6
+ License: Apache-2.0
7
+ Keywords: nl2data,postgresql,metadata,sql,adapter
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Topic :: Software Development :: Libraries
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: nl2data-core>=0.1.0
18
+ Requires-Dist: psycopg[binary,pool]<4,>=3.1
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=8.0; extra == "dev"
21
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
22
+ Requires-Dist: mypy>=1.10; extra == "dev"
23
+ Requires-Dist: ruff>=0.5; extra == "dev"
24
+
25
+ # nl2data-postgres
26
+
27
+ An optional PostgreSQL backend integration for
28
+ [nl2data-core](https://github.com/emmansun/nl2data-core). It combines
29
+ metadata discovery and governed read-only SQL execution behind the core
30
+ provider-neutral contracts.
31
+
32
+ `psycopg` is an optional dependency and is loaded lazily at first use. The
33
+ core import boundary never loads the PostgreSQL driver.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install nl2data-postgres
39
+ ```
40
+
41
+ Requires Python 3.11+, `nl2data-core>=0.1.0`, and
42
+ `psycopg[binary,pool]>=3.1,<4`.
43
+
44
+ From a source checkout:
45
+
46
+ ```bash
47
+ pip install -e packages/nl2data-postgres
48
+ ```
49
+
50
+ ## Public surface
51
+
52
+ ```python
53
+ from nl2data_postgres import (
54
+ PostgresAdapterConfig,
55
+ PostgresMetadataDiscoverer,
56
+ PostgresQueryAdapter,
57
+ )
58
+ ```
59
+
60
+ - `PostgresAdapterConfig` — bounded discovery/execution configuration with
61
+ host-injected DSN references, object/field allowlists, and result bounds.
62
+ - `PostgresMetadataDiscoverer` — the core `MetadataDiscoverer` port
63
+ implementation for PostgreSQL.
64
+ - `PostgresQueryAdapter` — the core `QueryAdapter` port implementation
65
+ for read-only PostgreSQL SQL.
66
+
67
+ ## Configuration
68
+
69
+ Configuration is credential-free; hosts inject DSNs through a resolver
70
+ or environment at runtime. `PostgresAdapterConfig` carries only
71
+ bounded, fingerprintable settings.
72
+
73
+ ```python
74
+ from nl2data_postgres import PostgresAdapterConfig
75
+
76
+ config = PostgresAdapterConfig(
77
+ dsn_reference="env:NL2DATA_POSTGRES_DSN",
78
+ allowed_objects={"users", "orders"},
79
+ max_objects=256,
80
+ max_rows=10_000,
81
+ )
82
+ ```
83
+
84
+ ## Discovery
85
+
86
+ ```python
87
+ from nl2data_postgres import PostgresAdapterConfig, PostgresMetadataDiscoverer
88
+ from nl2data_core.metadata.protocol import MetadataDiscoveryConfig
89
+
90
+ discoverer = PostgresMetadataDiscoverer(
91
+ config=PostgresAdapterConfig(dsn_reference="env:NL2DATA_POSTGRES_DSN"),
92
+ allowed_objects={"users", "orders"},
93
+ )
94
+ snapshot = await discoverer.discover(MetadataDiscoveryConfig())
95
+ ```
96
+
97
+ Discovery returns a core `MetadataSnapshot` with normalized tables,
98
+ columns, types, primary/foreign keys, and bounded protected statistics.
99
+
100
+ ## Query execution
101
+
102
+ ```python
103
+ from nl2data_postgres import PostgresAdapterConfig, PostgresQueryAdapter
104
+ from nl2data_core.adapters.models import ValidationContext
105
+
106
+ adapter = PostgresQueryAdapter(
107
+ config=PostgresAdapterConfig(dsn_reference="env:NL2DATA_POSTGRES_DSN"),
108
+ allowed_objects={"users"},
109
+ )
110
+ artifact = adapter.parse("SELECT id, name FROM users LIMIT 10", ValidationContext())
111
+ validated = adapter.validate(artifact, ValidationContext())
112
+ result = await adapter.execute(validated, ValidationContext())
113
+ ```
114
+
115
+ Execution uses a read-only connection pool, enforces statement timeout,
116
+ limits rows/columns/bytes, and maps every result value to the protected
117
+ scalar set.
118
+
119
+ ## Failure classification
120
+
121
+ | Condition | Normalized result |
122
+ | --- | --- |
123
+ | Missing driver / unreachable service | Retryable `MetadataUnavailableError` |
124
+ | Authorization or permission denial | `MetadataUnauthorizedError` |
125
+ | Lifecycle misuse (unparsed/unvalidated artifacts) | `PostgresAdapterError` |
126
+ | Guard rejection (scope/obligation/limit) | `SQLGuardError` |
127
+ | Statement/result bounds exceeded | `PostgresExecutionError` |
128
+ | Malformed SQL | `SQLParseError` |
129
+
130
+ DSNs, raw SQL, native exceptions, and credentials never cross the
131
+ boundary.
132
+
133
+ ## Compatibility
134
+
135
+ The in-core `nl2data_core.adapters.sql.SqlMetadataDiscoverer` still
136
+ accepts `dialect="postgresql"` as a temporary compatibility path, but it
137
+ always uses the legacy in-core implementation and emits a deprecation
138
+ warning; it does not delegate to this package. New code should import
139
+ from `nl2data_postgres` directly.
140
+
141
+ ## More documentation
142
+
143
+ - [Documentation index](../../docs/README.md)
144
+ - [Adding an adapter or provider](../../docs/development/adding-adapter-or-provider.md)
145
+ - [Secrets and live testing](../../docs/operations/secrets.md)
146
+ - [Capabilities and support](../../docs/reference/capabilities.md)
@@ -0,0 +1,12 @@
1
+ nl2data_postgres/__init__.py,sha256=aRHG-IuP1pFfbYGCNA4-HWdRFa-CLF7Rp4x3KfcuqOk,852
2
+ nl2data_postgres/adapter.py,sha256=_X6IN-cNz7OiizES3xbcP-P2HRx0VccTOExozxxx7bE,8240
3
+ nl2data_postgres/client.py,sha256=VfRPRLzmJLuPc5RRHYflwVZANhZYGIJTXPlmrpTHGoc,4549
4
+ nl2data_postgres/config.py,sha256=dS5Np_GldMWBunkkatPkeN32RRUrXjplQM1FDzRRAfA,4607
5
+ nl2data_postgres/discovery.py,sha256=Jpz0LiF4gQrD3URahVRhHuCv8ysyvqycb5JetEjXtik,13768
6
+ nl2data_postgres/errors.py,sha256=PDqmv5GA4_jfpGQBjA7vYn44RFLZeSGcD1Fj3QkA8Dg,1033
7
+ nl2data_postgres/execution.py,sha256=OGoGWRfAn0Pt8PTXf2DOVwgEBZRmg8Mzeh1Zg_2x5-U,6156
8
+ nl2data_postgres/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ nl2data_postgres-0.1.0.dist-info/METADATA,sha256=zAj3mH9hh1zFLAF5oxpPclewD611qzMrNilGkKl3tEg,4836
10
+ nl2data_postgres-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ nl2data_postgres-0.1.0.dist-info/top_level.txt,sha256=OIQ8dphDbQpNfaAaJ_HPlVuT2IyHdTH0kJH9ZGVXv8Q,17
12
+ nl2data_postgres-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ nl2data_postgres