sunstone-data-platform 1.0.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.
- sunstone_data/__init__.py +36 -0
- sunstone_data/_version.py +24 -0
- sunstone_data/auth/__init__.py +43 -0
- sunstone_data/auth/client_creds.py +239 -0
- sunstone_data/auth/pkce.py +307 -0
- sunstone_data/auth/store.py +97 -0
- sunstone_data/catalog.py +175 -0
- sunstone_data/cli/__init__.py +5 -0
- sunstone_data/cli/_app.py +26 -0
- sunstone_data/cli/_errors.py +31 -0
- sunstone_data/cli/commands/__init__.py +22 -0
- sunstone_data/cli/commands/data_auth.py +120 -0
- sunstone_data/cli/commands/data_import.py +157 -0
- sunstone_data/cli/commands/data_info.py +166 -0
- sunstone_data/cli/commands/data_publish.py +69 -0
- sunstone_data/cli/commands/ls.py +49 -0
- sunstone_data/cli/commands/prov.py +58 -0
- sunstone_data/cli/commands/refs.py +142 -0
- sunstone_data/cli/commands/show.py +139 -0
- sunstone_data/cli/commands/snapshots.py +77 -0
- sunstone_data/cli/commands/sparql.py +68 -0
- sunstone_data/cli/main.py +30 -0
- sunstone_data/config.py +250 -0
- sunstone_data/discovery.py +317 -0
- sunstone_data/env_commands.py +97 -0
- sunstone_data/env_section.py +86 -0
- sunstone_data/graph/__init__.py +36 -0
- sunstone_data/graph/client.py +328 -0
- sunstone_data/graph/dataset.py +438 -0
- sunstone_data/graph/terms.py +34 -0
- sunstone_data/identifiers.py +155 -0
- sunstone_data/import_/__init__.py +28 -0
- sunstone_data/import_/fetcher.py +259 -0
- sunstone_data/import_/owner.py +130 -0
- sunstone_data/import_/parser.py +492 -0
- sunstone_data/import_/pipeline.py +275 -0
- sunstone_data/import_/storage.py +135 -0
- sunstone_data/metadata/__init__.py +41 -0
- sunstone_data/metadata/builder.py +276 -0
- sunstone_data/metadata/models.py +87 -0
- sunstone_data/metadata/provenance.py +171 -0
- sunstone_data/metadata/provenance_rdf.py +102 -0
- sunstone_data/plugin.py +62 -0
- sunstone_data/publish.py +134 -0
- sunstone_data/query.py +624 -0
- sunstone_data/resolve.py +178 -0
- sunstone_data/url.py +78 -0
- sunstone_data/utils/__init__.py +12 -0
- sunstone_data/utils/checksum.py +14 -0
- sunstone_data/utils/encoding.py +16 -0
- sunstone_data/utils/naming.py +38 -0
- sunstone_data/versioning/__init__.py +17 -0
- sunstone_data/versioning/schema_evolution.py +180 -0
- sunstone_data_platform-1.0.0.dist-info/METADATA +302 -0
- sunstone_data_platform-1.0.0.dist-info/RECORD +66 -0
- sunstone_data_platform-1.0.0.dist-info/WHEEL +4 -0
- sunstone_data_platform-1.0.0.dist-info/entry_points.txt +2 -0
- sunstone_pipeline/__init__.py +1 -0
- sunstone_pipeline/assets/__init__.py +11 -0
- sunstone_pipeline/assets/mirrors.py +123 -0
- sunstone_pipeline/assets/transforms.py +74 -0
- sunstone_pipeline/definitions.py +25 -0
- sunstone_pipeline/io_manager.py +56 -0
- sunstone_pipeline/resources/__init__.py +6 -0
- sunstone_pipeline/resources/iceberg.py +29 -0
- sunstone_pipeline/resources/refs.py +88 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Source fetching: URL download and local file validation."""
|
|
2
|
+
|
|
3
|
+
import tempfile
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Callable
|
|
8
|
+
from urllib.parse import unquote, urlparse
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from sunstone.ssrf import is_public_url
|
|
12
|
+
|
|
13
|
+
# Maximum download size: 512 MiB.
|
|
14
|
+
MAX_DOWNLOAD_BYTES: int = 512 * 1024 * 1024
|
|
15
|
+
|
|
16
|
+
# Maximum number of HTTP redirects to follow.
|
|
17
|
+
MAX_REDIRECTS: int = 10
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FileType(Enum):
|
|
21
|
+
"""Supported file types for import."""
|
|
22
|
+
|
|
23
|
+
CSV = "csv"
|
|
24
|
+
EXCEL = "excel"
|
|
25
|
+
UNKNOWN = "unknown"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class SourceInfo:
|
|
30
|
+
"""Information about a fetched source."""
|
|
31
|
+
|
|
32
|
+
local_path: Path
|
|
33
|
+
filename: str
|
|
34
|
+
file_type: FileType
|
|
35
|
+
encoding: str | None
|
|
36
|
+
is_temp: bool # True if downloaded to temp file
|
|
37
|
+
source_url: str | None # Original URL if downloaded
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SSRFError(ValueError):
|
|
41
|
+
"""Raised when a URL targets a disallowed network address."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DownloadTooLargeError(ValueError):
|
|
45
|
+
"""Raised when a download exceeds MAX_DOWNLOAD_BYTES."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Public helpers
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def detect_file_type(filename: str) -> FileType:
|
|
54
|
+
"""Detect file type from filename extension."""
|
|
55
|
+
lower = filename.lower()
|
|
56
|
+
if lower.endswith(".csv"):
|
|
57
|
+
return FileType.CSV
|
|
58
|
+
elif lower.endswith((".xlsx", ".xls")):
|
|
59
|
+
return FileType.EXCEL
|
|
60
|
+
return FileType.UNKNOWN
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def fetch_source(
|
|
64
|
+
source: str,
|
|
65
|
+
progress_callback: Callable[[int, int | None], None] | None = None,
|
|
66
|
+
) -> SourceInfo:
|
|
67
|
+
"""Fetch source from URL or validate local file.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
source: Local file path or URL to fetch.
|
|
71
|
+
progress_callback: Optional callback(downloaded_bytes, total_bytes).
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
SourceInfo with local path and metadata.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
FileNotFoundError: If local file doesn't exist.
|
|
78
|
+
httpx.HTTPError: If URL download fails.
|
|
79
|
+
ValueError: If file type is not supported or URL scheme is invalid.
|
|
80
|
+
SSRFError: If URL targets a disallowed address.
|
|
81
|
+
DownloadTooLargeError: If the response body exceeds MAX_DOWNLOAD_BYTES.
|
|
82
|
+
"""
|
|
83
|
+
if source.startswith(("http://", "https://")):
|
|
84
|
+
return _download_url(source, progress_callback)
|
|
85
|
+
|
|
86
|
+
# Reject non-HTTP URL schemes (file://, ftp://, gopher://, etc.)
|
|
87
|
+
if "://" in source:
|
|
88
|
+
scheme = source.split("://", 1)[0].lower()
|
|
89
|
+
if scheme not in ("http", "https"):
|
|
90
|
+
raise ValueError(f"Unsupported URL scheme: {scheme}://. Only http and https are allowed.")
|
|
91
|
+
|
|
92
|
+
return _validate_local(source)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Local file handling
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _validate_local(path: str) -> SourceInfo:
|
|
101
|
+
"""Validate local file exists and determine type."""
|
|
102
|
+
local_path = Path(path).expanduser().resolve()
|
|
103
|
+
|
|
104
|
+
if not local_path.exists():
|
|
105
|
+
raise FileNotFoundError(f"File not found: {local_path}")
|
|
106
|
+
|
|
107
|
+
if not local_path.is_file():
|
|
108
|
+
raise ValueError(f"Not a file: {local_path}")
|
|
109
|
+
|
|
110
|
+
file_type = detect_file_type(local_path.name)
|
|
111
|
+
if file_type == FileType.UNKNOWN:
|
|
112
|
+
raise ValueError(f"Unsupported file type: {local_path.suffix}. Supported: .csv, .xlsx, .xls")
|
|
113
|
+
|
|
114
|
+
return SourceInfo(
|
|
115
|
+
local_path=local_path,
|
|
116
|
+
filename=local_path.name,
|
|
117
|
+
file_type=file_type,
|
|
118
|
+
encoding=None, # Will be detected during parsing
|
|
119
|
+
is_temp=False,
|
|
120
|
+
source_url=None,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# URL validation
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _validate_url(url: str) -> None:
|
|
130
|
+
"""Validate a URL's scheme and resolved IP addresses.
|
|
131
|
+
|
|
132
|
+
Delegates to ``sunstone.ssrf.is_public_url`` for scheme, DNS resolution,
|
|
133
|
+
and IP blocklist checks. Raises :class:`SSRFError` on rejection.
|
|
134
|
+
"""
|
|
135
|
+
if not is_public_url(url):
|
|
136
|
+
raise SSRFError(f"URL blocked by SSRF protection: {url}")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _strip_sensitive_headers(
|
|
140
|
+
headers: dict[str, str],
|
|
141
|
+
original_url: str,
|
|
142
|
+
redirect_url: str,
|
|
143
|
+
) -> dict[str, str]:
|
|
144
|
+
"""Strip sensitive headers when redirecting across origins."""
|
|
145
|
+
orig = urlparse(original_url)
|
|
146
|
+
redir = urlparse(redirect_url)
|
|
147
|
+
|
|
148
|
+
same_origin = orig.scheme == redir.scheme and orig.hostname == redir.hostname and orig.port == redir.port
|
|
149
|
+
|
|
150
|
+
if same_origin:
|
|
151
|
+
return headers
|
|
152
|
+
|
|
153
|
+
sensitive = {"authorization", "cookie", "proxy-authorization"}
|
|
154
|
+
return {k: v for k, v in headers.items() if k.lower() not in sensitive}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
# Download with SSRF protection
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _download_url(
|
|
163
|
+
url: str,
|
|
164
|
+
progress_callback: Callable[[int, int | None], None] | None = None,
|
|
165
|
+
) -> SourceInfo:
|
|
166
|
+
"""Download URL to temp file with SSRF protection and size limits."""
|
|
167
|
+
# Validate initial URL
|
|
168
|
+
_validate_url(url)
|
|
169
|
+
|
|
170
|
+
# Extract filename from URL
|
|
171
|
+
parsed = urlparse(url)
|
|
172
|
+
url_path = unquote(parsed.path)
|
|
173
|
+
filename = Path(url_path).name or "download"
|
|
174
|
+
|
|
175
|
+
# Determine file type
|
|
176
|
+
file_type = detect_file_type(filename)
|
|
177
|
+
if file_type == FileType.UNKNOWN:
|
|
178
|
+
raise ValueError(f"Cannot determine file type from URL: {url}. URL must end with .csv, .xlsx, or .xls")
|
|
179
|
+
|
|
180
|
+
# Create temp file with appropriate extension
|
|
181
|
+
suffix = Path(filename).suffix
|
|
182
|
+
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
|
183
|
+
tmp_path = Path(tmp.name)
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
current_url = url
|
|
187
|
+
headers: dict[str, str] = {}
|
|
188
|
+
|
|
189
|
+
for _redirect_num in range(MAX_REDIRECTS + 1):
|
|
190
|
+
with httpx.stream(
|
|
191
|
+
"GET",
|
|
192
|
+
current_url,
|
|
193
|
+
follow_redirects=False,
|
|
194
|
+
timeout=60.0,
|
|
195
|
+
headers=headers,
|
|
196
|
+
) as response:
|
|
197
|
+
# Handle redirects manually
|
|
198
|
+
if response.is_redirect:
|
|
199
|
+
redirect_url = str(response.next_request.url) if response.next_request else None
|
|
200
|
+
if not redirect_url:
|
|
201
|
+
raise SSRFError(f"Redirect with no valid target from {current_url}")
|
|
202
|
+
|
|
203
|
+
# Validate redirect target
|
|
204
|
+
_validate_url(redirect_url)
|
|
205
|
+
headers = _strip_sensitive_headers(headers, current_url, redirect_url)
|
|
206
|
+
current_url = redirect_url
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
response.raise_for_status()
|
|
210
|
+
|
|
211
|
+
# Check Content-Length before downloading
|
|
212
|
+
content_length_str = response.headers.get("content-length")
|
|
213
|
+
if content_length_str:
|
|
214
|
+
try:
|
|
215
|
+
content_length = int(content_length_str)
|
|
216
|
+
except ValueError:
|
|
217
|
+
content_length = None # Ignore malformed
|
|
218
|
+
else:
|
|
219
|
+
if content_length > MAX_DOWNLOAD_BYTES:
|
|
220
|
+
raise DownloadTooLargeError(
|
|
221
|
+
f"Content-Length {content_length} exceeds maximum {MAX_DOWNLOAD_BYTES} bytes."
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# Check content type for HTML (error page) detection
|
|
225
|
+
content_type = response.headers.get("content-type", "")
|
|
226
|
+
if "text/html" in content_type and file_type != FileType.CSV:
|
|
227
|
+
raise ValueError(f"URL returned HTML instead of expected file. Content-Type: {content_type}")
|
|
228
|
+
|
|
229
|
+
total = int(content_length_str) if content_length_str else None
|
|
230
|
+
downloaded = 0
|
|
231
|
+
|
|
232
|
+
for chunk in response.iter_bytes(chunk_size=8192):
|
|
233
|
+
downloaded += len(chunk)
|
|
234
|
+
if downloaded > MAX_DOWNLOAD_BYTES:
|
|
235
|
+
raise DownloadTooLargeError(f"Download exceeded maximum {MAX_DOWNLOAD_BYTES} bytes.")
|
|
236
|
+
tmp.write(chunk)
|
|
237
|
+
if progress_callback:
|
|
238
|
+
progress_callback(downloaded, total)
|
|
239
|
+
|
|
240
|
+
# Successful download — break out of redirect loop
|
|
241
|
+
break
|
|
242
|
+
else:
|
|
243
|
+
raise SSRFError(f"Too many redirects ({MAX_REDIRECTS}) following URL: {url}")
|
|
244
|
+
|
|
245
|
+
tmp.close()
|
|
246
|
+
|
|
247
|
+
return SourceInfo(
|
|
248
|
+
local_path=tmp_path,
|
|
249
|
+
filename=filename,
|
|
250
|
+
file_type=file_type,
|
|
251
|
+
encoding=None,
|
|
252
|
+
is_temp=True,
|
|
253
|
+
source_url=url,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
except Exception:
|
|
257
|
+
tmp.close()
|
|
258
|
+
tmp_path.unlink(missing_ok=True)
|
|
259
|
+
raise
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Resolve the data-owner organization for an import.
|
|
2
|
+
|
|
3
|
+
The owner is a path-structured organization identifier (e.g.
|
|
4
|
+
``un/who/emissions-div``). It is resolved at import time, and the resolved
|
|
5
|
+
path drives three things:
|
|
6
|
+
|
|
7
|
+
- the Iceberg namespace root (governance anchor), via :func:`ensure_root_prefix`;
|
|
8
|
+
- the ``sunstone.owner.org`` table property (full org URI), via :func:`org_uri`;
|
|
9
|
+
- the RDF org tree emitted by the DCAT builder (Phase 2).
|
|
10
|
+
|
|
11
|
+
Resolution precedence is strict — there is no silent default:
|
|
12
|
+
|
|
13
|
+
1. an explicit ``--owner`` flag;
|
|
14
|
+
2. the first matching rule in the ordered source-URL rules file;
|
|
15
|
+
3. otherwise :class:`OwnerUnresolved` is raised.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import yaml
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class OwnerError(ValueError):
|
|
28
|
+
"""Base class for owner-resolution failures.
|
|
29
|
+
|
|
30
|
+
Subclasses ``ValueError`` so the import CLI's existing ``except ValueError``
|
|
31
|
+
handler renders them as clean ``Invalid input`` errors.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OwnerUnresolved(OwnerError):
|
|
36
|
+
"""No owner could be resolved (no --owner and no matching rule)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class OwnerInvalid(OwnerError):
|
|
40
|
+
"""The resolved owner path is malformed or its root is not allow-listed."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class OwnerRule:
|
|
45
|
+
"""One ordered source-URL rule: a regex and the owner path it assigns."""
|
|
46
|
+
|
|
47
|
+
pattern: str
|
|
48
|
+
owner: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_rules(rules_path: Path | None) -> list[OwnerRule]:
|
|
52
|
+
"""Load ordered owner rules from a YAML file.
|
|
53
|
+
|
|
54
|
+
Returns an empty list when ``rules_path`` is None or does not exist, so a
|
|
55
|
+
deployment without a rules file simply has no rules (and must rely on
|
|
56
|
+
``--owner``).
|
|
57
|
+
"""
|
|
58
|
+
if rules_path is None or not rules_path.exists():
|
|
59
|
+
return []
|
|
60
|
+
data = yaml.safe_load(rules_path.read_text(encoding="utf-8")) or {}
|
|
61
|
+
return [OwnerRule(pattern=r["pattern"], owner=r["owner"]) for r in data.get("rules", [])]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _validate(owner: str, org_roots: tuple[str, ...]) -> str:
|
|
65
|
+
"""Normalize and validate an owner path.
|
|
66
|
+
|
|
67
|
+
Strips surrounding slashes/whitespace, rejects empty segments, and (when
|
|
68
|
+
``org_roots`` is non-empty) requires the root segment to be allow-listed.
|
|
69
|
+
An empty ``org_roots`` disables root checking (permissive dev default).
|
|
70
|
+
"""
|
|
71
|
+
stripped = owner.strip().strip("/")
|
|
72
|
+
parts = stripped.split("/")
|
|
73
|
+
if not stripped or any(not p for p in parts):
|
|
74
|
+
raise OwnerInvalid(f"malformed owner path: {owner!r}")
|
|
75
|
+
root = parts[0]
|
|
76
|
+
if org_roots and root not in org_roots:
|
|
77
|
+
raise OwnerInvalid(f"owner root {root!r} not in configured org_roots {list(org_roots)}")
|
|
78
|
+
return "/".join(parts)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def resolve_owner(
|
|
82
|
+
source_url: str,
|
|
83
|
+
owner_flag: str | None,
|
|
84
|
+
*,
|
|
85
|
+
rules: list[OwnerRule],
|
|
86
|
+
org_roots: tuple[str, ...],
|
|
87
|
+
) -> str:
|
|
88
|
+
"""Resolve the owner path for an import. Strict: never returns ownerless.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
source_url: The import's fetch URL (``source_info.source_url``); empty
|
|
92
|
+
for local-file imports, which therefore never match a URL rule.
|
|
93
|
+
owner_flag: The explicit ``--owner`` value, if any.
|
|
94
|
+
rules: Ordered owner rules (first regex match wins).
|
|
95
|
+
org_roots: Allowed root segments; empty disables the check.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
A normalized owner path, e.g. ``"un/who/emissions-div"``.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
OwnerInvalid: If the chosen owner path is malformed or root-disallowed.
|
|
102
|
+
OwnerUnresolved: If no ``--owner`` and no rule matches ``source_url``.
|
|
103
|
+
"""
|
|
104
|
+
if owner_flag:
|
|
105
|
+
return _validate(owner_flag, org_roots)
|
|
106
|
+
for rule in rules:
|
|
107
|
+
if re.search(rule.pattern, source_url):
|
|
108
|
+
return _validate(rule.owner, org_roots)
|
|
109
|
+
raise OwnerUnresolved(f"no owner resolved for {source_url!r}: pass --owner or add a matching source-URL rule")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def owner_root(owner: str) -> str:
|
|
113
|
+
"""Return the root (level-0) segment of an owner path."""
|
|
114
|
+
return owner.strip("/").split("/")[0]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def ensure_root_prefix(namespace: str, root: str) -> str:
|
|
118
|
+
"""Guarantee ``namespace``'s level-0 segment is the owner ``root``.
|
|
119
|
+
|
|
120
|
+
Already-rooted namespaces are returned unchanged (no ``un.un``);
|
|
121
|
+
otherwise the root is prepended as the governance anchor.
|
|
122
|
+
"""
|
|
123
|
+
if namespace.split(".")[0] == root:
|
|
124
|
+
return namespace
|
|
125
|
+
return f"{root}.{namespace}"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def org_uri(owner: str, base_uri: str) -> str:
|
|
129
|
+
"""Build the full, stable org URI for an owner path."""
|
|
130
|
+
return f"{base_uri.rstrip('/')}/{owner.strip('/')}"
|