weaverstack 0.1.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.
- weaver/__init__.py +59 -0
- weaver/build_bundle/__init__.py +109 -0
- weaver/build_bundle/aliases.py +325 -0
- weaver/build_bundle/bundle.py +359 -0
- weaver/build_bundle/catalogue_actions.py +275 -0
- weaver/build_bundle/changes.py +186 -0
- weaver/build_bundle/endpoints.py +83 -0
- weaver/build_bundle/executors/__init__.py +69 -0
- weaver/build_bundle/executors/alias.py +202 -0
- weaver/build_bundle/executors/base.py +132 -0
- weaver/build_bundle/executors/folder.py +71 -0
- weaver/build_bundle/executors/load_file.py +205 -0
- weaver/build_bundle/executors/spark_case.py +26 -0
- weaver/build_bundle/executors/spark_schema.py +60 -0
- weaver/build_bundle/executors/spark_sql.py +59 -0
- weaver/build_bundle/executors/spark_sql_batch.py +57 -0
- weaver/build_bundle/executors/spark_table.py +213 -0
- weaver/build_bundle/executors/sql_endpoint_refresh.py +34 -0
- weaver/build_bundle/executors/tsql.py +81 -0
- weaver/build_bundle/incremental.py +288 -0
- weaver/build_bundle/installer.py +384 -0
- weaver/build_bundle/models.py +288 -0
- weaver/build_bundle/payloads.py +34 -0
- weaver/build_bundle/physical.py +625 -0
- weaver/build_bundle/planner.py +389 -0
- weaver/build_bundle/prune.py +620 -0
- weaver/build_bundle/report.py +108 -0
- weaver/build_bundle/stages.py +196 -0
- weaver/build_bundle/targets.py +272 -0
- weaver/build_bundle/workflow.py +585 -0
- weaver/catalogue/__init__.py +73 -0
- weaver/catalogue/builtin.py +238 -0
- weaver/catalogue/claims.py +121 -0
- weaver/catalogue/projection.py +437 -0
- weaver/catalogue/reader.py +152 -0
- weaver/catalogue/reconcile.py +231 -0
- weaver/catalogue/render.py +410 -0
- weaver/catalogue/state.py +660 -0
- weaver/catalogue/tables.py +648 -0
- weaver/config.py +178 -0
- weaver/declaration/__init__.py +171 -0
- weaver/declaration/columns.py +223 -0
- weaver/declaration/ddl.py +266 -0
- weaver/declaration/dependencies.py +544 -0
- weaver/declaration/graph.py +240 -0
- weaver/declaration/item_dependencies.py +292 -0
- weaver/declaration/load.py +191 -0
- weaver/declaration/metadata.py +1405 -0
- weaver/declaration/model.py +448 -0
- weaver/declaration/references.py +294 -0
- weaver/declaration/repository.py +959 -0
- weaver/declaration/schemas.py +135 -0
- weaver/declaration/source.py +674 -0
- weaver/declaration/spark_load.py +759 -0
- weaver/declaration/sql_shaping.py +591 -0
- weaver/declaration/templates/ddl/declared_create_table.sql +64 -0
- weaver/declaration/templates/ddl/infer_create_table.sql +97 -0
- weaver/declaration/templates/ddl/metadata_column_validation.sql +30 -0
- weaver/declaration/templates/load/column_metadata.sql +40 -0
- weaver/declaration/templates/load/full_replace_body.sql +21 -0
- weaver/declaration/templates/load/install_load_procedure.sql +27 -0
- weaver/declaration/templates/load/load_procedure.sql +48 -0
- weaver/declaration/templates/load/primary_key_body.sql +113 -0
- weaver/declaration/tsql_ddl.py +468 -0
- weaver/declaration/tsql_load.py +417 -0
- weaver/declaration/warehouse_type_mapping.yml +93 -0
- weaver/diagnostics.py +247 -0
- weaver/errors.py +61 -0
- weaver/etl.py +469 -0
- weaver/fabric/__init__.py +107 -0
- weaver/fabric/auth.py +137 -0
- weaver/fabric/capacity.py +143 -0
- weaver/fabric/client.py +147 -0
- weaver/fabric/environment.py +460 -0
- weaver/fabric/livy.py +478 -0
- weaver/fabric/notebooks.py +201 -0
- weaver/fabric/onelake.py +263 -0
- weaver/fabric/resolution.py +344 -0
- weaver/fabric/resources.py +245 -0
- weaver/fabric/session.py +148 -0
- weaver/fabric/shortcuts.py +120 -0
- weaver/fabric/sql.py +118 -0
- weaver/fabric/store.py +198 -0
- weaver/initialise.py +209 -0
- weaver/lakehouse.py +386 -0
- weaver/load.py +474 -0
- weaver/load_execution.py +483 -0
- weaver/load_plan.py +912 -0
- weaver/load_report.py +330 -0
- weaver/load_resolution.py +386 -0
- weaver/locations.py +164 -0
- weaver/objects.py +392 -0
- weaver/operations.py +757 -0
- weaver/physical_wipe.py +369 -0
- weaver/push.py +76 -0
- weaver/resolution.py +292 -0
- weaver/runtime/__init__.py +30 -0
- weaver/runtime/folder_load.py +402 -0
- weaver/runtime/load_contract.py +245 -0
- weaver/runtime/load_result.py +104 -0
- weaver/runtime/spark_load.py +152 -0
- weaver/runtime/table_load.py +497 -0
- weaver/spark/__init__.py +49 -0
- weaver/spark/catalogue.py +245 -0
- weaver/spark/destination.py +195 -0
- weaver/spark/session.py +84 -0
- weaver/spark/tokens.py +138 -0
- weaver/sql/__init__.py +40 -0
- weaver/sql/authentication.py +38 -0
- weaver/sql/connection.py +90 -0
- weaver/sql/errors.py +25 -0
- weaver/sql/execution.py +123 -0
- weaver/sql/pool.py +174 -0
- weaver/sql/wipe.py +156 -0
- weaver/store.py +209 -0
- weaver/targets.py +257 -0
- weaver/task_logging.py +215 -0
- weaver/unbind.py +74 -0
- weaver/workspaces.py +175 -0
- weaver_cli/__init__.py +12 -0
- weaver_cli/__main__.py +7 -0
- weaver_cli/main.py +626 -0
- weaverstack-0.1.1.dist-info/METADATA +113 -0
- weaverstack-0.1.1.dist-info/RECORD +127 -0
- weaverstack-0.1.1.dist-info/WHEEL +4 -0
- weaverstack-0.1.1.dist-info/entry_points.txt +2 -0
- weaverstack-0.1.1.dist-info/licenses/LICENSE +201 -0
weaver/fabric/auth.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Azure tokens for Fabric, OneLake and SQL.
|
|
2
|
+
|
|
3
|
+
Core does **not** decide which credential to use. It accepts an injected
|
|
4
|
+
credential and, absent one, falls back to ``DefaultAzureCredential`` — the
|
|
5
|
+
library default — without pinning the chain. Choosing a specific identity is a
|
|
6
|
+
caller's policy, not the core's.
|
|
7
|
+
|
|
8
|
+
That policy matters in practice: ``DefaultAzureCredential`` walks a chain and
|
|
9
|
+
does not always settle on the identity you are signed in as, so on a machine
|
|
10
|
+
where ``az`` works a OneLake write can still fail
|
|
11
|
+
``401 Access token validation failed``. ``azure-identity`` 1.23 honours
|
|
12
|
+
``AZURE_TOKEN_CREDENTIALS`` to pin the chain, and :func:`prefer_cli_credential`
|
|
13
|
+
sets it to ``AzureCliCredential`` — but a **caller** invokes that (the desktop
|
|
14
|
+
CLI does; the Fabric test infrastructure does). Core never sets it as a side
|
|
15
|
+
effect of asking for a token.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
|
|
22
|
+
#: Scopes. Generic technical values, not environment-specific.
|
|
23
|
+
FABRIC_SCOPE = "https://api.fabric.microsoft.com/.default"
|
|
24
|
+
STORAGE_SCOPE = "https://storage.azure.com/.default"
|
|
25
|
+
SQL_SCOPE = "https://database.windows.net/.default"
|
|
26
|
+
|
|
27
|
+
#: Honoured by azure-identity >= 1.23 to pin DefaultAzureCredential's chain.
|
|
28
|
+
CREDENTIAL_ENV = "AZURE_TOKEN_CREDENTIALS"
|
|
29
|
+
DEFAULT_CREDENTIAL = "AzureCliCredential"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def prefer_cli_credential() -> str:
|
|
33
|
+
"""Pin the credential chain to the Azure CLI, unless already chosen.
|
|
34
|
+
|
|
35
|
+
Policy, so a **caller** invokes it — the desktop CLI before a Fabric
|
|
36
|
+
command, the test infrastructure before the Fabric suite. Core never calls
|
|
37
|
+
it, so importing or using the core imposes no credential choice.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
existing = os.environ.get(CREDENTIAL_ENV)
|
|
41
|
+
if existing:
|
|
42
|
+
return existing
|
|
43
|
+
os.environ[CREDENTIAL_ENV] = DEFAULT_CREDENTIAL
|
|
44
|
+
return DEFAULT_CREDENTIAL
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def credential():
|
|
48
|
+
"""A default credential. Callers that want a specific one inject it instead."""
|
|
49
|
+
|
|
50
|
+
from azure.identity import DefaultAzureCredential
|
|
51
|
+
|
|
52
|
+
return DefaultAzureCredential()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_token(scope: str, cred=None) -> str:
|
|
56
|
+
"""An access token for one scope, from an injected credential or the default.
|
|
57
|
+
|
|
58
|
+
Answers the string and drops the expiry, which suits a one-shot command and
|
|
59
|
+
nothing that outlives one. Anything long-lived wants :class:`TokenProvider`.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
return (cred or credential()).get_token(scope).token
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
#: Renew this long before a token lapses, so a call already in flight when the
|
|
66
|
+
#: margin opens still carries a valid one.
|
|
67
|
+
TOKEN_REFRESH_MARGIN_SECONDS = 300.0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TokenProvider:
|
|
71
|
+
"""A token for one scope, renewed shortly before it expires.
|
|
72
|
+
|
|
73
|
+
This exists because holding the *string* is a bug that only shows up in long
|
|
74
|
+
runs, and then shows up as something else. A caller that snapshots
|
|
75
|
+
:func:`get_token` keeps sending the same bearer until the API starts
|
|
76
|
+
answering ``401`` — and because ``AzureCliCredential`` serves from the Azure
|
|
77
|
+
CLI's own cache, the string may already be most of the way through its life
|
|
78
|
+
when it arrives. The usable budget is therefore *not* the nominal lifetime
|
|
79
|
+
and cannot be assumed from it: a run that starts with a nearly-spent token
|
|
80
|
+
has minutes, not an hour.
|
|
81
|
+
|
|
82
|
+
That is not hypothetical. A Fabric suite whose session snapshotted its token
|
|
83
|
+
died twenty minutes in with ``401: no body``, taking every downstream test
|
|
84
|
+
with it, and looked like six unrelated failures.
|
|
85
|
+
|
|
86
|
+
Refetching on every call would also be correct, and is what the SQL path does
|
|
87
|
+
— but there a token is fetched per *connection*. A REST client fetches per
|
|
88
|
+
*request*, and a credential shells out to ``az``, so the expiry is kept and
|
|
89
|
+
the token renewed only when it is close.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
scope: str,
|
|
95
|
+
cred=None,
|
|
96
|
+
*,
|
|
97
|
+
margin: float = TOKEN_REFRESH_MARGIN_SECONDS,
|
|
98
|
+
) -> None:
|
|
99
|
+
self.scope = scope
|
|
100
|
+
self._cred = cred
|
|
101
|
+
self._margin = margin
|
|
102
|
+
self._token: str | None = None
|
|
103
|
+
self._expires_on = 0.0
|
|
104
|
+
|
|
105
|
+
def _credential(self):
|
|
106
|
+
# Built once and kept: constructing one per call would shell out to the
|
|
107
|
+
# CLI every time, which is the cost this class exists to avoid.
|
|
108
|
+
if self._cred is None:
|
|
109
|
+
self._cred = credential()
|
|
110
|
+
return self._cred
|
|
111
|
+
|
|
112
|
+
def __call__(self) -> str:
|
|
113
|
+
import time
|
|
114
|
+
|
|
115
|
+
if self._token is None or time.time() >= self._expires_on - self._margin:
|
|
116
|
+
acquired = self._credential().get_token(self.scope)
|
|
117
|
+
self._token = acquired.token
|
|
118
|
+
# A credential that reports no expiry gets renewed every call. Slow
|
|
119
|
+
# rather than wrong, and no shipped credential does it.
|
|
120
|
+
self._expires_on = float(getattr(acquired, "expires_on", 0) or 0)
|
|
121
|
+
return self._token
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def token_source(token=None, *, scope: str, cred=None):
|
|
125
|
+
"""Normalise what a caller supplied into a zero-argument token source.
|
|
126
|
+
|
|
127
|
+
``None`` builds a renewing :class:`TokenProvider`. A **string** is honoured
|
|
128
|
+
exactly as given — the caller owns it and its lifetime, which is how a Fabric
|
|
129
|
+
session passes on the identity it was handed. A **callable** is used as-is,
|
|
130
|
+
so a caller with its own refresh keeps it.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
if token is None:
|
|
134
|
+
return TokenProvider(scope, cred)
|
|
135
|
+
if callable(token):
|
|
136
|
+
return token
|
|
137
|
+
return lambda: token
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Turning a Fabric capacity on and off.
|
|
2
|
+
|
|
3
|
+
Capacity is billed while it runs, so this is the first and last thing a session
|
|
4
|
+
touches. It goes through the Azure CLI rather than a REST call because capacity
|
|
5
|
+
lives in ARM rather than in the Fabric API, and ``az`` already holds the
|
|
6
|
+
subscription context.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Sequence
|
|
17
|
+
|
|
18
|
+
from ..errors import WeaverError
|
|
19
|
+
|
|
20
|
+
CAPACITY_ACTIONS = ("status", "resume", "suspend")
|
|
21
|
+
|
|
22
|
+
_AZ_VERB = {"status": "show", "resume": "resume", "suspend": "suspend"}
|
|
23
|
+
|
|
24
|
+
#: Environment fallback for the subscription, when az has more than one.
|
|
25
|
+
SUBSCRIPTION_ENV = "FABRIC_SUBSCRIPTION_ID"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CapacityError(WeaverError):
|
|
29
|
+
"""Raised when a capacity action cannot be run."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class CapacityAction:
|
|
34
|
+
"""The outcome of one capacity action."""
|
|
35
|
+
|
|
36
|
+
action: str
|
|
37
|
+
capacity: str
|
|
38
|
+
state: str | None
|
|
39
|
+
sku: str | None = None
|
|
40
|
+
returncode: int = 0
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def running(self) -> bool:
|
|
44
|
+
return (self.state or "").lower() == "active"
|
|
45
|
+
|
|
46
|
+
def __str__(self) -> str:
|
|
47
|
+
detail = f"{self.state or 'unknown'}"
|
|
48
|
+
if self.sku:
|
|
49
|
+
detail += f", {self.sku}"
|
|
50
|
+
return f"{self.capacity}: {detail}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def capacity_command(
|
|
54
|
+
action: str,
|
|
55
|
+
*,
|
|
56
|
+
resource_group: str,
|
|
57
|
+
capacity_name: str,
|
|
58
|
+
subscription_id: str | None = None,
|
|
59
|
+
extra_args: Sequence[str] = (),
|
|
60
|
+
) -> list[str]:
|
|
61
|
+
"""The Azure CLI command for one capacity action, without running it."""
|
|
62
|
+
|
|
63
|
+
verb = _AZ_VERB.get(action)
|
|
64
|
+
if verb is None:
|
|
65
|
+
raise CapacityError(
|
|
66
|
+
f"unknown capacity action {action!r} — expected one of "
|
|
67
|
+
+ ", ".join(CAPACITY_ACTIONS)
|
|
68
|
+
)
|
|
69
|
+
if not resource_group:
|
|
70
|
+
raise CapacityError("a capacity needs its resource group")
|
|
71
|
+
if not capacity_name:
|
|
72
|
+
raise CapacityError("a capacity needs its name")
|
|
73
|
+
|
|
74
|
+
command = [
|
|
75
|
+
"az", "fabric", "capacity", verb,
|
|
76
|
+
"--resource-group", resource_group,
|
|
77
|
+
"--capacity-name", capacity_name,
|
|
78
|
+
]
|
|
79
|
+
if subscription_id:
|
|
80
|
+
command.extend(["--subscription", subscription_id])
|
|
81
|
+
command.extend(extra_args)
|
|
82
|
+
return command
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def run_capacity_action(
|
|
86
|
+
action: str,
|
|
87
|
+
*,
|
|
88
|
+
resource_group: str,
|
|
89
|
+
capacity_name: str,
|
|
90
|
+
subscription_id: str | None = None,
|
|
91
|
+
extra_args: Sequence[str] = (),
|
|
92
|
+
) -> CapacityAction:
|
|
93
|
+
"""Run a capacity action and report the resulting state."""
|
|
94
|
+
|
|
95
|
+
if shutil.which("az") is None:
|
|
96
|
+
from ..diagnostics import install_command
|
|
97
|
+
|
|
98
|
+
raise CapacityError(
|
|
99
|
+
f"the Azure CLI is not installed. Install it: {install_command('azure-cli')}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
command = capacity_command(
|
|
103
|
+
action,
|
|
104
|
+
resource_group=resource_group,
|
|
105
|
+
capacity_name=capacity_name,
|
|
106
|
+
subscription_id=subscription_id or os.environ.get(SUBSCRIPTION_ENV),
|
|
107
|
+
extra_args=(*extra_args, "--output", "json"),
|
|
108
|
+
)
|
|
109
|
+
completed = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
110
|
+
if completed.returncode != 0:
|
|
111
|
+
raise CapacityError(
|
|
112
|
+
f"az {action} failed for {capacity_name!r}: "
|
|
113
|
+
+ (completed.stderr.strip() or completed.stdout.strip() or "no output")
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
payload = _payload(completed.stdout)
|
|
117
|
+
return CapacityAction(
|
|
118
|
+
action=action,
|
|
119
|
+
capacity=capacity_name,
|
|
120
|
+
state=_state(payload),
|
|
121
|
+
sku=(payload.get("sku") or {}).get("name") if payload else None,
|
|
122
|
+
returncode=completed.returncode,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _payload(stdout: str) -> dict:
|
|
127
|
+
text = (stdout or "").strip()
|
|
128
|
+
if not text:
|
|
129
|
+
return {}
|
|
130
|
+
try:
|
|
131
|
+
loaded = json.loads(text)
|
|
132
|
+
except json.JSONDecodeError:
|
|
133
|
+
return {}
|
|
134
|
+
return loaded if isinstance(loaded, dict) else {}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _state(payload: dict) -> str | None:
|
|
138
|
+
"""The running state, which az reports in more than one place."""
|
|
139
|
+
|
|
140
|
+
if not payload:
|
|
141
|
+
return None
|
|
142
|
+
properties = payload.get("properties") or {}
|
|
143
|
+
return properties.get("state") or payload.get("state")
|
weaver/fabric/client.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Fabric REST transport.
|
|
2
|
+
|
|
3
|
+
Thin on purpose: a token, a base URL, and enough error translation that a
|
|
4
|
+
failure says what failed rather than surfacing a bare HTTP status.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ..errors import WeaverError
|
|
14
|
+
from .auth import FABRIC_SCOPE, token_source
|
|
15
|
+
|
|
16
|
+
#: Generic technical defaults, not environment-specific.
|
|
17
|
+
FABRIC_API = "https://api.fabric.microsoft.com/v1"
|
|
18
|
+
ONELAKE_DFS = "https://onelake.dfs.fabric.microsoft.com"
|
|
19
|
+
DEFAULT_TIMEOUT = 60.0
|
|
20
|
+
DEFAULT_OPERATION_TIMEOUT = 900.0
|
|
21
|
+
DEFAULT_OPERATION_POLL_INTERVAL = 2.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FabricError(WeaverError):
|
|
25
|
+
"""Raised when a Fabric API call fails."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, message: str, *, status_code: int | None = None) -> None:
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.status_code = status_code
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FabricClient:
|
|
33
|
+
"""Authenticated access to the Fabric REST API."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
api_base_url: str = FABRIC_API,
|
|
39
|
+
token: str | None = None,
|
|
40
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
41
|
+
) -> None:
|
|
42
|
+
self.api_base_url = api_base_url.rstrip("/")
|
|
43
|
+
self.timeout = timeout
|
|
44
|
+
self._token_source = token_source(token, scope=FABRIC_SCOPE)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def token(self) -> str:
|
|
48
|
+
"""A currently-valid bearer, renewed when it is close to expiring.
|
|
49
|
+
|
|
50
|
+
Read per request rather than cached: a client outlives its token, and a
|
|
51
|
+
stale one surfaces as ``401`` in whatever call happens to be next.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
return self._token_source()
|
|
55
|
+
|
|
56
|
+
def request(
|
|
57
|
+
self,
|
|
58
|
+
method: str,
|
|
59
|
+
path: str,
|
|
60
|
+
*,
|
|
61
|
+
payload: Any = None,
|
|
62
|
+
expected: tuple[int, ...] = (200, 201, 202),
|
|
63
|
+
):
|
|
64
|
+
import requests
|
|
65
|
+
|
|
66
|
+
url = path if path.startswith("http") else f"{self.api_base_url}/{path.lstrip('/')}"
|
|
67
|
+
response = requests.request(
|
|
68
|
+
method,
|
|
69
|
+
url,
|
|
70
|
+
headers={
|
|
71
|
+
"Authorization": f"Bearer {self.token}",
|
|
72
|
+
"Content-Type": "application/json",
|
|
73
|
+
},
|
|
74
|
+
data=json.dumps(payload) if payload is not None else None,
|
|
75
|
+
timeout=self.timeout,
|
|
76
|
+
)
|
|
77
|
+
if response.status_code not in expected:
|
|
78
|
+
raise FabricError(
|
|
79
|
+
f"{method} {url} returned {response.status_code}: "
|
|
80
|
+
f"{response.text.strip()[:400] or 'no body'}",
|
|
81
|
+
status_code=response.status_code,
|
|
82
|
+
)
|
|
83
|
+
return response
|
|
84
|
+
|
|
85
|
+
def get_json(self, path: str) -> dict:
|
|
86
|
+
response = self.request("GET", path, expected=(200,))
|
|
87
|
+
return response.json() if response.content else {}
|
|
88
|
+
|
|
89
|
+
def paged(self, path: str, *, key: str = "value") -> list[dict]:
|
|
90
|
+
"""Every item across a paged listing."""
|
|
91
|
+
|
|
92
|
+
items: list[dict] = []
|
|
93
|
+
next_path: str | None = path
|
|
94
|
+
while next_path:
|
|
95
|
+
payload = self.get_json(next_path)
|
|
96
|
+
items.extend(payload.get(key, []))
|
|
97
|
+
next_path = payload.get("continuationUri")
|
|
98
|
+
return items
|
|
99
|
+
|
|
100
|
+
def wait_for_operation(
|
|
101
|
+
self,
|
|
102
|
+
response,
|
|
103
|
+
*,
|
|
104
|
+
timeout: float = DEFAULT_OPERATION_TIMEOUT,
|
|
105
|
+
poll_interval: float = DEFAULT_OPERATION_POLL_INTERVAL,
|
|
106
|
+
) -> dict:
|
|
107
|
+
"""Wait for a Fabric long-running-operation response to settle."""
|
|
108
|
+
|
|
109
|
+
if response.status_code != 202:
|
|
110
|
+
return response.json() if response.content else {}
|
|
111
|
+
|
|
112
|
+
location = response.headers.get("Location")
|
|
113
|
+
operation_id = response.headers.get("x-ms-operation-id")
|
|
114
|
+
if not location and operation_id:
|
|
115
|
+
location = f"operations/{operation_id}"
|
|
116
|
+
if not location:
|
|
117
|
+
raise FabricError(
|
|
118
|
+
"Fabric accepted a long-running operation without a polling location"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
deadline = time.monotonic() + timeout
|
|
122
|
+
current = response
|
|
123
|
+
while time.monotonic() < deadline:
|
|
124
|
+
retry_after = current.headers.get("Retry-After")
|
|
125
|
+
try:
|
|
126
|
+
delay = float(retry_after) if retry_after is not None else poll_interval
|
|
127
|
+
except (TypeError, ValueError):
|
|
128
|
+
delay = poll_interval
|
|
129
|
+
time.sleep(max(0.0, delay))
|
|
130
|
+
current = self.request("GET", location, expected=(200,))
|
|
131
|
+
body = current.json() if current.content else {}
|
|
132
|
+
status = str(body.get("status") or "").casefold()
|
|
133
|
+
if status == "succeeded":
|
|
134
|
+
return body
|
|
135
|
+
if status in {"failed", "cancelled", "canceled"}:
|
|
136
|
+
error = body.get("error") or {}
|
|
137
|
+
message = error.get("message") if isinstance(error, dict) else None
|
|
138
|
+
raise FabricError(
|
|
139
|
+
f"Fabric operation {operation_id or location} {status}"
|
|
140
|
+
+ (f": {message}" if message else "")
|
|
141
|
+
)
|
|
142
|
+
location = current.headers.get("Location") or location
|
|
143
|
+
|
|
144
|
+
raise FabricError(
|
|
145
|
+
f"Fabric operation {operation_id or location} did not finish within "
|
|
146
|
+
f"{int(timeout)}s"
|
|
147
|
+
)
|