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.
Files changed (66) hide show
  1. sunstone_data/__init__.py +36 -0
  2. sunstone_data/_version.py +24 -0
  3. sunstone_data/auth/__init__.py +43 -0
  4. sunstone_data/auth/client_creds.py +239 -0
  5. sunstone_data/auth/pkce.py +307 -0
  6. sunstone_data/auth/store.py +97 -0
  7. sunstone_data/catalog.py +175 -0
  8. sunstone_data/cli/__init__.py +5 -0
  9. sunstone_data/cli/_app.py +26 -0
  10. sunstone_data/cli/_errors.py +31 -0
  11. sunstone_data/cli/commands/__init__.py +22 -0
  12. sunstone_data/cli/commands/data_auth.py +120 -0
  13. sunstone_data/cli/commands/data_import.py +157 -0
  14. sunstone_data/cli/commands/data_info.py +166 -0
  15. sunstone_data/cli/commands/data_publish.py +69 -0
  16. sunstone_data/cli/commands/ls.py +49 -0
  17. sunstone_data/cli/commands/prov.py +58 -0
  18. sunstone_data/cli/commands/refs.py +142 -0
  19. sunstone_data/cli/commands/show.py +139 -0
  20. sunstone_data/cli/commands/snapshots.py +77 -0
  21. sunstone_data/cli/commands/sparql.py +68 -0
  22. sunstone_data/cli/main.py +30 -0
  23. sunstone_data/config.py +250 -0
  24. sunstone_data/discovery.py +317 -0
  25. sunstone_data/env_commands.py +97 -0
  26. sunstone_data/env_section.py +86 -0
  27. sunstone_data/graph/__init__.py +36 -0
  28. sunstone_data/graph/client.py +328 -0
  29. sunstone_data/graph/dataset.py +438 -0
  30. sunstone_data/graph/terms.py +34 -0
  31. sunstone_data/identifiers.py +155 -0
  32. sunstone_data/import_/__init__.py +28 -0
  33. sunstone_data/import_/fetcher.py +259 -0
  34. sunstone_data/import_/owner.py +130 -0
  35. sunstone_data/import_/parser.py +492 -0
  36. sunstone_data/import_/pipeline.py +275 -0
  37. sunstone_data/import_/storage.py +135 -0
  38. sunstone_data/metadata/__init__.py +41 -0
  39. sunstone_data/metadata/builder.py +276 -0
  40. sunstone_data/metadata/models.py +87 -0
  41. sunstone_data/metadata/provenance.py +171 -0
  42. sunstone_data/metadata/provenance_rdf.py +102 -0
  43. sunstone_data/plugin.py +62 -0
  44. sunstone_data/publish.py +134 -0
  45. sunstone_data/query.py +624 -0
  46. sunstone_data/resolve.py +178 -0
  47. sunstone_data/url.py +78 -0
  48. sunstone_data/utils/__init__.py +12 -0
  49. sunstone_data/utils/checksum.py +14 -0
  50. sunstone_data/utils/encoding.py +16 -0
  51. sunstone_data/utils/naming.py +38 -0
  52. sunstone_data/versioning/__init__.py +17 -0
  53. sunstone_data/versioning/schema_evolution.py +180 -0
  54. sunstone_data_platform-1.0.0.dist-info/METADATA +302 -0
  55. sunstone_data_platform-1.0.0.dist-info/RECORD +66 -0
  56. sunstone_data_platform-1.0.0.dist-info/WHEEL +4 -0
  57. sunstone_data_platform-1.0.0.dist-info/entry_points.txt +2 -0
  58. sunstone_pipeline/__init__.py +1 -0
  59. sunstone_pipeline/assets/__init__.py +11 -0
  60. sunstone_pipeline/assets/mirrors.py +123 -0
  61. sunstone_pipeline/assets/transforms.py +74 -0
  62. sunstone_pipeline/definitions.py +25 -0
  63. sunstone_pipeline/io_manager.py +56 -0
  64. sunstone_pipeline/resources/__init__.py +6 -0
  65. sunstone_pipeline/resources/iceberg.py +29 -0
  66. 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('/')}"