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.
Files changed (127) hide show
  1. weaver/__init__.py +59 -0
  2. weaver/build_bundle/__init__.py +109 -0
  3. weaver/build_bundle/aliases.py +325 -0
  4. weaver/build_bundle/bundle.py +359 -0
  5. weaver/build_bundle/catalogue_actions.py +275 -0
  6. weaver/build_bundle/changes.py +186 -0
  7. weaver/build_bundle/endpoints.py +83 -0
  8. weaver/build_bundle/executors/__init__.py +69 -0
  9. weaver/build_bundle/executors/alias.py +202 -0
  10. weaver/build_bundle/executors/base.py +132 -0
  11. weaver/build_bundle/executors/folder.py +71 -0
  12. weaver/build_bundle/executors/load_file.py +205 -0
  13. weaver/build_bundle/executors/spark_case.py +26 -0
  14. weaver/build_bundle/executors/spark_schema.py +60 -0
  15. weaver/build_bundle/executors/spark_sql.py +59 -0
  16. weaver/build_bundle/executors/spark_sql_batch.py +57 -0
  17. weaver/build_bundle/executors/spark_table.py +213 -0
  18. weaver/build_bundle/executors/sql_endpoint_refresh.py +34 -0
  19. weaver/build_bundle/executors/tsql.py +81 -0
  20. weaver/build_bundle/incremental.py +288 -0
  21. weaver/build_bundle/installer.py +384 -0
  22. weaver/build_bundle/models.py +288 -0
  23. weaver/build_bundle/payloads.py +34 -0
  24. weaver/build_bundle/physical.py +625 -0
  25. weaver/build_bundle/planner.py +389 -0
  26. weaver/build_bundle/prune.py +620 -0
  27. weaver/build_bundle/report.py +108 -0
  28. weaver/build_bundle/stages.py +196 -0
  29. weaver/build_bundle/targets.py +272 -0
  30. weaver/build_bundle/workflow.py +585 -0
  31. weaver/catalogue/__init__.py +73 -0
  32. weaver/catalogue/builtin.py +238 -0
  33. weaver/catalogue/claims.py +121 -0
  34. weaver/catalogue/projection.py +437 -0
  35. weaver/catalogue/reader.py +152 -0
  36. weaver/catalogue/reconcile.py +231 -0
  37. weaver/catalogue/render.py +410 -0
  38. weaver/catalogue/state.py +660 -0
  39. weaver/catalogue/tables.py +648 -0
  40. weaver/config.py +178 -0
  41. weaver/declaration/__init__.py +171 -0
  42. weaver/declaration/columns.py +223 -0
  43. weaver/declaration/ddl.py +266 -0
  44. weaver/declaration/dependencies.py +544 -0
  45. weaver/declaration/graph.py +240 -0
  46. weaver/declaration/item_dependencies.py +292 -0
  47. weaver/declaration/load.py +191 -0
  48. weaver/declaration/metadata.py +1405 -0
  49. weaver/declaration/model.py +448 -0
  50. weaver/declaration/references.py +294 -0
  51. weaver/declaration/repository.py +959 -0
  52. weaver/declaration/schemas.py +135 -0
  53. weaver/declaration/source.py +674 -0
  54. weaver/declaration/spark_load.py +759 -0
  55. weaver/declaration/sql_shaping.py +591 -0
  56. weaver/declaration/templates/ddl/declared_create_table.sql +64 -0
  57. weaver/declaration/templates/ddl/infer_create_table.sql +97 -0
  58. weaver/declaration/templates/ddl/metadata_column_validation.sql +30 -0
  59. weaver/declaration/templates/load/column_metadata.sql +40 -0
  60. weaver/declaration/templates/load/full_replace_body.sql +21 -0
  61. weaver/declaration/templates/load/install_load_procedure.sql +27 -0
  62. weaver/declaration/templates/load/load_procedure.sql +48 -0
  63. weaver/declaration/templates/load/primary_key_body.sql +113 -0
  64. weaver/declaration/tsql_ddl.py +468 -0
  65. weaver/declaration/tsql_load.py +417 -0
  66. weaver/declaration/warehouse_type_mapping.yml +93 -0
  67. weaver/diagnostics.py +247 -0
  68. weaver/errors.py +61 -0
  69. weaver/etl.py +469 -0
  70. weaver/fabric/__init__.py +107 -0
  71. weaver/fabric/auth.py +137 -0
  72. weaver/fabric/capacity.py +143 -0
  73. weaver/fabric/client.py +147 -0
  74. weaver/fabric/environment.py +460 -0
  75. weaver/fabric/livy.py +478 -0
  76. weaver/fabric/notebooks.py +201 -0
  77. weaver/fabric/onelake.py +263 -0
  78. weaver/fabric/resolution.py +344 -0
  79. weaver/fabric/resources.py +245 -0
  80. weaver/fabric/session.py +148 -0
  81. weaver/fabric/shortcuts.py +120 -0
  82. weaver/fabric/sql.py +118 -0
  83. weaver/fabric/store.py +198 -0
  84. weaver/initialise.py +209 -0
  85. weaver/lakehouse.py +386 -0
  86. weaver/load.py +474 -0
  87. weaver/load_execution.py +483 -0
  88. weaver/load_plan.py +912 -0
  89. weaver/load_report.py +330 -0
  90. weaver/load_resolution.py +386 -0
  91. weaver/locations.py +164 -0
  92. weaver/objects.py +392 -0
  93. weaver/operations.py +757 -0
  94. weaver/physical_wipe.py +369 -0
  95. weaver/push.py +76 -0
  96. weaver/resolution.py +292 -0
  97. weaver/runtime/__init__.py +30 -0
  98. weaver/runtime/folder_load.py +402 -0
  99. weaver/runtime/load_contract.py +245 -0
  100. weaver/runtime/load_result.py +104 -0
  101. weaver/runtime/spark_load.py +152 -0
  102. weaver/runtime/table_load.py +497 -0
  103. weaver/spark/__init__.py +49 -0
  104. weaver/spark/catalogue.py +245 -0
  105. weaver/spark/destination.py +195 -0
  106. weaver/spark/session.py +84 -0
  107. weaver/spark/tokens.py +138 -0
  108. weaver/sql/__init__.py +40 -0
  109. weaver/sql/authentication.py +38 -0
  110. weaver/sql/connection.py +90 -0
  111. weaver/sql/errors.py +25 -0
  112. weaver/sql/execution.py +123 -0
  113. weaver/sql/pool.py +174 -0
  114. weaver/sql/wipe.py +156 -0
  115. weaver/store.py +209 -0
  116. weaver/targets.py +257 -0
  117. weaver/task_logging.py +215 -0
  118. weaver/unbind.py +74 -0
  119. weaver/workspaces.py +175 -0
  120. weaver_cli/__init__.py +12 -0
  121. weaver_cli/__main__.py +7 -0
  122. weaver_cli/main.py +626 -0
  123. weaverstack-0.1.1.dist-info/METADATA +113 -0
  124. weaverstack-0.1.1.dist-info/RECORD +127 -0
  125. weaverstack-0.1.1.dist-info/WHEEL +4 -0
  126. weaverstack-0.1.1.dist-info/entry_points.txt +2 -0
  127. 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")
@@ -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
+ )