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,10 @@
1
+ __all__ = [
2
+ 'airflow_utils',
3
+ 'config_utils',
4
+ 'geo_utils',
5
+ 'logging_utils',
6
+ 'azure_as_utils',
7
+ 'sql_utils',
8
+ 'tg_utils',
9
+ 'ones_api_utils',
10
+ ]
@@ -0,0 +1,13 @@
1
+ from .airflow_utils import (
2
+ get_variable_value,
3
+ get_variable_value_dict,
4
+ get_variable_value_int,
5
+ get_variable_value_str,
6
+ )
7
+
8
+ __all__ = [
9
+ "get_variable_value",
10
+ "get_variable_value_dict",
11
+ "get_variable_value_int",
12
+ "get_variable_value_str",
13
+ ]
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import json
5
+ from pathlib import Path
6
+ from sys import platform
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+
12
+ def _get_dags_folder() -> str:
13
+ try:
14
+ from airflow import settings # type: ignore
15
+ return settings.DAGS_FOLDER
16
+ except Exception:
17
+ return "./dags"
18
+
19
+
20
+ def _airflow_variable_get(name: str) -> str:
21
+ from airflow.sdk import Variable # type: ignore
22
+ return Variable.get(name)
23
+
24
+
25
+ @functools.cache
26
+ def get_variable_value(
27
+ airflow_variable_name: str,
28
+ pack_to_json: bool = False,
29
+ source: str = "airflow",
30
+ source_section: str = "variables",
31
+ ) -> Any:
32
+ """Get variable value different ways for windows and linux."""
33
+
34
+ if platform == "win32":
35
+ base = Path(".local/variables")
36
+ data = json.loads((base / "variables.json").read_text(encoding="utf-8"))
37
+ environment_code = data["ENVIRONMENT"]
38
+
39
+ env_data = json.loads(
40
+ (base / f"{environment_code.lower()}_variables.json").read_text(encoding="utf-8")
41
+ )
42
+ variable_value = env_data[airflow_variable_name]
43
+
44
+ else:
45
+ if source == "config":
46
+ dags_folder = _get_dags_folder()
47
+ p = Path(dags_folder) / ".configs" / "_variables.yaml"
48
+ variables = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
49
+ variable_value = variables[source_section][airflow_variable_name]
50
+ else:
51
+ variable_value = _airflow_variable_get(airflow_variable_name)
52
+
53
+ if pack_to_json and not isinstance(variable_value, dict):
54
+ try:
55
+ variable_value = json.loads(variable_value)
56
+ except Exception:
57
+ pass
58
+
59
+ return variable_value
60
+
61
+
62
+ def get_variable_value_str(
63
+ airflow_variable_name: str,
64
+ source: str = "airflow",
65
+ source_section: str = "variables",
66
+ ) -> str:
67
+ return str(get_variable_value(airflow_variable_name, False, source, source_section))
68
+
69
+
70
+ def get_variable_value_int(
71
+ airflow_variable_name: str,
72
+ source: str = "airflow",
73
+ source_section: str = "variables",
74
+ ) -> int:
75
+ return int(get_variable_value(airflow_variable_name, False, source, source_section))
76
+
77
+
78
+ def get_variable_value_dict(
79
+ airflow_variable_name: str,
80
+ source: str = "airflow",
81
+ source_section: str = "variables",
82
+ ) -> dict:
83
+ return dict(get_variable_value(airflow_variable_name, True, source, source_section))
@@ -0,0 +1,31 @@
1
+ from .auth import ServicePrincipalTokenProvider, StaticTokenProvider, TokenProvider
2
+ from .azure_as_utils import SemanticModels
3
+ from .client import AasRestClient
4
+ from .errors import (
5
+ AasAuthError,
6
+ AasError,
7
+ AasHttpError,
8
+ AasRefreshError,
9
+ AasRefreshTimeout,
10
+ AasTransportError,
11
+ )
12
+ from .models import RefreshObject, RefreshRequest, RefreshStatus
13
+ from .poller import RefreshPoller
14
+
15
+ __all__ = [
16
+ 'AasAuthError',
17
+ 'AasError',
18
+ 'AasHttpError',
19
+ 'AasRefreshError',
20
+ 'AasRefreshTimeout',
21
+ 'AasRestClient',
22
+ 'AasTransportError',
23
+ 'RefreshObject',
24
+ 'RefreshPoller',
25
+ 'RefreshRequest',
26
+ 'RefreshStatus',
27
+ 'SemanticModels',
28
+ 'ServicePrincipalTokenProvider',
29
+ 'StaticTokenProvider',
30
+ 'TokenProvider',
31
+ ]
@@ -0,0 +1,82 @@
1
+ """Access tokens for the AAS REST API via an Azure AD service principal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any, Protocol
7
+
8
+ import msal
9
+
10
+ from .errors import AasAuthError
11
+
12
+ #: Resource identifier of Azure Analysis Services; the wildcard is literal.
13
+ AAS_SCOPE = 'https://*.asazure.windows.net/.default'
14
+ DEFAULT_AUTHORITY_HOST = 'https://login.microsoftonline.com'
15
+
16
+
17
+ class TokenProvider(Protocol):
18
+ """Anything that can hand out a bearer token for the AAS resource."""
19
+
20
+ def get_token(self) -> str: ...
21
+
22
+
23
+ class StaticTokenProvider:
24
+ """Returns a fixed token. Useful for tests and pre-issued tokens."""
25
+
26
+ def __init__(self, token: str) -> None:
27
+ if not token:
28
+ raise ValueError('token must be a non-empty string')
29
+ self._token = token
30
+
31
+ def get_token(self) -> str:
32
+ return self._token
33
+
34
+
35
+ class ServicePrincipalTokenProvider:
36
+ """Client-credentials flow through MSAL.
37
+
38
+ MSAL keeps the issued token in its in-memory cache and only goes back to
39
+ Azure AD when it is about to expire, so calling :meth:`get_token` before
40
+ every request is cheap.
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ tenant_id: str,
46
+ client_id: str,
47
+ client_secret: str,
48
+ *,
49
+ authority_host: str = DEFAULT_AUTHORITY_HOST,
50
+ scope: str = AAS_SCOPE,
51
+ ) -> None:
52
+ for name, value in (
53
+ ('tenant_id', tenant_id),
54
+ ('client_id', client_id),
55
+ ('client_secret', client_secret),
56
+ ):
57
+ if not isinstance(value, str) or not value.strip():
58
+ raise ValueError(f'{name} must be a non-empty string')
59
+ self._scopes = [scope]
60
+ self._authority = f'{authority_host.rstrip("/")}/{tenant_id}'
61
+ self._app = msal.ConfidentialClientApplication(
62
+ client_id, authority=self._authority, client_credential=client_secret
63
+ )
64
+
65
+ def get_token(self) -> str:
66
+ result: Mapping[str, Any] | None = self._app.acquire_token_for_client(
67
+ scopes=self._scopes
68
+ )
69
+ token = result.get('access_token') if isinstance(result, Mapping) else None
70
+ if not token:
71
+ raise AasAuthError(_describe_failure(result))
72
+ return token
73
+
74
+
75
+ def _describe_failure(result: Mapping[str, Any] | None) -> str:
76
+ if not isinstance(result, Mapping):
77
+ return f'token request returned no result ({result!r})'
78
+ error = result.get('error') or 'unknown_error'
79
+ description = result.get('error_description') or 'no description'
80
+ correlation = result.get('correlation_id')
81
+ suffix = f' (correlation_id={correlation})' if correlation else ''
82
+ return f'token request failed: {error}: {description}{suffix}'
@@ -0,0 +1,223 @@
1
+ """Refresh Azure Analysis Services tabular models over the REST API.
2
+
3
+ Typical use::
4
+
5
+ from al_data_utils.azure_as_utils import SemanticModels
6
+
7
+ sm = SemanticModels(
8
+ tenant_id=..., client_id=..., client_secret=...,
9
+ rollout='westeurope', server_name='myserver',
10
+ )
11
+ sm.refresh_models([
12
+ ('MyModel', {'Type': 'Full', 'CommitMode': 'transactional',
13
+ 'MaxParallelism': 2, 'RetryCount': 2, 'Objects': []}),
14
+ ])
15
+
16
+ The class only glues together the smaller pieces in this package: a token
17
+ provider (``auth``), the REST client (``client``) and the status poller
18
+ (``poller``). Nothing here touches Airflow, databases or environment
19
+ variables; the caller supplies every credential and setting.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ import time
26
+ from collections.abc import Callable, Iterable, Mapping
27
+ from typing import Any
28
+
29
+ import requests
30
+
31
+ import al_data_utils.logging_utils # noqa: F401 (configures root logging)
32
+
33
+ from .auth import ServicePrincipalTokenProvider, TokenProvider
34
+ from .client import AasRestClient
35
+ from .errors import AasHttpError, AasRefreshError, AasRefreshTimeout, AasTransportError
36
+ from .models import RefreshRequest, RefreshStatus
37
+ from .poller import (
38
+ DEFAULT_POLL_INTERVAL_SECONDS,
39
+ DEFAULT_TIMEOUT_SECONDS,
40
+ RefreshPoller,
41
+ )
42
+
43
+ log = logging.getLogger(__name__)
44
+
45
+ ModelSpec = tuple[str, RefreshRequest | Mapping[str, Any]]
46
+
47
+ #: Errors that mean "this refresh unit failed" rather than "the setup is broken".
48
+ _UNIT_FAILURES = (AasRefreshError, AasRefreshTimeout, AasHttpError, AasTransportError)
49
+
50
+
51
+ class SemanticModels:
52
+ """Refreshes one or more tabular models on a single AAS server.
53
+
54
+ Args:
55
+ tenant_id, client_id, client_secret: Azure AD service principal.
56
+ rollout: AAS region, e.g. ``'westeurope'``.
57
+ server_name: AAS server name (without the ``asazure://`` prefix).
58
+ process_by_partitions: when ``False`` every ``partition`` key is dropped
59
+ from the request objects and duplicate tables are collapsed, so the
60
+ refresh runs at table level.
61
+ debug_mode: when ``True`` each object of a request is refreshed in its
62
+ own call and gets its own log line. Failures do not stop the run;
63
+ one :class:`AasRefreshError` summarising them is raised at the end.
64
+ timeout_seconds: how long a single refresh may run before
65
+ :class:`AasRefreshTimeout` is raised.
66
+ poll_interval_seconds: initial delay between status checks; the poller
67
+ backs off from there.
68
+ request_timeout_seconds: socket timeout for each HTTP call.
69
+ token_provider, session: injection points, mainly for tests.
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ tenant_id: str,
75
+ client_id: str,
76
+ client_secret: str,
77
+ rollout: str,
78
+ server_name: str,
79
+ process_by_partitions: bool = True,
80
+ debug_mode: bool = False,
81
+ *,
82
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
83
+ poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS,
84
+ request_timeout_seconds: float = 60.0,
85
+ token_provider: TokenProvider | None = None,
86
+ session: requests.Session | None = None,
87
+ sleep: Callable[[float], None] = time.sleep,
88
+ ) -> None:
89
+ self.process_by_partitions = bool(process_by_partitions)
90
+ self.debug_mode = bool(debug_mode)
91
+ if token_provider is None:
92
+ token_provider = ServicePrincipalTokenProvider(
93
+ tenant_id, client_id, client_secret
94
+ )
95
+ self.client = AasRestClient(
96
+ rollout,
97
+ server_name,
98
+ token_provider,
99
+ session=session,
100
+ request_timeout_seconds=request_timeout_seconds,
101
+ )
102
+ self.poller = RefreshPoller(
103
+ self.client,
104
+ poll_interval_seconds=poll_interval_seconds,
105
+ timeout_seconds=timeout_seconds,
106
+ sleep=sleep,
107
+ )
108
+
109
+ # -- Public API ----------------------------------------------------------
110
+
111
+ def refresh_models(self, models: Iterable[ModelSpec]) -> None:
112
+ """Refresh every ``(model_name, refresh_request)`` pair in order.
113
+
114
+ Outside debug mode the first failure raises immediately. In debug mode
115
+ all units of all models are attempted and a single
116
+ :class:`AasRefreshError` is raised afterwards if any of them failed.
117
+ """
118
+ specs = list(models)
119
+ failures: list[str] = []
120
+ for model_name, raw_request in specs:
121
+ failures.extend(self._refresh_model_collecting(model_name, raw_request))
122
+ if failures:
123
+ raise AasRefreshError(
124
+ f'{len(failures)} refresh unit(s) failed: ' + '; '.join(failures),
125
+ messages=failures,
126
+ )
127
+
128
+ def refresh_model(
129
+ self, model_name: str, request: RefreshRequest | Mapping[str, Any]
130
+ ) -> list[RefreshStatus]:
131
+ """Refresh a single model and return the terminal status of each unit."""
132
+ results: list[RefreshStatus] = []
133
+ failures: list[str] = []
134
+ for unit in self._units(self.prepare_request(request)):
135
+ status = self._run_unit(model_name, unit, failures)
136
+ if status is not None:
137
+ results.append(status)
138
+ if failures:
139
+ raise AasRefreshError(
140
+ f'{len(failures)} refresh unit(s) of model {model_name!r} failed: '
141
+ + '; '.join(failures),
142
+ model_name=model_name,
143
+ messages=failures,
144
+ )
145
+ return results
146
+
147
+ def prepare_request(
148
+ self, request: RefreshRequest | Mapping[str, Any]
149
+ ) -> RefreshRequest:
150
+ """Apply the instance settings (partition handling) to a raw request."""
151
+ prepared = RefreshRequest.coerce(request)
152
+ if not self.process_by_partitions:
153
+ prepared = prepared.without_partitions()
154
+ return prepared
155
+
156
+ # -- Internals -----------------------------------------------------------
157
+
158
+ def _refresh_model_collecting(
159
+ self, model_name: str, raw_request: RefreshRequest | Mapping[str, Any]
160
+ ) -> list[str]:
161
+ failures: list[str] = []
162
+ for unit in self._units(self.prepare_request(raw_request)):
163
+ self._run_unit(model_name, unit, failures)
164
+ return failures
165
+
166
+ def _units(self, request: RefreshRequest) -> list[RefreshRequest]:
167
+ if not self.debug_mode:
168
+ return [request]
169
+ if request.is_whole_model:
170
+ log.warning(
171
+ 'debug_mode is on but the request has no Objects; '
172
+ 'refreshing the whole model in one call'
173
+ )
174
+ return request.per_object()
175
+
176
+ def _run_unit(
177
+ self, model_name: str, unit: RefreshRequest, failures: list[str]
178
+ ) -> RefreshStatus | None:
179
+ """Run one refresh; append to ``failures`` in debug mode, raise otherwise."""
180
+ label = unit.describe()
181
+ try:
182
+ status = self._start_and_wait(model_name, unit)
183
+ except _UNIT_FAILURES as exc:
184
+ if not self.debug_mode:
185
+ raise
186
+ log.error('model %s | %s | FAILED: %s', model_name, label, exc)
187
+ failures.append(f'{model_name} | {label}: {exc}')
188
+ return None
189
+ if self.debug_mode:
190
+ log.info('model %s | %s | %s', model_name, label, status.status)
191
+ return status
192
+
193
+ def _start_and_wait(self, model_name: str, unit: RefreshRequest) -> RefreshStatus:
194
+ log.info(
195
+ 'model %s: starting %s refresh (%s) on %s/%s',
196
+ model_name,
197
+ unit.refresh_type,
198
+ unit.describe(),
199
+ self.client.host,
200
+ self.client.server_name,
201
+ )
202
+ refresh_id = self.client.start_refresh(model_name, unit)
203
+ log.info('model %s: refresh %s accepted', model_name, refresh_id)
204
+ status = self.poller.wait(model_name, refresh_id)
205
+ if not status.succeeded:
206
+ details = list(status.messages) or status.failed_objects()
207
+ detail_text = f': {"; ".join(details)}' if details else ''
208
+ raise AasRefreshError(
209
+ f'refresh {refresh_id} of model {model_name!r} ended with status '
210
+ f'{status.status!r}{detail_text}',
211
+ model_name=model_name,
212
+ refresh_id=refresh_id,
213
+ status=status.status,
214
+ messages=details,
215
+ )
216
+ log.info(
217
+ 'model %s: refresh %s succeeded (%s -> %s)',
218
+ model_name,
219
+ refresh_id,
220
+ status.start_time,
221
+ status.end_time,
222
+ )
223
+ return status
@@ -0,0 +1,158 @@
1
+ """Thin HTTP layer over the AAS refresh endpoints.
2
+
3
+ Endpoints (see the Azure Analysis Services REST reference):
4
+
5
+ * ``POST /servers/{server}/models/{model}/refreshes`` start
6
+ * ``GET /servers/{server}/models/{model}/refreshes/{id}`` status
7
+ * ``GET /servers/{server}/models/{model}/refreshes`` history
8
+ * ``DELETE /servers/{server}/models/{model}/refreshes/{id}`` cancel
9
+
10
+ A successful start answers ``202 Accepted`` with a ``Location`` header that
11
+ points at the status resource; the last path segment is the refresh id.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+ from urllib.parse import quote, urlsplit
18
+
19
+ import requests
20
+
21
+ from .auth import TokenProvider
22
+ from .errors import AasError, AasHttpError, AasTransportError
23
+ from .models import RefreshRequest, RefreshStatus
24
+
25
+ AAS_DOMAIN = 'asazure.windows.net'
26
+ _BODY_EXCERPT_CHARS = 2000
27
+
28
+
29
+ def rollout_host(rollout: str) -> str:
30
+ """Normalise ``'westeurope'`` or a full host / URL into the API host name."""
31
+ if not isinstance(rollout, str) or not rollout.strip():
32
+ raise ValueError('rollout must be a non-empty string')
33
+ value = rollout.strip().lower()
34
+ if '://' in value:
35
+ value = urlsplit(value).netloc or value
36
+ value = value.strip('/')
37
+ if '.' in value:
38
+ return value
39
+ return f'{value}.{AAS_DOMAIN}'
40
+
41
+
42
+ class AasRestClient:
43
+ """Sends authenticated requests to one AAS server."""
44
+
45
+ def __init__(
46
+ self,
47
+ rollout: str,
48
+ server_name: str,
49
+ token_provider: TokenProvider,
50
+ *,
51
+ session: requests.Session | None = None,
52
+ request_timeout_seconds: float = 60.0,
53
+ ) -> None:
54
+ if not isinstance(server_name, str) or not server_name.strip():
55
+ raise ValueError('server_name must be a non-empty string')
56
+ if request_timeout_seconds <= 0:
57
+ raise ValueError('request_timeout_seconds must be positive')
58
+ self.host = rollout_host(rollout)
59
+ self.server_name = server_name.strip()
60
+ self._token_provider = token_provider
61
+ self._session = session or requests.Session()
62
+ self._timeout = request_timeout_seconds
63
+
64
+ # -- URL helpers ---------------------------------------------------------
65
+
66
+ @property
67
+ def server_url(self) -> str:
68
+ return f'https://{self.host}/servers/{quote(self.server_name, safe="")}'
69
+
70
+ def refreshes_url(self, model_name: str) -> str:
71
+ if not isinstance(model_name, str) or not model_name.strip():
72
+ raise ValueError('model_name must be a non-empty string')
73
+ return f'{self.server_url}/models/{quote(model_name, safe="")}/refreshes'
74
+
75
+ def refresh_url(self, model_name: str, refresh_id: str) -> str:
76
+ return f'{self.refreshes_url(model_name)}/{quote(refresh_id, safe="")}'
77
+
78
+ # -- Operations ----------------------------------------------------------
79
+
80
+ def start_refresh(self, model_name: str, request: RefreshRequest) -> str:
81
+ """Kick off a refresh and return its id."""
82
+ url = self.refreshes_url(model_name)
83
+ response = self._send('POST', url, json=request.to_payload())
84
+ refresh_id = _refresh_id_from_response(response)
85
+ if not refresh_id:
86
+ raise AasError(
87
+ f'POST {url} accepted the refresh but returned no refresh id '
88
+ f'(status {response.status_code}, headers {dict(response.headers)})'
89
+ )
90
+ return refresh_id
91
+
92
+ def get_refresh(self, model_name: str, refresh_id: str) -> RefreshStatus:
93
+ url = self.refresh_url(model_name, refresh_id)
94
+ response = self._send('GET', url)
95
+ try:
96
+ payload = response.json()
97
+ except ValueError as exc:
98
+ raise AasError(
99
+ f'GET {url} returned a non-JSON body: {_excerpt(response)}'
100
+ ) from exc
101
+ if not isinstance(payload, dict):
102
+ raise AasError(f'GET {url} returned unexpected JSON: {payload!r}')
103
+ return RefreshStatus.from_response(refresh_id, payload)
104
+
105
+ def list_refreshes(self, model_name: str) -> list[dict[str, Any]]:
106
+ response = self._send('GET', self.refreshes_url(model_name))
107
+ payload = response.json()
108
+ return (
109
+ [item for item in payload if isinstance(item, dict)]
110
+ if isinstance(payload, list)
111
+ else []
112
+ )
113
+
114
+ def cancel_refresh(self, model_name: str, refresh_id: str) -> None:
115
+ self._send('DELETE', self.refresh_url(model_name, refresh_id))
116
+
117
+ # -- Plumbing ------------------------------------------------------------
118
+
119
+ def _send(self, method: str, url: str, **kwargs: Any) -> requests.Response:
120
+ headers = {
121
+ 'Authorization': f'Bearer {self._token_provider.get_token()}',
122
+ 'Accept': 'application/json',
123
+ }
124
+ try:
125
+ response = self._session.request(
126
+ method, url, headers=headers, timeout=self._timeout, **kwargs
127
+ )
128
+ except requests.RequestException as exc:
129
+ raise AasTransportError(method, url, exc) from exc
130
+ if response.status_code >= 400:
131
+ raise AasHttpError(method, url, response.status_code, _excerpt(response))
132
+ return response
133
+
134
+
135
+ def _excerpt(response: requests.Response) -> str:
136
+ text = response.text or ''
137
+ if len(text) > _BODY_EXCERPT_CHARS:
138
+ return text[:_BODY_EXCERPT_CHARS] + '...'
139
+ return text
140
+
141
+
142
+ def _refresh_id_from_response(response: requests.Response) -> str | None:
143
+ location = response.headers.get('Location') or response.headers.get('location')
144
+ if location:
145
+ path = urlsplit(location).path or location
146
+ tail = path.rstrip('/').rsplit('/', 1)[-1]
147
+ if tail:
148
+ return tail
149
+ try:
150
+ body = response.json()
151
+ except ValueError:
152
+ return None
153
+ if isinstance(body, dict):
154
+ for key in ('operationId', 'refreshId', 'id'):
155
+ value = body.get(key)
156
+ if isinstance(value, str) and value:
157
+ return value
158
+ return None
@@ -0,0 +1,78 @@
1
+ """Exception hierarchy for the Azure Analysis Services helpers.
2
+
3
+ Every error raised on purpose by this package derives from :class:`AasError`,
4
+ so callers can catch one type when they do not care about the details.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Sequence
10
+
11
+
12
+ class AasError(Exception):
13
+ """Base class for all errors raised by ``azure_as_utils``."""
14
+
15
+
16
+ class AasAuthError(AasError):
17
+ """Acquiring an access token for the AAS server failed."""
18
+
19
+
20
+ class AasTransportError(AasError):
21
+ """The HTTP request never produced a response (DNS, TLS, timeout, ...)."""
22
+
23
+ def __init__(self, method: str, url: str, reason: BaseException) -> None:
24
+ self.method = method
25
+ self.url = url
26
+ self.reason = reason
27
+ super().__init__(f'{method} {url} failed: {reason!r}')
28
+
29
+
30
+ class AasHttpError(AasError):
31
+ """The AAS REST endpoint answered with a non-success HTTP status."""
32
+
33
+ def __init__(self, method: str, url: str, status_code: int, body: str) -> None:
34
+ self.method = method
35
+ self.url = url
36
+ self.status_code = status_code
37
+ self.body = body
38
+ super().__init__(f'{method} {url} -> HTTP {status_code}: {body}')
39
+
40
+ @property
41
+ def is_transient(self) -> bool:
42
+ """True for throttling and server-side failures worth retrying."""
43
+ return self.status_code == 429 or self.status_code >= 500
44
+
45
+
46
+ class AasRefreshError(AasError):
47
+ """A refresh ended in a state other than ``succeeded``."""
48
+
49
+ def __init__(
50
+ self,
51
+ message: str,
52
+ *,
53
+ model_name: str | None = None,
54
+ refresh_id: str | None = None,
55
+ status: str | None = None,
56
+ messages: Sequence[str] = (),
57
+ ) -> None:
58
+ self.model_name = model_name
59
+ self.refresh_id = refresh_id
60
+ self.status = status
61
+ self.messages = tuple(messages)
62
+ super().__init__(message)
63
+
64
+
65
+ class AasRefreshTimeout(AasError):
66
+ """A refresh was still running when the polling deadline passed."""
67
+
68
+ def __init__(
69
+ self, model_name: str, refresh_id: str, timeout_seconds: float, status: str
70
+ ) -> None:
71
+ self.model_name = model_name
72
+ self.refresh_id = refresh_id
73
+ self.timeout_seconds = timeout_seconds
74
+ self.status = status
75
+ super().__init__(
76
+ f'refresh {refresh_id} of model {model_name!r} still {status!r} after '
77
+ f'{timeout_seconds:.0f}s'
78
+ )