al-data-utils 0.3.1__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 (60) hide show
  1. al_data_utils/__init__.py +10 -0
  2. al_data_utils/airflow_utils/__init__.py +13 -0
  3. al_data_utils/airflow_utils/airflow_utils.py +83 -0
  4. al_data_utils/azure_as_utils/__init__.py +31 -0
  5. al_data_utils/azure_as_utils/auth.py +82 -0
  6. al_data_utils/azure_as_utils/azure_as_utils.py +223 -0
  7. al_data_utils/azure_as_utils/client.py +158 -0
  8. al_data_utils/azure_as_utils/errors.py +78 -0
  9. al_data_utils/azure_as_utils/models.py +265 -0
  10. al_data_utils/azure_as_utils/poller.py +121 -0
  11. al_data_utils/backup_utils/__init__.py +9 -0
  12. al_data_utils/backup_utils/mssql/dacpac/__init__.py +1 -0
  13. al_data_utils/backup_utils/mssql/dacpac/sqlpackage.py +207 -0
  14. al_data_utils/backup_utils/mssql/schema_snapshot/__init__.py +8 -0
  15. al_data_utils/backup_utils/mssql/schema_snapshot/config.py +150 -0
  16. al_data_utils/backup_utils/mssql/schema_snapshot/emit/__init__.py +11 -0
  17. al_data_utils/backup_utils/mssql/schema_snapshot/emit/foreign_keys.py +38 -0
  18. al_data_utils/backup_utils/mssql/schema_snapshot/emit/indexes.py +37 -0
  19. al_data_utils/backup_utils/mssql/schema_snapshot/emit/sql_objects.py +17 -0
  20. al_data_utils/backup_utils/mssql/schema_snapshot/emit/tables.py +139 -0
  21. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/__init__.py +19 -0
  22. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/foreign_keys.py +173 -0
  23. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/functions.py +133 -0
  24. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/indexes.py +190 -0
  25. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/sequences.py +180 -0
  26. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/stored_procedures.py +313 -0
  27. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/synonyms.py +135 -0
  28. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/tables.py +251 -0
  29. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/views.py +244 -0
  30. al_data_utils/backup_utils/mssql/schema_snapshot/io/__init__.py +10 -0
  31. al_data_utils/backup_utils/mssql/schema_snapshot/io/layout.py +28 -0
  32. al_data_utils/backup_utils/mssql/schema_snapshot/io/writer.py +40 -0
  33. al_data_utils/backup_utils/mssql/schema_snapshot/models/__init__.py +14 -0
  34. al_data_utils/backup_utils/mssql/schema_snapshot/models/foreign_key_def.py +19 -0
  35. al_data_utils/backup_utils/mssql/schema_snapshot/models/index_def.py +19 -0
  36. al_data_utils/backup_utils/mssql/schema_snapshot/models/sql_object_def.py +11 -0
  37. al_data_utils/backup_utils/mssql/schema_snapshot/models/table_column_def.py +27 -0
  38. al_data_utils/backup_utils/mssql/schema_snapshot/models/table_def.py +22 -0
  39. al_data_utils/backup_utils/mssql/schema_snapshot/snapshot_processor.py +754 -0
  40. al_data_utils/backup_utils/mssql/scripter/__init__.py +1 -0
  41. al_data_utils/config_utils/__init__.py +6 -0
  42. al_data_utils/config_utils/config_utils.py +31 -0
  43. al_data_utils/files_sync_utils/__init__.py +15 -0
  44. al_data_utils/files_sync_utils/files_sync_utils.py +567 -0
  45. al_data_utils/geo_utils/__init__.py +0 -0
  46. al_data_utils/git_utils/__init__.py +27 -0
  47. al_data_utils/git_utils/git_utils.py +142 -0
  48. al_data_utils/logging_utils/__init__.py +3 -0
  49. al_data_utils/logging_utils/logging_utils.py +39 -0
  50. al_data_utils/ones_api_utils/__init__.py +3 -0
  51. al_data_utils/ones_api_utils/ones_api_utils.py +61 -0
  52. al_data_utils/sql_utils/__init__.py +3 -0
  53. al_data_utils/sql_utils/sql_utils.py +158 -0
  54. al_data_utils/tg_utils/__init__.py +3 -0
  55. al_data_utils/tg_utils/tg_utils.py +45 -0
  56. al_data_utils-0.3.1.dist-info/METADATA +157 -0
  57. al_data_utils-0.3.1.dist-info/RECORD +60 -0
  58. al_data_utils-0.3.1.dist-info/WHEEL +5 -0
  59. al_data_utils-0.3.1.dist-info/licenses/LICENSE +21 -0
  60. al_data_utils-0.3.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,265 @@
1
+ """Value objects describing a refresh request and its observed status.
2
+
3
+ The AAS REST API speaks JSON with PascalCase keys on the way in (``Type``,
4
+ ``CommitMode``, ``Objects`` ...) and camelCase keys on the way out
5
+ (``status``, ``startTime`` ...). The dataclasses here give both sides a typed
6
+ shape while still accepting the plain dicts callers have always passed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterable, Mapping
12
+ from dataclasses import dataclass, field, replace
13
+ from typing import Any
14
+
15
+ REFRESH_TYPES: frozenset[str] = frozenset(
16
+ {'full', 'clearvalues', 'calculate', 'dataonly', 'automatic', 'defragment'}
17
+ )
18
+ COMMIT_MODES: frozenset[str] = frozenset({'transactional', 'partialbatch'})
19
+
20
+ #: Statuses reported while a refresh has not finished yet.
21
+ ACTIVE_STATUSES: frozenset[str] = frozenset({'notstarted', 'inprogress'})
22
+ SUCCEEDED_STATUS = 'succeeded'
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class RefreshObject:
27
+ """One unit of work inside a refresh: a whole table or a single partition."""
28
+
29
+ table: str
30
+ partition: str | None = None
31
+
32
+ def __post_init__(self) -> None:
33
+ if not isinstance(self.table, str) or not self.table.strip():
34
+ raise ValueError('refresh object needs a non-empty "table" name')
35
+ if self.partition is not None and not isinstance(self.partition, str):
36
+ raise ValueError('"partition" must be a string when given')
37
+ if self.partition is not None and not self.partition.strip():
38
+ object.__setattr__(self, 'partition', None)
39
+
40
+ @classmethod
41
+ def coerce(cls, value: RefreshObject | Mapping[str, Any]) -> RefreshObject:
42
+ """Accept an existing instance or a ``{'table': .., 'partition': ..}`` dict."""
43
+ if isinstance(value, RefreshObject):
44
+ return value
45
+ if not isinstance(value, Mapping):
46
+ raise TypeError(f'refresh object must be a mapping, got {type(value)!r}')
47
+ lowered = {str(key).lower(): val for key, val in value.items()}
48
+ unknown = sorted(set(lowered) - {'table', 'partition'})
49
+ if unknown:
50
+ raise ValueError(f'unsupported refresh object keys: {unknown}')
51
+ if 'table' not in lowered:
52
+ raise ValueError(f'refresh object is missing "table": {dict(value)!r}')
53
+ return cls(table=lowered['table'], partition=lowered.get('partition'))
54
+
55
+ @property
56
+ def label(self) -> str:
57
+ """Short human-readable name used in log lines."""
58
+ if self.partition is None:
59
+ return self.table
60
+ return f'{self.table} / {self.partition}'
61
+
62
+ def table_only(self) -> RefreshObject:
63
+ """Same table with the partition dropped."""
64
+ return RefreshObject(self.table)
65
+
66
+ def to_payload(self) -> dict[str, str]:
67
+ payload = {'table': self.table}
68
+ if self.partition is not None:
69
+ payload['partition'] = self.partition
70
+ return payload
71
+
72
+
73
+ _REQUEST_KEYS: dict[str, str] = {
74
+ 'type': 'refresh_type',
75
+ 'commitmode': 'commit_mode',
76
+ 'maxparallelism': 'max_parallelism',
77
+ 'retrycount': 'retry_count',
78
+ 'objects': 'objects',
79
+ }
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class RefreshRequest:
84
+ """Body of ``POST .../models/{model}/refreshes``.
85
+
86
+ ``objects`` empty means the whole model. Keys the class does not model
87
+ (for example ``ApplyRefreshPolicy``) survive in ``extra`` and are sent
88
+ untouched.
89
+ """
90
+
91
+ refresh_type: str = 'Full'
92
+ commit_mode: str = 'transactional'
93
+ max_parallelism: int = 10
94
+ retry_count: int = 0
95
+ objects: tuple[RefreshObject, ...] = ()
96
+ extra: Mapping[str, Any] = field(default_factory=dict, hash=False)
97
+
98
+ def __post_init__(self) -> None:
99
+ if (
100
+ not isinstance(self.refresh_type, str)
101
+ or self.refresh_type.lower() not in REFRESH_TYPES
102
+ ):
103
+ raise ValueError(
104
+ f'unsupported refresh Type {self.refresh_type!r}; '
105
+ f'expected one of {sorted(REFRESH_TYPES)}'
106
+ )
107
+ if (
108
+ not isinstance(self.commit_mode, str)
109
+ or self.commit_mode.lower() not in COMMIT_MODES
110
+ ):
111
+ raise ValueError(
112
+ f'unsupported CommitMode {self.commit_mode!r}; '
113
+ f'expected one of {sorted(COMMIT_MODES)}'
114
+ )
115
+ if isinstance(self.max_parallelism, bool) or not isinstance(
116
+ self.max_parallelism, int
117
+ ):
118
+ raise TypeError('MaxParallelism must be an integer')
119
+ if self.max_parallelism < 1:
120
+ raise ValueError('MaxParallelism must be at least 1')
121
+ if isinstance(self.retry_count, bool) or not isinstance(self.retry_count, int):
122
+ raise TypeError('RetryCount must be an integer')
123
+ if self.retry_count < 0:
124
+ raise ValueError('RetryCount cannot be negative')
125
+ if self.objects is None:
126
+ object.__setattr__(self, 'objects', ())
127
+ else:
128
+ object.__setattr__(
129
+ self, 'objects', tuple(RefreshObject.coerce(o) for o in self.objects)
130
+ )
131
+ object.__setattr__(self, 'extra', dict(self.extra or {}))
132
+
133
+ @classmethod
134
+ def coerce(cls, value: RefreshRequest | Mapping[str, Any]) -> RefreshRequest:
135
+ """Accept an existing instance or the raw AAS JSON body as a dict."""
136
+ if isinstance(value, RefreshRequest):
137
+ return value
138
+ if not isinstance(value, Mapping):
139
+ raise TypeError(f'refresh request must be a mapping, got {type(value)!r}')
140
+ kwargs: dict[str, Any] = {}
141
+ extra: dict[str, Any] = {}
142
+ for key, val in value.items():
143
+ target = _REQUEST_KEYS.get(str(key).lower())
144
+ if target is None:
145
+ extra[str(key)] = val
146
+ else:
147
+ kwargs[target] = val
148
+ return cls(extra=extra, **kwargs)
149
+
150
+ @property
151
+ def is_whole_model(self) -> bool:
152
+ return not self.objects
153
+
154
+ def with_objects(self, objects: Iterable[RefreshObject]) -> RefreshRequest:
155
+ return replace(self, objects=tuple(objects))
156
+
157
+ def without_partitions(self) -> RefreshRequest:
158
+ """Collapse partition-level objects to their tables, keeping first-seen order."""
159
+ seen: dict[str, RefreshObject] = {}
160
+ for obj in self.objects:
161
+ seen.setdefault(obj.table, obj.table_only())
162
+ return self.with_objects(seen.values())
163
+
164
+ def per_object(self) -> list[RefreshRequest]:
165
+ """One request per object; a whole-model request yields itself."""
166
+ if self.is_whole_model:
167
+ return [self]
168
+ return [self.with_objects((obj,)) for obj in self.objects]
169
+
170
+ def describe(self) -> str:
171
+ """Short summary used in log lines."""
172
+ if self.is_whole_model:
173
+ return 'whole model'
174
+ return ', '.join(obj.label for obj in self.objects)
175
+
176
+ def to_payload(self) -> dict[str, Any]:
177
+ payload: dict[str, Any] = dict(self.extra)
178
+ payload['Type'] = self.refresh_type
179
+ payload['CommitMode'] = self.commit_mode
180
+ payload['MaxParallelism'] = self.max_parallelism
181
+ payload['RetryCount'] = self.retry_count
182
+ if self.objects:
183
+ payload['Objects'] = [obj.to_payload() for obj in self.objects]
184
+ return payload
185
+
186
+
187
+ def _flatten_messages(raw: Any, prefix: str = '') -> list[str]:
188
+ """Turn the ``messages`` array from a status document into plain strings."""
189
+ out: list[str] = []
190
+ if not isinstance(raw, list):
191
+ return out
192
+ for item in raw:
193
+ if isinstance(item, Mapping):
194
+ text = item.get('message') or item.get('Message') or str(dict(item))
195
+ else:
196
+ text = str(item)
197
+ out.append(f'{prefix}{text}')
198
+ return out
199
+
200
+
201
+ @dataclass(frozen=True)
202
+ class RefreshStatus:
203
+ """Snapshot of ``GET .../refreshes/{refreshId}``."""
204
+
205
+ refresh_id: str
206
+ status: str
207
+ refresh_type: str | None = None
208
+ start_time: str | None = None
209
+ end_time: str | None = None
210
+ objects: tuple[Mapping[str, Any], ...] = ()
211
+ messages: tuple[str, ...] = ()
212
+ raw: Mapping[str, Any] = field(default_factory=dict, hash=False)
213
+
214
+ @classmethod
215
+ def from_response(
216
+ cls, refresh_id: str, payload: Mapping[str, Any]
217
+ ) -> RefreshStatus:
218
+ status = payload.get('status')
219
+ if not isinstance(status, str) or not status:
220
+ raise ValueError(f'status document has no "status" field: {payload!r}')
221
+ objects_raw = payload.get('objects')
222
+ objects: tuple[Mapping[str, Any], ...] = ()
223
+ if isinstance(objects_raw, list):
224
+ objects = tuple(o for o in objects_raw if isinstance(o, Mapping))
225
+ messages = _flatten_messages(payload.get('messages'))
226
+ for obj in objects:
227
+ label = _object_label(obj)
228
+ messages.extend(_flatten_messages(obj.get('messages'), f'[{label}] '))
229
+ return cls(
230
+ refresh_id=refresh_id,
231
+ status=status,
232
+ refresh_type=payload.get('type') or payload.get('currentRefreshType'),
233
+ start_time=payload.get('startTime'),
234
+ end_time=payload.get('endTime'),
235
+ objects=objects,
236
+ messages=tuple(messages),
237
+ raw=dict(payload),
238
+ )
239
+
240
+ @property
241
+ def is_active(self) -> bool:
242
+ return self.status.lower() in ACTIVE_STATUSES
243
+
244
+ @property
245
+ def is_terminal(self) -> bool:
246
+ return not self.is_active
247
+
248
+ @property
249
+ def succeeded(self) -> bool:
250
+ return self.status.lower() == SUCCEEDED_STATUS
251
+
252
+ def failed_objects(self) -> list[str]:
253
+ """Labels of objects whose own status is not succeeded."""
254
+ out: list[str] = []
255
+ for obj in self.objects:
256
+ obj_status = str(obj.get('status', '')).lower()
257
+ if obj_status and obj_status != SUCCEEDED_STATUS:
258
+ out.append(f'{_object_label(obj)}: {obj.get("status")}')
259
+ return out
260
+
261
+
262
+ def _object_label(obj: Mapping[str, Any]) -> str:
263
+ table = obj.get('table') or '?'
264
+ partition = obj.get('partition')
265
+ return f'{table} / {partition}' if partition else str(table)
@@ -0,0 +1,121 @@
1
+ """Waits for a started refresh to reach a terminal state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from collections.abc import Callable
8
+
9
+ from .client import AasRestClient
10
+ from .errors import AasHttpError, AasRefreshTimeout, AasTransportError
11
+ from .models import RefreshStatus
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+ DEFAULT_POLL_INTERVAL_SECONDS = 15.0
16
+ DEFAULT_TIMEOUT_SECONDS = 6 * 60 * 60.0
17
+
18
+
19
+ class RefreshPoller:
20
+ """Polls the status endpoint with a growing interval until the refresh ends.
21
+
22
+ The interval starts at ``poll_interval_seconds`` and is multiplied by
23
+ ``backoff_factor`` after each check, capped at ``max_poll_interval_seconds``
24
+ (eight times the initial interval unless given). Up to
25
+ ``max_consecutive_errors`` transient failures in a row (HTTP 429 / 5xx or
26
+ connection problems) are tolerated before the error is raised.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ client: AasRestClient,
32
+ *,
33
+ poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS,
34
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
35
+ backoff_factor: float = 1.5,
36
+ max_poll_interval_seconds: float | None = None,
37
+ max_consecutive_errors: int = 3,
38
+ sleep: Callable[[float], None] = time.sleep,
39
+ clock: Callable[[], float] = time.monotonic,
40
+ ) -> None:
41
+ if poll_interval_seconds <= 0:
42
+ raise ValueError('poll_interval_seconds must be positive')
43
+ if timeout_seconds <= 0:
44
+ raise ValueError('timeout_seconds must be positive')
45
+ if backoff_factor < 1:
46
+ raise ValueError('backoff_factor must be >= 1')
47
+ if max_consecutive_errors < 0:
48
+ raise ValueError('max_consecutive_errors cannot be negative')
49
+ self._client = client
50
+ self._interval = float(poll_interval_seconds)
51
+ self._timeout = float(timeout_seconds)
52
+ self._backoff = float(backoff_factor)
53
+ self._max_interval = (
54
+ float(max_poll_interval_seconds)
55
+ if max_poll_interval_seconds is not None
56
+ else self._interval * 8
57
+ )
58
+ self._max_interval = max(self._max_interval, self._interval)
59
+ self._max_errors = max_consecutive_errors
60
+ self._sleep = sleep
61
+ self._clock = clock
62
+
63
+ @property
64
+ def timeout_seconds(self) -> float:
65
+ return self._timeout
66
+
67
+ def wait(self, model_name: str, refresh_id: str) -> RefreshStatus:
68
+ """Block until the refresh is no longer active and return its last status.
69
+
70
+ Raises :class:`AasRefreshTimeout` when the deadline passes first. The
71
+ returned status may be a failure; the caller decides what to do.
72
+ """
73
+ started = self._clock()
74
+ deadline = started + self._timeout
75
+ delay = self._interval
76
+ errors_in_a_row = 0
77
+ last_status = 'unknown'
78
+
79
+ while True:
80
+ try:
81
+ status = self._client.get_refresh(model_name, refresh_id)
82
+ except (AasHttpError, AasTransportError) as exc:
83
+ transient = isinstance(exc, AasTransportError) or exc.is_transient
84
+ errors_in_a_row += 1
85
+ if not transient or errors_in_a_row > self._max_errors:
86
+ raise
87
+ log.warning(
88
+ 'model %s refresh %s: status check failed (%d/%d), retrying: %s',
89
+ model_name,
90
+ refresh_id,
91
+ errors_in_a_row,
92
+ self._max_errors,
93
+ exc,
94
+ )
95
+ else:
96
+ errors_in_a_row = 0
97
+ last_status = status.status
98
+ if status.is_terminal:
99
+ return status
100
+ log.info(
101
+ 'model %s refresh %s: %s (elapsed %s)',
102
+ model_name,
103
+ refresh_id,
104
+ status.status,
105
+ _format_elapsed(self._clock() - started),
106
+ )
107
+
108
+ remaining = deadline - self._clock()
109
+ if remaining <= 0:
110
+ raise AasRefreshTimeout(
111
+ model_name, refresh_id, self._timeout, last_status
112
+ )
113
+ self._sleep(min(delay, remaining))
114
+ delay = min(delay * self._backoff, self._max_interval)
115
+
116
+
117
+ def _format_elapsed(seconds: float) -> str:
118
+ total = max(int(seconds), 0)
119
+ hours, rest = divmod(total, 3600)
120
+ minutes, secs = divmod(rest, 60)
121
+ return f'{hours:d}:{minutes:02d}:{secs:02d}'
@@ -0,0 +1,9 @@
1
+ """MSSQL-related utilities under backup_utils.
2
+
3
+ This package is intentionally modular:
4
+ - schema_snapshot: DataGrip-like deterministic schema snapshots (per-table files)
5
+ - dacpac: SQLPackage/DACPAC helpers (planned / migrated from legacy mssql.py)
6
+ - scripter: alternative scripting approach (planned)
7
+ """
8
+
9
+ # Re-exports can be added later if you want a shorter import path.
@@ -0,0 +1 @@
1
+ # placeholder for DACPAC/sqlpackage wrappers (migrate legacy mssql.py here)
@@ -0,0 +1,207 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Optional, Sequence
8
+
9
+ from al_data_utils import logging_utils
10
+
11
+
12
+ class SqlPackageError(RuntimeError):
13
+ pass
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class SqlPackageResult:
18
+ cmd: list[str]
19
+ returncode: int
20
+ stdout: str
21
+ stderr: str
22
+
23
+
24
+ def _run(
25
+ cmd: Sequence[str], cwd: Optional[str | os.PathLike[str]] = None
26
+ ) -> SqlPackageResult:
27
+ cmd_list = [str(x) for x in cmd]
28
+ logging_utils.info(
29
+ f'[sqlpackage] run: {" ".join(cmd_list)} (cwd={cwd or os.getcwd()})'
30
+ )
31
+
32
+ p = subprocess.run(
33
+ cmd_list,
34
+ cwd=str(cwd) if cwd else None,
35
+ stdout=subprocess.PIPE,
36
+ stderr=subprocess.PIPE,
37
+ text=True,
38
+ )
39
+
40
+ out = (p.stdout or '').strip()
41
+ err = (p.stderr or '').strip()
42
+
43
+ if out:
44
+ logging_utils.info(f'[sqlpackage] stdout: {out}')
45
+ if err:
46
+ logging_utils.info(f'[sqlpackage] stderr: {err}')
47
+
48
+ if p.returncode != 0:
49
+ msg = err or out or 'sqlpackage failed'
50
+ logging_utils.error(f'[sqlpackage] fail: {msg}')
51
+ raise SqlPackageError(msg)
52
+
53
+ return SqlPackageResult(
54
+ cmd=cmd_list, returncode=p.returncode, stdout=out, stderr=err
55
+ )
56
+
57
+
58
+ def _sqlpackage_path(sqlpackage_path: Optional[str] = None) -> str:
59
+ return sqlpackage_path or os.environ.get('SQLPACKAGE', 'sqlpackage')
60
+
61
+
62
+ def _normalize_server(server: str) -> str:
63
+ s = server.strip()
64
+ if ':' in s and s.count(':') == 1 and ',' not in s:
65
+ host, port = s.split(':')
66
+ if port.isdigit():
67
+ return f'{host},{port}'
68
+ return s
69
+
70
+
71
+ def extract_dacpac(
72
+ *,
73
+ source_connection_string: Optional[str] = None,
74
+ source_server: Optional[str] = None,
75
+ source_database: Optional[str] = None,
76
+ target_dacpac: str | os.PathLike[str],
77
+ sqlpackage_path: Optional[str] = None,
78
+ access_token: Optional[str] = None,
79
+ verify_extraction: bool = False,
80
+ ) -> SqlPackageResult:
81
+ sp = _sqlpackage_path(sqlpackage_path)
82
+ target = str(Path(target_dacpac))
83
+
84
+ cmd = [sp, '/Action:Extract', f'/TargetFile:{target}']
85
+
86
+ if source_connection_string:
87
+ cmd.append(f'/SourceConnectionString:{source_connection_string}')
88
+ else:
89
+ if not source_server or not source_database:
90
+ raise ValueError(
91
+ 'Provide either source_connection_string OR (source_server and source_database).'
92
+ )
93
+ cmd.append(f'/SourceServerName:{_normalize_server(source_server)}')
94
+ cmd.append(f'/SourceDatabaseName:{source_database}')
95
+
96
+ if access_token:
97
+ cmd.append(f'/AccessToken:{access_token}')
98
+
99
+ if verify_extraction:
100
+ cmd.append('/p:VerifyExtraction=true')
101
+
102
+ return _run(cmd)
103
+
104
+
105
+ def script_schema_from_dacpac(
106
+ *,
107
+ source_dacpac: str | os.PathLike[str],
108
+ empty_target_dacpac: str | os.PathLike[str],
109
+ output_sql: str | os.PathLike[str],
110
+ target_database_name: str,
111
+ sqlpackage_path: Optional[str] = None,
112
+ block_on_possible_data_loss: bool = True,
113
+ drop_objects_not_in_source: bool = False,
114
+ ) -> SqlPackageResult:
115
+ sp = _sqlpackage_path(sqlpackage_path)
116
+ src = str(Path(source_dacpac))
117
+ tgt = str(Path(empty_target_dacpac))
118
+ out = str(Path(output_sql))
119
+
120
+ Path(out).parent.mkdir(parents=True, exist_ok=True)
121
+
122
+ cmd = [
123
+ sp,
124
+ '/Action:Script',
125
+ f'/SourceFile:{src}',
126
+ f'/TargetFile:{tgt}',
127
+ f'/TargetDatabaseName:{target_database_name}',
128
+ f'/OutputPath:{out}',
129
+ f'/p:BlockOnPossibleDataLoss={"true" if block_on_possible_data_loss else "false"}',
130
+ f'/p:DropObjectsNotInSource={"true" if drop_objects_not_in_source else "false"}',
131
+ ]
132
+ return _run(cmd)
133
+
134
+
135
+ def snapshot_schema(
136
+ *,
137
+ out_dir: str | os.PathLike[str],
138
+ empty_target_dacpac: str | os.PathLike[str],
139
+ target_database_name: str,
140
+ # source:
141
+ source_connection_string: Optional[str] = None,
142
+ source_server: Optional[str] = None,
143
+ source_database: Optional[str] = None,
144
+ access_token: Optional[str] = None,
145
+ sqlpackage_path: Optional[str] = None,
146
+ # behavior:
147
+ store_dacpac: bool = False,
148
+ cleanup_temp: bool = True,
149
+ ) -> dict[str, str]:
150
+ """
151
+ Default behavior:
152
+ - writes ONLY schema.sql to out_dir
153
+ - uses a temp dacpac internally
154
+
155
+ If store_dacpac=True:
156
+ - also writes db.dacpac to out_dir
157
+
158
+ Returns:
159
+ {'schema_sql': ..., 'dacpac': ... (only if stored)}
160
+ """
161
+ out_dir_p = Path(out_dir)
162
+ out_dir_p.mkdir(parents=True, exist_ok=True)
163
+
164
+ sql_path = out_dir_p / 'schema.sql'
165
+
166
+ # Choose where dacpac lives
167
+ if store_dacpac:
168
+ dacpac_path = out_dir_p / 'db.dacpac'
169
+ temp_dacpac_path = dacpac_path
170
+ else:
171
+ # temp file (still deterministic output because only schema.sql is committed)
172
+ tmp_dir = out_dir_p / '.tmp'
173
+ tmp_dir.mkdir(parents=True, exist_ok=True)
174
+ temp_dacpac_path = tmp_dir / 'db.dacpac'
175
+
176
+ extract_dacpac(
177
+ source_connection_string=source_connection_string,
178
+ source_server=source_server,
179
+ source_database=source_database,
180
+ target_dacpac=temp_dacpac_path,
181
+ sqlpackage_path=sqlpackage_path,
182
+ access_token=access_token,
183
+ )
184
+
185
+ script_schema_from_dacpac(
186
+ source_dacpac=temp_dacpac_path,
187
+ empty_target_dacpac=empty_target_dacpac,
188
+ output_sql=sql_path,
189
+ target_database_name=target_database_name,
190
+ sqlpackage_path=sqlpackage_path,
191
+ )
192
+
193
+ # cleanup temp dacpac if not stored
194
+ if not store_dacpac and cleanup_temp:
195
+ try:
196
+ Path(temp_dacpac_path).unlink(
197
+ missing_ok=True
198
+ ) # py3.8+ supports missing_ok on Windows too
199
+ except Exception as e:
200
+ logging_utils.info(
201
+ f'[sqlpackage] temp cleanup skipped: {temp_dacpac_path} ({e})'
202
+ )
203
+
204
+ result = {'schema_sql': str(sql_path)}
205
+ if store_dacpac:
206
+ result['dacpac'] = str(out_dir_p / 'db.dacpac')
207
+ return result
@@ -0,0 +1,8 @@
1
+ from .config import SnapshotConfig
2
+ from .snapshot_processor import snapshot_schema, snapshot_schema_from_params
3
+
4
+ __all__ = [
5
+ "SnapshotConfig",
6
+ "snapshot_schema",
7
+ "snapshot_schema_from_params",
8
+ ]