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.
- al_data_utils/__init__.py +10 -0
- al_data_utils/airflow_utils/__init__.py +13 -0
- al_data_utils/airflow_utils/airflow_utils.py +83 -0
- al_data_utils/azure_as_utils/__init__.py +31 -0
- al_data_utils/azure_as_utils/auth.py +82 -0
- al_data_utils/azure_as_utils/azure_as_utils.py +223 -0
- al_data_utils/azure_as_utils/client.py +158 -0
- al_data_utils/azure_as_utils/errors.py +78 -0
- al_data_utils/azure_as_utils/models.py +265 -0
- al_data_utils/azure_as_utils/poller.py +121 -0
- al_data_utils/backup_utils/__init__.py +9 -0
- al_data_utils/backup_utils/mssql/dacpac/__init__.py +1 -0
- al_data_utils/backup_utils/mssql/dacpac/sqlpackage.py +207 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/__init__.py +8 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/config.py +150 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/emit/__init__.py +11 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/emit/foreign_keys.py +38 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/emit/indexes.py +37 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/emit/sql_objects.py +17 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/emit/tables.py +139 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/introspect/__init__.py +19 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/introspect/foreign_keys.py +173 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/introspect/functions.py +133 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/introspect/indexes.py +190 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/introspect/sequences.py +180 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/introspect/stored_procedures.py +313 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/introspect/synonyms.py +135 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/introspect/tables.py +251 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/introspect/views.py +244 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/io/__init__.py +10 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/io/layout.py +28 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/io/writer.py +40 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/models/__init__.py +14 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/models/foreign_key_def.py +19 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/models/index_def.py +19 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/models/sql_object_def.py +11 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/models/table_column_def.py +27 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/models/table_def.py +22 -0
- al_data_utils/backup_utils/mssql/schema_snapshot/snapshot_processor.py +754 -0
- al_data_utils/backup_utils/mssql/scripter/__init__.py +1 -0
- al_data_utils/config_utils/__init__.py +6 -0
- al_data_utils/config_utils/config_utils.py +31 -0
- al_data_utils/files_sync_utils/__init__.py +15 -0
- al_data_utils/files_sync_utils/files_sync_utils.py +567 -0
- al_data_utils/geo_utils/__init__.py +0 -0
- al_data_utils/git_utils/__init__.py +27 -0
- al_data_utils/git_utils/git_utils.py +142 -0
- al_data_utils/logging_utils/__init__.py +3 -0
- al_data_utils/logging_utils/logging_utils.py +39 -0
- al_data_utils/ones_api_utils/__init__.py +3 -0
- al_data_utils/ones_api_utils/ones_api_utils.py +61 -0
- al_data_utils/sql_utils/__init__.py +3 -0
- al_data_utils/sql_utils/sql_utils.py +158 -0
- al_data_utils/tg_utils/__init__.py +3 -0
- al_data_utils/tg_utils/tg_utils.py +45 -0
- al_data_utils-0.3.1.dist-info/METADATA +157 -0
- al_data_utils-0.3.1.dist-info/RECORD +60 -0
- al_data_utils-0.3.1.dist-info/WHEEL +5 -0
- al_data_utils-0.3.1.dist-info/licenses/LICENSE +21 -0
- 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
|