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,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
|
+
)
|