msdev-kit 0.2.2__tar.gz → 0.2.4__tar.gz
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.
- {msdev_kit-0.2.2 → msdev_kit-0.2.4}/PKG-INFO +52 -32
- {msdev_kit-0.2.2 → msdev_kit-0.2.4}/README.md +51 -31
- {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/__init__.py +1 -1
- {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/dataflow.py +256 -41
- {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/dataset.py +10 -14
- {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/notebook.py +10 -12
- {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/pipeline.py +10 -14
- {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/graph/client.py +21 -6
- msdev_kit-0.2.4/msdev_kit/http.py +92 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.4}/pyproject.toml +1 -1
- {msdev_kit-0.2.2 → msdev_kit-0.2.4}/LICENSE +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/auth.py +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/__init__.py +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/admin.py +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/capacity.py +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/database.py +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/kql.py +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/operations.py +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/report.py +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/utilities.py +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/workspace.py +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/graph/__init__.py +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/sharepoint/__init__.py +0 -0
- {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/sharepoint/client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: msdev-kit
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Microsoft developer toolkit: Fabric, MS Graph, and SharePoint
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -65,15 +65,11 @@ Or install from GitHub:
|
|
|
65
65
|
pip install git+https://github.com/Bernardo-Rufino/msdev-kit.git
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
-
For local development
|
|
68
|
+
For local development, use the repository instructions in [Development](docs/development.md).
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
pip install -e .
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
**Requirements:** Python >= 3.10, an Azure app registration with a client ID and client secret.
|
|
70
|
+
**Requirements:** Python >= 3.10. Service principal authentication requires an
|
|
71
|
+
Azure app registration, tenant ID, client ID, and client secret. Interactive
|
|
72
|
+
user authentication is also supported for APIs that permit it.
|
|
77
73
|
|
|
78
74
|
---
|
|
79
75
|
|
|
@@ -89,7 +85,7 @@ from msdev_kit.sharepoint import SharePointClient
|
|
|
89
85
|
auth = Auth(tenant_id="...", client_id="...", client_secret="...")
|
|
90
86
|
|
|
91
87
|
# fabric: list workspaces
|
|
92
|
-
ws = Workspace(auth.get_token('
|
|
88
|
+
ws = Workspace(auth.get_token('pbi'))
|
|
93
89
|
workspaces = ws.list_workspaces_for_user()
|
|
94
90
|
|
|
95
91
|
# graph: look up a user
|
|
@@ -105,7 +101,10 @@ sp.download_file('/Reports/monthly.xlsx', local_dir='./downloads')
|
|
|
105
101
|
|
|
106
102
|
## Authentication
|
|
107
103
|
|
|
108
|
-
|
|
104
|
+
`Auth` owns token acquisition. Fabric and Power BI classes receive a token string,
|
|
105
|
+
while Graph and SharePoint clients receive the `Auth` instance. You can use
|
|
106
|
+
different service principals for different services by instantiating one `Auth`
|
|
107
|
+
per service principal:
|
|
109
108
|
|
|
110
109
|
```python
|
|
111
110
|
from msdev_kit import Auth
|
|
@@ -142,14 +141,16 @@ token = auth.get_token_for_user('fabric')
|
|
|
142
141
|
|
|
143
142
|
### Credentials
|
|
144
143
|
|
|
145
|
-
|
|
144
|
+
For the bundled examples, copy the template and set the values:
|
|
146
145
|
|
|
147
146
|
```shell
|
|
148
|
-
|
|
149
|
-
CLIENT_ID='<YOUR_CLIENT_ID>'
|
|
150
|
-
CLIENT_SECRET='<YOUR_CLIENT_SECRET>'
|
|
147
|
+
cp .env.example .env
|
|
151
148
|
```
|
|
152
149
|
|
|
150
|
+
`msdev-kit` does not load `.env` automatically. The scripts in `examples/` load
|
|
151
|
+
`.env` from the repository root. Applications may pass values directly to
|
|
152
|
+
`Auth`, or load environment variables with their own configuration mechanism.
|
|
153
|
+
|
|
153
154
|
---
|
|
154
155
|
|
|
155
156
|
## Fabric & Power BI
|
|
@@ -239,7 +240,7 @@ pages = rpt.list_report_pages(workspace_id, report_id)
|
|
|
239
240
|
Manage Power BI and Fabric dataflows, including Gen1, Gen2, and Gen2 CI/CD.
|
|
240
241
|
|
|
241
242
|
```python
|
|
242
|
-
df = Dataflow(auth.get_token('
|
|
243
|
+
df = Dataflow(auth.get_token('pbi'))
|
|
243
244
|
|
|
244
245
|
# upgrade Gen1 to Gen2 CI/CD
|
|
245
246
|
result = df.upgrade_to_gen2_cicd(
|
|
@@ -262,10 +263,33 @@ result = df.upgrade_to_gen2_cicd(
|
|
|
262
263
|
| `create_dataflow_gen2_from_definition(workspace_id, display_name, definition)` | Create a Dataflow Gen2 CI/CD from a definition. |
|
|
263
264
|
| `update_dataflow_gen2_from_definition(workspace_id, dataflow_id, display_name, definition)` | Update an existing Dataflow Gen2 CI/CD definition. |
|
|
264
265
|
| `get_data_destinations(workspace_id, dataflow_id)` | Get data destination details for each table in a dataflow. |
|
|
266
|
+
| `get_workspace_data_destinations(workspace_id, max_workers=4)` | Inspect every dataflow, return its destination details, and save destination-only rows as a workbook under `data/dataflows`. The inventory is paced at 200 requests per minute and retries 429 responses. |
|
|
265
267
|
| `change_data_destination(workspace_id, dataflow_id, destination_type, ...)` | Change data destination (Lakehouse/Warehouse). Modes: `preview`, `replace`, `create`. |
|
|
266
268
|
| `create_dataflow_with_new_destination(workspace_id, dataflow_id, ...)` | Create a new Gen2 CI/CD dataflow with a different data destination. |
|
|
267
269
|
| `upgrade_to_gen2_cicd(...)` | Upgrade a Gen1 or Gen2 (standard) dataflow to Gen2 CI/CD. |
|
|
268
270
|
|
|
271
|
+
#### Inventory workspace data destinations
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
from msdev_kit import Auth
|
|
275
|
+
from msdev_kit.fabric import Dataflow
|
|
276
|
+
|
|
277
|
+
workspace_id = "<workspace-id>"
|
|
278
|
+
auth = Auth(tenant_id="<tenant-id>", client_id="<client-id>", client_secret="<client-secret>")
|
|
279
|
+
dataflow = Dataflow(auth.get_token("pbi"))
|
|
280
|
+
|
|
281
|
+
result = dataflow.get_workspace_data_destinations(workspace_id, max_workers=4)
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
The method inspects every dataflow so `result["content"]` also records empty
|
|
285
|
+
inspections and failures. Its workbook contains only table rows with a real data
|
|
286
|
+
destination. Table name is exported as `table_name`, not `table_table`. A Fabric
|
|
287
|
+
source without an API generation value is normalized to `2.1`. The progress line
|
|
288
|
+
is updated in place. If a 429 response occurs, the method prints the completed
|
|
289
|
+
count, waits, then resumes the progress line. See
|
|
290
|
+
[`examples/dataflow_destinations.py`](examples/dataflow_destinations.py) for a
|
|
291
|
+
DataFrame normalizer and a runnable placeholder.
|
|
292
|
+
|
|
269
293
|
### Pipeline
|
|
270
294
|
|
|
271
295
|
Manage Fabric Data Pipelines.
|
|
@@ -410,7 +434,7 @@ Hostname and site path inputs are normalized automatically:
|
|
|
410
434
|
|
|
411
435
|
## Limitations
|
|
412
436
|
|
|
413
|
-
-
|
|
437
|
+
- Power BI and Fabric endpoints apply operation-specific throttling. Do not assume one global quota. `get_workspace_data_destinations` intentionally paces definition lookups at 200 requests per minute and backs off after HTTP 429.
|
|
414
438
|
- Not all users can be updated via the API. See Microsoft docs: [Dataset permissions](https://learn.microsoft.com/en-us/power-bi/developer/embedded/datasets-permissions#get-and-update-dataset-permissions-with-apis).
|
|
415
439
|
- **Dataset query limits** (executeQueries API):
|
|
416
440
|
- Max **100,000 rows** or **1,000,000 values** (rows x columns) per query, whichever is hit first.
|
|
@@ -424,28 +448,24 @@ Hostname and site path inputs are normalized automatically:
|
|
|
424
448
|
|
|
425
449
|
## Examples
|
|
426
450
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
451
|
+
Runnable, placeholder-based scripts live in [`examples/`](./examples). Start
|
|
452
|
+
with the [examples guide](examples/README.md), then run a read-only example from
|
|
453
|
+
the repository root:
|
|
430
454
|
|
|
431
455
|
```bash
|
|
456
|
+
cp .env.example .env
|
|
432
457
|
python -m examples.workspaces
|
|
433
|
-
python -m examples.
|
|
458
|
+
python -m examples.dataflow_destinations
|
|
434
459
|
```
|
|
435
460
|
|
|
461
|
+
Write examples never perform a mutation when run directly. Edit the placeholder
|
|
462
|
+
values and call their explicit helper only after reviewing the target IDs.
|
|
463
|
+
|
|
436
464
|
---
|
|
437
465
|
|
|
438
466
|
## Contributing
|
|
439
467
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
2. Use `feature/<name>` / `fix/<name>` branches. Direct pushes to `main` are
|
|
444
|
-
blocked by repository rulesets.
|
|
445
|
-
3. Keep PRs focused. Add or update tests under `tests/` for any behavior change.
|
|
446
|
-
4. Run `pytest` locally before opening a PR; the `Collaboration` workflow runs
|
|
447
|
-
the same suite on every PR and must pass before merging.
|
|
448
|
-
|
|
449
|
-
PyPI releases are published automatically from `main` by the `Publish to PyPI`
|
|
450
|
-
workflow, gated by a manual approval on the `pypi` environment.
|
|
468
|
+
Read [Contributing](CONTRIBUTING.md) before opening a pull request. It links the
|
|
469
|
+
local setup, validation commands, issue and pull request process, branch
|
|
470
|
+
conventions, and release boundary.
|
|
451
471
|
|
|
@@ -32,15 +32,11 @@ Or install from GitHub:
|
|
|
32
32
|
pip install git+https://github.com/Bernardo-Rufino/msdev-kit.git
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
For local development
|
|
35
|
+
For local development, use the repository instructions in [Development](docs/development.md).
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
pip install -e .
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
**Requirements:** Python >= 3.10, an Azure app registration with a client ID and client secret.
|
|
37
|
+
**Requirements:** Python >= 3.10. Service principal authentication requires an
|
|
38
|
+
Azure app registration, tenant ID, client ID, and client secret. Interactive
|
|
39
|
+
user authentication is also supported for APIs that permit it.
|
|
44
40
|
|
|
45
41
|
---
|
|
46
42
|
|
|
@@ -56,7 +52,7 @@ from msdev_kit.sharepoint import SharePointClient
|
|
|
56
52
|
auth = Auth(tenant_id="...", client_id="...", client_secret="...")
|
|
57
53
|
|
|
58
54
|
# fabric: list workspaces
|
|
59
|
-
ws = Workspace(auth.get_token('
|
|
55
|
+
ws = Workspace(auth.get_token('pbi'))
|
|
60
56
|
workspaces = ws.list_workspaces_for_user()
|
|
61
57
|
|
|
62
58
|
# graph: look up a user
|
|
@@ -72,7 +68,10 @@ sp.download_file('/Reports/monthly.xlsx', local_dir='./downloads')
|
|
|
72
68
|
|
|
73
69
|
## Authentication
|
|
74
70
|
|
|
75
|
-
|
|
71
|
+
`Auth` owns token acquisition. Fabric and Power BI classes receive a token string,
|
|
72
|
+
while Graph and SharePoint clients receive the `Auth` instance. You can use
|
|
73
|
+
different service principals for different services by instantiating one `Auth`
|
|
74
|
+
per service principal:
|
|
76
75
|
|
|
77
76
|
```python
|
|
78
77
|
from msdev_kit import Auth
|
|
@@ -109,14 +108,16 @@ token = auth.get_token_for_user('fabric')
|
|
|
109
108
|
|
|
110
109
|
### Credentials
|
|
111
110
|
|
|
112
|
-
|
|
111
|
+
For the bundled examples, copy the template and set the values:
|
|
113
112
|
|
|
114
113
|
```shell
|
|
115
|
-
|
|
116
|
-
CLIENT_ID='<YOUR_CLIENT_ID>'
|
|
117
|
-
CLIENT_SECRET='<YOUR_CLIENT_SECRET>'
|
|
114
|
+
cp .env.example .env
|
|
118
115
|
```
|
|
119
116
|
|
|
117
|
+
`msdev-kit` does not load `.env` automatically. The scripts in `examples/` load
|
|
118
|
+
`.env` from the repository root. Applications may pass values directly to
|
|
119
|
+
`Auth`, or load environment variables with their own configuration mechanism.
|
|
120
|
+
|
|
120
121
|
---
|
|
121
122
|
|
|
122
123
|
## Fabric & Power BI
|
|
@@ -206,7 +207,7 @@ pages = rpt.list_report_pages(workspace_id, report_id)
|
|
|
206
207
|
Manage Power BI and Fabric dataflows, including Gen1, Gen2, and Gen2 CI/CD.
|
|
207
208
|
|
|
208
209
|
```python
|
|
209
|
-
df = Dataflow(auth.get_token('
|
|
210
|
+
df = Dataflow(auth.get_token('pbi'))
|
|
210
211
|
|
|
211
212
|
# upgrade Gen1 to Gen2 CI/CD
|
|
212
213
|
result = df.upgrade_to_gen2_cicd(
|
|
@@ -229,10 +230,33 @@ result = df.upgrade_to_gen2_cicd(
|
|
|
229
230
|
| `create_dataflow_gen2_from_definition(workspace_id, display_name, definition)` | Create a Dataflow Gen2 CI/CD from a definition. |
|
|
230
231
|
| `update_dataflow_gen2_from_definition(workspace_id, dataflow_id, display_name, definition)` | Update an existing Dataflow Gen2 CI/CD definition. |
|
|
231
232
|
| `get_data_destinations(workspace_id, dataflow_id)` | Get data destination details for each table in a dataflow. |
|
|
233
|
+
| `get_workspace_data_destinations(workspace_id, max_workers=4)` | Inspect every dataflow, return its destination details, and save destination-only rows as a workbook under `data/dataflows`. The inventory is paced at 200 requests per minute and retries 429 responses. |
|
|
232
234
|
| `change_data_destination(workspace_id, dataflow_id, destination_type, ...)` | Change data destination (Lakehouse/Warehouse). Modes: `preview`, `replace`, `create`. |
|
|
233
235
|
| `create_dataflow_with_new_destination(workspace_id, dataflow_id, ...)` | Create a new Gen2 CI/CD dataflow with a different data destination. |
|
|
234
236
|
| `upgrade_to_gen2_cicd(...)` | Upgrade a Gen1 or Gen2 (standard) dataflow to Gen2 CI/CD. |
|
|
235
237
|
|
|
238
|
+
#### Inventory workspace data destinations
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
from msdev_kit import Auth
|
|
242
|
+
from msdev_kit.fabric import Dataflow
|
|
243
|
+
|
|
244
|
+
workspace_id = "<workspace-id>"
|
|
245
|
+
auth = Auth(tenant_id="<tenant-id>", client_id="<client-id>", client_secret="<client-secret>")
|
|
246
|
+
dataflow = Dataflow(auth.get_token("pbi"))
|
|
247
|
+
|
|
248
|
+
result = dataflow.get_workspace_data_destinations(workspace_id, max_workers=4)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
The method inspects every dataflow so `result["content"]` also records empty
|
|
252
|
+
inspections and failures. Its workbook contains only table rows with a real data
|
|
253
|
+
destination. Table name is exported as `table_name`, not `table_table`. A Fabric
|
|
254
|
+
source without an API generation value is normalized to `2.1`. The progress line
|
|
255
|
+
is updated in place. If a 429 response occurs, the method prints the completed
|
|
256
|
+
count, waits, then resumes the progress line. See
|
|
257
|
+
[`examples/dataflow_destinations.py`](examples/dataflow_destinations.py) for a
|
|
258
|
+
DataFrame normalizer and a runnable placeholder.
|
|
259
|
+
|
|
236
260
|
### Pipeline
|
|
237
261
|
|
|
238
262
|
Manage Fabric Data Pipelines.
|
|
@@ -377,7 +401,7 @@ Hostname and site path inputs are normalized automatically:
|
|
|
377
401
|
|
|
378
402
|
## Limitations
|
|
379
403
|
|
|
380
|
-
-
|
|
404
|
+
- Power BI and Fabric endpoints apply operation-specific throttling. Do not assume one global quota. `get_workspace_data_destinations` intentionally paces definition lookups at 200 requests per minute and backs off after HTTP 429.
|
|
381
405
|
- Not all users can be updated via the API. See Microsoft docs: [Dataset permissions](https://learn.microsoft.com/en-us/power-bi/developer/embedded/datasets-permissions#get-and-update-dataset-permissions-with-apis).
|
|
382
406
|
- **Dataset query limits** (executeQueries API):
|
|
383
407
|
- Max **100,000 rows** or **1,000,000 values** (rows x columns) per query, whichever is hit first.
|
|
@@ -391,27 +415,23 @@ Hostname and site path inputs are normalized automatically:
|
|
|
391
415
|
|
|
392
416
|
## Examples
|
|
393
417
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
418
|
+
Runnable, placeholder-based scripts live in [`examples/`](./examples). Start
|
|
419
|
+
with the [examples guide](examples/README.md), then run a read-only example from
|
|
420
|
+
the repository root:
|
|
397
421
|
|
|
398
422
|
```bash
|
|
423
|
+
cp .env.example .env
|
|
399
424
|
python -m examples.workspaces
|
|
400
|
-
python -m examples.
|
|
425
|
+
python -m examples.dataflow_destinations
|
|
401
426
|
```
|
|
402
427
|
|
|
428
|
+
Write examples never perform a mutation when run directly. Edit the placeholder
|
|
429
|
+
values and call their explicit helper only after reviewing the target IDs.
|
|
430
|
+
|
|
403
431
|
---
|
|
404
432
|
|
|
405
433
|
## Contributing
|
|
406
434
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
2. Use `feature/<name>` / `fix/<name>` branches. Direct pushes to `main` are
|
|
411
|
-
blocked by repository rulesets.
|
|
412
|
-
3. Keep PRs focused. Add or update tests under `tests/` for any behavior change.
|
|
413
|
-
4. Run `pytest` locally before opening a PR; the `Collaboration` workflow runs
|
|
414
|
-
the same suite on every PR and must pass before merging.
|
|
415
|
-
|
|
416
|
-
PyPI releases are published automatically from `main` by the `Publish to PyPI`
|
|
417
|
-
workflow, gated by a manual approval on the `pypi` environment.
|
|
435
|
+
Read [Contributing](CONTRIBUTING.md) before opening a pull request. It links the
|
|
436
|
+
local setup, validation commands, issue and pull request process, branch
|
|
437
|
+
conventions, and release boundary.
|
|
@@ -7,8 +7,11 @@ import time
|
|
|
7
7
|
import base64
|
|
8
8
|
import requests
|
|
9
9
|
import pandas as pd
|
|
10
|
-
from
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
11
|
+
from threading import Lock
|
|
12
|
+
from typing import Callable, Dict, List, Optional
|
|
11
13
|
from pydantic import BaseModel, ConfigDict
|
|
14
|
+
from msdev_kit.http import RequestPacer, request_with_retry
|
|
12
15
|
from .utilities import create_directory
|
|
13
16
|
from .workspace import Workspace
|
|
14
17
|
|
|
@@ -27,14 +30,17 @@ class ComputeEngineSettingsModel(BaseModel):
|
|
|
27
30
|
class Dataflow:
|
|
28
31
|
|
|
29
32
|
def __init__(self, token: str):
|
|
30
|
-
"""
|
|
31
|
-
|
|
33
|
+
"""Initialize the Dataflow client.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
token: Power BI or Fabric bearer token.
|
|
32
37
|
"""
|
|
33
38
|
self.main_url = 'https://api.powerbi.com/v1.0/myorg'
|
|
34
39
|
self.fabric_api_base_url = 'https://api.fabric.microsoft.com'
|
|
35
40
|
self.token = token
|
|
36
41
|
self.headers = {'Authorization': f'Bearer {self.token}'}
|
|
37
42
|
self.workspace = Workspace(self.token)
|
|
43
|
+
self._request_pacer = RequestPacer(requests_per_minute=200)
|
|
38
44
|
|
|
39
45
|
# Directories
|
|
40
46
|
self.dataflows_dir = './data/dataflows'
|
|
@@ -44,24 +50,38 @@ class Dataflow:
|
|
|
44
50
|
create_directory(dir)
|
|
45
51
|
|
|
46
52
|
|
|
47
|
-
def _request_with_retry(
|
|
53
|
+
def _request_with_retry(
|
|
54
|
+
self,
|
|
55
|
+
method: str,
|
|
56
|
+
url: str,
|
|
57
|
+
max_retries: int = 3,
|
|
58
|
+
log_retries: bool = True,
|
|
59
|
+
on_rate_limit: Optional[Callable[[float], None]] = None,
|
|
60
|
+
**kwargs,
|
|
61
|
+
) -> requests.Response:
|
|
48
62
|
"""
|
|
49
|
-
|
|
50
|
-
Respects the Retry-After header when present.
|
|
63
|
+
Compatibility wrapper around the shared HTTP retry helper.
|
|
51
64
|
"""
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
return request_with_retry(
|
|
66
|
+
method,
|
|
67
|
+
url,
|
|
68
|
+
max_retries=max_retries,
|
|
69
|
+
pacer=getattr(self, '_request_pacer', None),
|
|
70
|
+
request_func=requests.request,
|
|
71
|
+
sleep=time.sleep,
|
|
72
|
+
log_retries=log_retries,
|
|
73
|
+
on_rate_limit=on_rate_limit,
|
|
74
|
+
**kwargs,
|
|
75
|
+
)
|
|
62
76
|
|
|
63
77
|
|
|
64
|
-
def _get_dataflow_pbi_definition(
|
|
78
|
+
def _get_dataflow_pbi_definition(
|
|
79
|
+
self,
|
|
80
|
+
workspace_id: str,
|
|
81
|
+
dataflow_id: str,
|
|
82
|
+
log_retries: bool = True,
|
|
83
|
+
on_rate_limit: Optional[Callable[[float], None]] = None,
|
|
84
|
+
) -> Dict:
|
|
65
85
|
"""
|
|
66
86
|
Fetches a dataflow definition from the Power BI REST API.
|
|
67
87
|
Works for Gen1 and Gen2 (standard) dataflows.
|
|
@@ -80,7 +100,13 @@ class Dataflow:
|
|
|
80
100
|
return {'message': 'Missing dataflow id, please check.', 'content': ''}
|
|
81
101
|
|
|
82
102
|
request_url = f'{self.main_url}/groups/{workspace_id}/dataflows/{dataflow_id}'
|
|
83
|
-
r = self._request_with_retry(
|
|
103
|
+
r = self._request_with_retry(
|
|
104
|
+
'GET',
|
|
105
|
+
request_url,
|
|
106
|
+
headers=self.headers,
|
|
107
|
+
log_retries=log_retries,
|
|
108
|
+
on_rate_limit=on_rate_limit,
|
|
109
|
+
)
|
|
84
110
|
|
|
85
111
|
if r.status_code == 200:
|
|
86
112
|
return {'message': 'Success', 'content': json.loads(r.content)}
|
|
@@ -166,6 +192,10 @@ class Dataflow:
|
|
|
166
192
|
if not fabric_df.empty:
|
|
167
193
|
fabric_df['name'] = fabric_df['displayName']
|
|
168
194
|
fabric_df.drop(columns=['displayName'], inplace=True)
|
|
195
|
+
if 'generation' not in fabric_df.columns:
|
|
196
|
+
fabric_df['generation'] = 2.1
|
|
197
|
+
else:
|
|
198
|
+
fabric_df['generation'] = fabric_df['generation'].fillna(2.1)
|
|
169
199
|
fabric_df['source'] = 'fabric'
|
|
170
200
|
fabric_records = fabric_df.to_dict('records')
|
|
171
201
|
|
|
@@ -371,7 +401,13 @@ class Dataflow:
|
|
|
371
401
|
|
|
372
402
|
return {'message': 'Success', 'content': response}
|
|
373
403
|
|
|
374
|
-
def get_dataflow_gen2_definition(
|
|
404
|
+
def get_dataflow_gen2_definition(
|
|
405
|
+
self,
|
|
406
|
+
workspace_id: str,
|
|
407
|
+
dataflow_id: str,
|
|
408
|
+
verbose: bool = True,
|
|
409
|
+
on_rate_limit: Optional[Callable[[float], None]] = None,
|
|
410
|
+
) -> Dict:
|
|
375
411
|
"""
|
|
376
412
|
Gets the definition of a Dataflow Gen2 (CI/CD) from a specified workspace.
|
|
377
413
|
Only Dataflow Gen2 (CI/CD / native Fabric) items support definition export.
|
|
@@ -380,18 +416,27 @@ class Dataflow:
|
|
|
380
416
|
Args:
|
|
381
417
|
workspace_id (str): The ID of the workspace where the Dataflow Gen2 resides.
|
|
382
418
|
dataflow_id (str): The ID of the Dataflow Gen2 to retrieve the definition for.
|
|
419
|
+
verbose (bool): Print per-dataflow progress. Defaults to True.
|
|
383
420
|
|
|
384
421
|
Returns:
|
|
385
422
|
Dict: A dictionary containing the status ('Success' or error) and the Dataflow Gen2 definition content.
|
|
386
423
|
"""
|
|
387
424
|
api_url = f'{self.fabric_api_base_url}/v1/workspaces/{workspace_id}/dataflows/{dataflow_id}/getDefinition'
|
|
388
425
|
|
|
389
|
-
|
|
390
|
-
|
|
426
|
+
if verbose:
|
|
427
|
+
print(f"Extracting definition for dataflow {dataflow_id} from workspace {workspace_id}...")
|
|
428
|
+
response = self._request_with_retry(
|
|
429
|
+
'POST',
|
|
430
|
+
api_url,
|
|
431
|
+
headers=self.headers,
|
|
432
|
+
log_retries=verbose,
|
|
433
|
+
on_rate_limit=on_rate_limit,
|
|
434
|
+
)
|
|
391
435
|
|
|
392
436
|
if response.status_code == 200:
|
|
393
437
|
definition = response.json()
|
|
394
|
-
|
|
438
|
+
if verbose:
|
|
439
|
+
print("Successfully extracted Dataflow Gen2 definition.")
|
|
395
440
|
return {'message': 'Success', 'content': definition}
|
|
396
441
|
else:
|
|
397
442
|
# getDefinition only works for Dataflow Gen2 (CI/CD / native Fabric).
|
|
@@ -410,7 +455,8 @@ class Dataflow:
|
|
|
410
455
|
}
|
|
411
456
|
|
|
412
457
|
error_message = response.text
|
|
413
|
-
|
|
458
|
+
if verbose:
|
|
459
|
+
print(f"Error getting Dataflow Gen2 definition: {response.status_code} - {error_message}")
|
|
414
460
|
return {'message': {'error': error_message, 'status_code': response.status_code}}
|
|
415
461
|
|
|
416
462
|
|
|
@@ -1141,7 +1187,14 @@ class Dataflow:
|
|
|
1141
1187
|
return new_m_code, metadata
|
|
1142
1188
|
|
|
1143
1189
|
|
|
1144
|
-
def get_data_destinations(
|
|
1190
|
+
def get_data_destinations(
|
|
1191
|
+
self,
|
|
1192
|
+
workspace_id: str,
|
|
1193
|
+
dataflow_id: str,
|
|
1194
|
+
source: Optional[str] = None,
|
|
1195
|
+
verbose: bool = True,
|
|
1196
|
+
on_rate_limit: Optional[Callable[[float], None]] = None,
|
|
1197
|
+
) -> Dict:
|
|
1145
1198
|
"""
|
|
1146
1199
|
Gets the data destination details for each table in a dataflow.
|
|
1147
1200
|
|
|
@@ -1152,10 +1205,14 @@ class Dataflow:
|
|
|
1152
1205
|
Args:
|
|
1153
1206
|
workspace_id: The workspace ID where the dataflow resides.
|
|
1154
1207
|
dataflow_id: The dataflow ID.
|
|
1208
|
+
source: Source returned by :meth:`list_dataflows`. Uses the known
|
|
1209
|
+
API route directly when it is ``pbi`` or ``fabric``.
|
|
1210
|
+
verbose: Print definition extraction progress. Defaults to True.
|
|
1211
|
+
on_rate_limit: Called with the retry delay for each HTTP 429.
|
|
1155
1212
|
|
|
1156
1213
|
Returns:
|
|
1157
1214
|
Dict: 'message' and 'content' (list of dicts with keys:
|
|
1158
|
-
|
|
1215
|
+
name, destination_type, workspace_id, item_id, sql_schema,
|
|
1159
1216
|
mapping_type ('Automatic' or 'Manual'),
|
|
1160
1217
|
columns (list of {source, destination} dicts, empty for automatic mappings)).
|
|
1161
1218
|
"""
|
|
@@ -1164,18 +1221,176 @@ class Dataflow:
|
|
|
1164
1221
|
if dataflow_id == '':
|
|
1165
1222
|
return {'message': 'Missing dataflow id, please check.', 'content': ''}
|
|
1166
1223
|
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1224
|
+
cicd_result = None
|
|
1225
|
+
if source != 'pbi':
|
|
1226
|
+
cicd_result = self.get_dataflow_gen2_definition(
|
|
1227
|
+
workspace_id,
|
|
1228
|
+
dataflow_id,
|
|
1229
|
+
verbose=verbose,
|
|
1230
|
+
on_rate_limit=on_rate_limit,
|
|
1231
|
+
)
|
|
1232
|
+
if cicd_result.get('message') == 'Success':
|
|
1233
|
+
return self._get_data_destinations_cicd(cicd_result['content'])
|
|
1234
|
+
if source == 'fabric':
|
|
1235
|
+
return {
|
|
1236
|
+
'message': f'Failed to fetch Dataflow Gen2 definition: {cicd_result.get("message")}',
|
|
1237
|
+
'content': '',
|
|
1238
|
+
}
|
|
1170
1239
|
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1240
|
+
pbi_result = self._get_dataflow_pbi_definition(
|
|
1241
|
+
workspace_id,
|
|
1242
|
+
dataflow_id,
|
|
1243
|
+
log_retries=verbose,
|
|
1244
|
+
on_rate_limit=on_rate_limit,
|
|
1245
|
+
)
|
|
1246
|
+
if pbi_result.get('message') == 'Success':
|
|
1247
|
+
return self._get_data_destinations_standard(pbi_result['content'])
|
|
1248
|
+
|
|
1249
|
+
cicd_message = cicd_result.get('message') if cicd_result else 'not attempted'
|
|
1250
|
+
return {
|
|
1251
|
+
'message': (
|
|
1252
|
+
'Failed to fetch dataflow definition. '
|
|
1253
|
+
f'CI/CD: {cicd_message}. PBI: {pbi_result.get("message")}'
|
|
1254
|
+
),
|
|
1255
|
+
'content': '',
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
def get_workspace_data_destinations(
|
|
1260
|
+
self,
|
|
1261
|
+
workspace_id: str,
|
|
1262
|
+
max_workers: int = 4,
|
|
1263
|
+
) -> Dict:
|
|
1264
|
+
"""Get destination tables for every dataflow in a workspace.
|
|
1265
|
+
|
|
1266
|
+
Uses :meth:`list_dataflows` as the source of truth for the workspace,
|
|
1267
|
+
then obtains each dataflow's destinations concurrently. Requests share
|
|
1268
|
+
the hardcoded 200 requests-per-minute pace and retry 429 responses, so
|
|
1269
|
+
increasing ``max_workers`` does not bypass API throttling.
|
|
1270
|
+
|
|
1271
|
+
Args:
|
|
1272
|
+
workspace_id: The workspace ID to inventory.
|
|
1273
|
+
max_workers: Maximum concurrent definition lookups. Defaults to 4.
|
|
1274
|
+
|
|
1275
|
+
Returns:
|
|
1276
|
+
Dict with ``content`` containing one item per dataflow. Each item
|
|
1277
|
+
has the source ``dataflow`` record, ``tables`` with destination
|
|
1278
|
+
details, and ``error`` when that dataflow could not be inspected.
|
|
1279
|
+
The message is ``Success`` when all dataflows were inspected or
|
|
1280
|
+
``Partial success`` when one or more definition lookups failed.
|
|
1281
|
+
"""
|
|
1282
|
+
if workspace_id == '':
|
|
1283
|
+
return {'message': 'Missing workspace id, please check.', 'content': ''}
|
|
1284
|
+
if max_workers < 1:
|
|
1285
|
+
return {'message': 'max_workers must be at least 1.', 'content': ''}
|
|
1286
|
+
|
|
1287
|
+
dataflows_result = self.list_dataflows(workspace_id)
|
|
1288
|
+
if dataflows_result.get('message') != 'Success':
|
|
1289
|
+
return {
|
|
1290
|
+
'message': dataflows_result.get('message'),
|
|
1291
|
+
'content': [],
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
dataflows = dataflows_result.get('content', [])
|
|
1295
|
+
results = [None] * len(dataflows)
|
|
1296
|
+
progress_lock = Lock()
|
|
1297
|
+
progress = {'completed': 0}
|
|
1298
|
+
|
|
1299
|
+
def report_rate_limit(_retry_delay: float) -> None:
|
|
1300
|
+
with progress_lock:
|
|
1301
|
+
print(
|
|
1302
|
+
f'\n{progress["completed"]} processados até então, '
|
|
1303
|
+
'aguardando rate limit...',
|
|
1304
|
+
flush=True,
|
|
1305
|
+
)
|
|
1306
|
+
|
|
1307
|
+
def inspect_dataflow(index: int, dataflow: Dict) -> tuple[int, Dict]:
|
|
1308
|
+
dataflow_id = dataflow.get('id') or dataflow.get('objectId')
|
|
1309
|
+
result = {
|
|
1310
|
+
'dataflow': dataflow,
|
|
1311
|
+
'tables': [],
|
|
1312
|
+
}
|
|
1313
|
+
if not dataflow_id:
|
|
1314
|
+
result['error'] = 'Dataflow record has no id.'
|
|
1315
|
+
return index, result
|
|
1316
|
+
|
|
1317
|
+
try:
|
|
1318
|
+
destinations_result = self.get_data_destinations(
|
|
1319
|
+
workspace_id,
|
|
1320
|
+
dataflow_id,
|
|
1321
|
+
source=dataflow.get('source'),
|
|
1322
|
+
verbose=False,
|
|
1323
|
+
on_rate_limit=report_rate_limit,
|
|
1324
|
+
)
|
|
1325
|
+
except Exception as error:
|
|
1326
|
+
result['error'] = str(error)
|
|
1327
|
+
return index, result
|
|
1328
|
+
|
|
1329
|
+
if destinations_result.get('message') == 'Success':
|
|
1330
|
+
result['tables'] = destinations_result.get('content', [])
|
|
1177
1331
|
else:
|
|
1178
|
-
|
|
1332
|
+
result['error'] = destinations_result.get('message')
|
|
1333
|
+
return index, result
|
|
1334
|
+
|
|
1335
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
1336
|
+
futures = [
|
|
1337
|
+
executor.submit(inspect_dataflow, index, dataflow)
|
|
1338
|
+
for index, dataflow in enumerate(dataflows)
|
|
1339
|
+
]
|
|
1340
|
+
for completed, future in enumerate(as_completed(futures), start=1):
|
|
1341
|
+
index, result = future.result()
|
|
1342
|
+
results[index] = result
|
|
1343
|
+
with progress_lock:
|
|
1344
|
+
progress['completed'] = completed
|
|
1345
|
+
print(
|
|
1346
|
+
f'\rExtracted definition from {completed}/{len(dataflows)} '
|
|
1347
|
+
f'dataflows from workspace {workspace_id}...',
|
|
1348
|
+
end='',
|
|
1349
|
+
flush=True,
|
|
1350
|
+
)
|
|
1351
|
+
|
|
1352
|
+
if dataflows:
|
|
1353
|
+
print()
|
|
1354
|
+
|
|
1355
|
+
self._export_workspace_dataflow_destinations(workspace_id, results)
|
|
1356
|
+
failed = [result for result in results if result.get('error')]
|
|
1357
|
+
return {
|
|
1358
|
+
'message': 'Partial success' if failed else 'Success',
|
|
1359
|
+
'content': results,
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
def _export_workspace_dataflow_destinations(
|
|
1364
|
+
self,
|
|
1365
|
+
workspace_id: str,
|
|
1366
|
+
dataflows: List[Dict],
|
|
1367
|
+
) -> pd.DataFrame:
|
|
1368
|
+
"""Flatten workspace destination results and save them to Excel."""
|
|
1369
|
+
metadata = [
|
|
1370
|
+
['dataflow', 'id'],
|
|
1371
|
+
['dataflow', 'name'],
|
|
1372
|
+
['dataflow', 'configuredBy'],
|
|
1373
|
+
['dataflow', 'generation'],
|
|
1374
|
+
['dataflow', 'source'],
|
|
1375
|
+
]
|
|
1376
|
+
dataframe = pd.json_normalize(
|
|
1377
|
+
dataflows,
|
|
1378
|
+
record_path=['tables'],
|
|
1379
|
+
meta=metadata,
|
|
1380
|
+
record_prefix='table_',
|
|
1381
|
+
errors='ignore',
|
|
1382
|
+
)
|
|
1383
|
+
dataframe.columns = [column.replace('.', '_') for column in dataframe.columns]
|
|
1384
|
+
dataflow_columns = [
|
|
1385
|
+
column for column in dataframe.columns if column.startswith('dataflow_')
|
|
1386
|
+
]
|
|
1387
|
+
destination_columns = [
|
|
1388
|
+
column for column in dataframe.columns if not column.startswith('dataflow_')
|
|
1389
|
+
]
|
|
1390
|
+
dataframe = dataframe[dataflow_columns + destination_columns]
|
|
1391
|
+
filename = f'workspace_dataflow_destinations_{workspace_id}.xlsx'
|
|
1392
|
+
dataframe.to_excel(os.path.join(self.dataflows_dir, filename), index=False)
|
|
1393
|
+
return dataframe
|
|
1179
1394
|
|
|
1180
1395
|
|
|
1181
1396
|
def _parse_column_mappings(self, annotation: str) -> List[Dict]:
|
|
@@ -1235,7 +1450,7 @@ class Dataflow:
|
|
|
1235
1450
|
lh_match = re.search(r'lakehouseId\s*=\s*"([^"]+)"', body)
|
|
1236
1451
|
for q in data_queries:
|
|
1237
1452
|
destinations.append({
|
|
1238
|
-
'
|
|
1453
|
+
'name': q['name'],
|
|
1239
1454
|
'destination_type': 'Lakehouse',
|
|
1240
1455
|
'workspace_id': ws_match.group(1) if ws_match else '',
|
|
1241
1456
|
'item_id': lh_match.group(1) if lh_match else '',
|
|
@@ -1260,7 +1475,7 @@ class Dataflow:
|
|
|
1260
1475
|
if 'Lakehouse.Contents' in body:
|
|
1261
1476
|
lh_match = re.search(r'lakehouseId\s*=\s*"([^"]+)"', body)
|
|
1262
1477
|
destinations.append({
|
|
1263
|
-
'
|
|
1478
|
+
'name': q['name'],
|
|
1264
1479
|
'destination_type': 'Lakehouse',
|
|
1265
1480
|
'workspace_id': ws_match.group(1) if ws_match else '',
|
|
1266
1481
|
'item_id': lh_match.group(1) if lh_match else '',
|
|
@@ -1271,7 +1486,7 @@ class Dataflow:
|
|
|
1271
1486
|
elif 'Fabric.Warehouse' in body:
|
|
1272
1487
|
wh_match = re.search(r'warehouseId\s*=\s*"([^"]+)"', body)
|
|
1273
1488
|
destinations.append({
|
|
1274
|
-
'
|
|
1489
|
+
'name': q['name'],
|
|
1275
1490
|
'destination_type': 'Warehouse',
|
|
1276
1491
|
'workspace_id': ws_match.group(1) if ws_match else '',
|
|
1277
1492
|
'item_id': wh_match.group(1) if wh_match else '',
|
|
@@ -1312,7 +1527,7 @@ class Dataflow:
|
|
|
1312
1527
|
if 'Lakehouse.Contents' in body:
|
|
1313
1528
|
lh_match = re.search(r'lakehouseId\s*=\s*"([^"]+)"', body)
|
|
1314
1529
|
destinations.append({
|
|
1315
|
-
'
|
|
1530
|
+
'name': table_name,
|
|
1316
1531
|
'destination_type': 'Lakehouse',
|
|
1317
1532
|
'workspace_id': ws_match.group(1) if ws_match else '',
|
|
1318
1533
|
'item_id': lh_match.group(1) if lh_match else '',
|
|
@@ -1323,7 +1538,7 @@ class Dataflow:
|
|
|
1323
1538
|
elif 'Fabric.Warehouse' in body:
|
|
1324
1539
|
wh_match = re.search(r'warehouseId\s*=\s*"([^"]+)"', body)
|
|
1325
1540
|
destinations.append({
|
|
1326
|
-
'
|
|
1541
|
+
'name': table_name,
|
|
1327
1542
|
'destination_type': 'Warehouse',
|
|
1328
1543
|
'workspace_id': ws_match.group(1) if ws_match else '',
|
|
1329
1544
|
'item_id': wh_match.group(1) if wh_match else '',
|
|
@@ -1343,7 +1558,7 @@ class Dataflow:
|
|
|
1343
1558
|
bind_pattern = r'\[BindToDefaultDestination\s*=\s*true\]\s*\n\s*shared\s+(\w+)\s*='
|
|
1344
1559
|
for bind_match in re.finditer(bind_pattern, m_code):
|
|
1345
1560
|
destinations.append({
|
|
1346
|
-
'
|
|
1561
|
+
'name': bind_match.group(1),
|
|
1347
1562
|
'destination_type': 'Lakehouse',
|
|
1348
1563
|
'workspace_id': ws_match.group(1) if ws_match else '',
|
|
1349
1564
|
'item_id': lh_match.group(1) if lh_match else '',
|
|
@@ -8,6 +8,7 @@ from . import report
|
|
|
8
8
|
from typing import Dict
|
|
9
9
|
from .utilities import create_directory
|
|
10
10
|
from concurrent.futures import ThreadPoolExecutor
|
|
11
|
+
from msdev_kit.http import request_with_retry
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
class Dataset:
|
|
@@ -28,21 +29,16 @@ class Dataset:
|
|
|
28
29
|
self, method: str, url: str, max_retries: int = 3, **kwargs
|
|
29
30
|
) -> requests.Response:
|
|
30
31
|
"""
|
|
31
|
-
|
|
32
|
-
Respects the Retry-After header when present.
|
|
32
|
+
Compatibility wrapper around the shared HTTP retry helper.
|
|
33
33
|
"""
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
)
|
|
43
|
-
time.sleep(retry_after)
|
|
44
|
-
|
|
45
|
-
return response
|
|
34
|
+
return request_with_retry(
|
|
35
|
+
method,
|
|
36
|
+
url,
|
|
37
|
+
max_retries=max_retries,
|
|
38
|
+
request_func=requests.request,
|
|
39
|
+
sleep=time.sleep,
|
|
40
|
+
**kwargs,
|
|
41
|
+
)
|
|
46
42
|
|
|
47
43
|
def get_dataset_name(self, workspace_id: str, dataset_id: str) -> str:
|
|
48
44
|
"""
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import time
|
|
2
2
|
import requests
|
|
3
3
|
from typing import Dict
|
|
4
|
+
from msdev_kit.http import request_with_retry
|
|
4
5
|
|
|
5
6
|
|
|
6
7
|
class Notebook:
|
|
@@ -16,19 +17,16 @@ class Notebook:
|
|
|
16
17
|
|
|
17
18
|
def _request_with_retry(self, method: str, url: str, max_retries: int = 3, **kwargs) -> requests.Response:
|
|
18
19
|
"""
|
|
19
|
-
|
|
20
|
-
Respects the Retry-After header when present.
|
|
20
|
+
Compatibility wrapper around the shared HTTP retry helper.
|
|
21
21
|
"""
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return response
|
|
22
|
+
return request_with_retry(
|
|
23
|
+
method,
|
|
24
|
+
url,
|
|
25
|
+
max_retries=max_retries,
|
|
26
|
+
request_func=requests.request,
|
|
27
|
+
sleep=time.sleep,
|
|
28
|
+
**kwargs,
|
|
29
|
+
)
|
|
32
30
|
|
|
33
31
|
|
|
34
32
|
def list_notebooks(self, workspace_id: str) -> Dict:
|
|
@@ -7,6 +7,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
7
7
|
from .dataflow import Dataflow
|
|
8
8
|
from .notebook import Notebook
|
|
9
9
|
from .dataset import Dataset
|
|
10
|
+
from msdev_kit.http import request_with_retry
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class Pipeline:
|
|
@@ -23,21 +24,16 @@ class Pipeline:
|
|
|
23
24
|
self, method: str, url: str, max_retries: int = 3, **kwargs
|
|
24
25
|
) -> requests.Response:
|
|
25
26
|
"""
|
|
26
|
-
|
|
27
|
-
Respects the Retry-After header when present.
|
|
27
|
+
Compatibility wrapper around the shared HTTP retry helper.
|
|
28
28
|
"""
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
)
|
|
38
|
-
time.sleep(retry_after)
|
|
39
|
-
|
|
40
|
-
return response
|
|
29
|
+
return request_with_retry(
|
|
30
|
+
method,
|
|
31
|
+
url,
|
|
32
|
+
max_retries=max_retries,
|
|
33
|
+
request_func=requests.request,
|
|
34
|
+
sleep=time.sleep,
|
|
35
|
+
**kwargs,
|
|
36
|
+
)
|
|
41
37
|
|
|
42
38
|
def _resolve_pipeline(self, workspace_id: str, pipeline_id_or_name: str) -> tuple:
|
|
43
39
|
"""
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import requests
|
|
2
2
|
from typing import Optional
|
|
3
3
|
from msdev_kit.auth import Auth
|
|
4
|
+
from msdev_kit.http import request_with_retry
|
|
4
5
|
|
|
5
6
|
|
|
6
7
|
class GraphClient:
|
|
@@ -15,10 +16,20 @@ class GraphClient:
|
|
|
15
16
|
'Content-Type': 'application/json',
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
def _request(self, method: str, url: str, **kwargs) -> requests.Response:
|
|
20
|
+
"""Send a Graph request through the shared retry implementation."""
|
|
21
|
+
return request_with_retry(
|
|
22
|
+
method,
|
|
23
|
+
url,
|
|
24
|
+
request_func=requests.request,
|
|
25
|
+
**kwargs,
|
|
26
|
+
)
|
|
27
|
+
|
|
18
28
|
def get_user_id(self, email: str) -> Optional[str]:
|
|
19
29
|
"""Return the Entra object ID of a user by UPN/email, or None if not found.
|
|
20
30
|
Falls back to filtering by the mail field when the UPN lookup returns 404."""
|
|
21
|
-
resp =
|
|
31
|
+
resp = self._request(
|
|
32
|
+
'GET',
|
|
22
33
|
f'{self._GRAPH_BASE}/users/{email}',
|
|
23
34
|
headers=self._headers(),
|
|
24
35
|
params={'$select': 'id'},
|
|
@@ -28,7 +39,8 @@ class GraphClient:
|
|
|
28
39
|
resp.raise_for_status()
|
|
29
40
|
return resp.json().get('id')
|
|
30
41
|
|
|
31
|
-
resp =
|
|
42
|
+
resp = self._request(
|
|
43
|
+
'GET',
|
|
32
44
|
f'{self._GRAPH_BASE}/users',
|
|
33
45
|
headers=self._headers(),
|
|
34
46
|
params={'$filter': f"mail eq '{email}'", '$select': 'id'},
|
|
@@ -40,7 +52,8 @@ class GraphClient:
|
|
|
40
52
|
|
|
41
53
|
def get_group_id(self, group_name: str) -> Optional[str]:
|
|
42
54
|
"""Return the Entra object ID of a security group by displayName, or None."""
|
|
43
|
-
resp =
|
|
55
|
+
resp = self._request(
|
|
56
|
+
'GET',
|
|
44
57
|
f'{self._GRAPH_BASE}/groups',
|
|
45
58
|
headers=self._headers(),
|
|
46
59
|
params={'$filter': f"displayName eq '{group_name}'", '$select': 'id,displayName'},
|
|
@@ -58,7 +71,7 @@ class GraphClient:
|
|
|
58
71
|
params = {'$select': 'id,displayName,mail,userPrincipalName', '$top': '999'}
|
|
59
72
|
|
|
60
73
|
while url:
|
|
61
|
-
resp =
|
|
74
|
+
resp = self._request('GET', url, headers=self._headers(), params=params, timeout=30)
|
|
62
75
|
resp.raise_for_status()
|
|
63
76
|
data = resp.json()
|
|
64
77
|
members.extend(data.get('value', []))
|
|
@@ -69,7 +82,8 @@ class GraphClient:
|
|
|
69
82
|
|
|
70
83
|
def add_group_member(self, group_id: str, user_id: str):
|
|
71
84
|
"""Add user to group. Silently ignores 'already a member' errors."""
|
|
72
|
-
resp =
|
|
85
|
+
resp = self._request(
|
|
86
|
+
'POST',
|
|
73
87
|
f'{self._GRAPH_BASE}/groups/{group_id}/members/$ref',
|
|
74
88
|
headers=self._headers(),
|
|
75
89
|
json={'@odata.id': f'{self._GRAPH_BASE}/directoryObjects/{user_id}'},
|
|
@@ -81,7 +95,8 @@ class GraphClient:
|
|
|
81
95
|
|
|
82
96
|
def remove_group_member(self, group_id: str, user_id: str):
|
|
83
97
|
"""Remove user from group. Silently ignores 404 (not a member) and 403 (insufficient privileges)."""
|
|
84
|
-
resp =
|
|
98
|
+
resp = self._request(
|
|
99
|
+
'DELETE',
|
|
85
100
|
f'{self._GRAPH_BASE}/groups/{group_id}/members/{user_id}/$ref',
|
|
86
101
|
headers=self._headers(),
|
|
87
102
|
timeout=30,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Shared HTTP request helpers for Microsoft service clients."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from email.utils import parsedate_to_datetime
|
|
5
|
+
from threading import Lock
|
|
6
|
+
import time
|
|
7
|
+
from typing import Callable, Optional
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RequestPacer:
|
|
13
|
+
"""Thread-safe minimum interval between requests from one client instance."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, requests_per_minute: float):
|
|
16
|
+
if requests_per_minute <= 0:
|
|
17
|
+
raise ValueError('requests_per_minute must be greater than zero.')
|
|
18
|
+
self._interval = 60 / requests_per_minute
|
|
19
|
+
self._lock = Lock()
|
|
20
|
+
self._last_request_at = 0.0
|
|
21
|
+
|
|
22
|
+
def wait(self) -> None:
|
|
23
|
+
"""Wait until the next request is allowed."""
|
|
24
|
+
with self._lock:
|
|
25
|
+
elapsed = time.monotonic() - self._last_request_at
|
|
26
|
+
if elapsed < self._interval:
|
|
27
|
+
time.sleep(self._interval - elapsed)
|
|
28
|
+
self._last_request_at = time.monotonic()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def request_with_retry(
|
|
32
|
+
method: str,
|
|
33
|
+
url: str,
|
|
34
|
+
*,
|
|
35
|
+
max_retries: int = 3,
|
|
36
|
+
pacer: Optional[RequestPacer] = None,
|
|
37
|
+
request_func: Callable[..., requests.Response] = requests.request,
|
|
38
|
+
sleep: Optional[Callable[[float], None]] = None,
|
|
39
|
+
log_retries: bool = True,
|
|
40
|
+
on_rate_limit: Optional[Callable[[float], None]] = None,
|
|
41
|
+
**kwargs,
|
|
42
|
+
) -> requests.Response:
|
|
43
|
+
"""Send an HTTP request and retry HTTP 429 responses.
|
|
44
|
+
|
|
45
|
+
``method`` accepts any HTTP verb supported by ``requests``. When a 429
|
|
46
|
+
includes ``Retry-After``, its delta-seconds or HTTP date value is honored.
|
|
47
|
+
Otherwise retries back off exponentially from one second. A shared
|
|
48
|
+
``RequestPacer`` can be supplied by callers that issue concurrent work.
|
|
49
|
+
"""
|
|
50
|
+
normalized_method = method.strip().upper() if isinstance(method, str) else ''
|
|
51
|
+
if not normalized_method:
|
|
52
|
+
raise ValueError('method must be a non-empty HTTP method string.')
|
|
53
|
+
if max_retries < 0:
|
|
54
|
+
raise ValueError('max_retries must be zero or greater.')
|
|
55
|
+
if sleep is None:
|
|
56
|
+
sleep = time.sleep
|
|
57
|
+
|
|
58
|
+
for attempt in range(max_retries + 1):
|
|
59
|
+
if pacer is not None:
|
|
60
|
+
pacer.wait()
|
|
61
|
+
|
|
62
|
+
response = request_func(normalized_method, url, **kwargs)
|
|
63
|
+
if response.status_code != 429:
|
|
64
|
+
return response
|
|
65
|
+
|
|
66
|
+
if attempt < max_retries:
|
|
67
|
+
retry_delay = _get_retry_delay(response, attempt)
|
|
68
|
+
if on_rate_limit is not None:
|
|
69
|
+
on_rate_limit(retry_delay)
|
|
70
|
+
if log_retries:
|
|
71
|
+
print(
|
|
72
|
+
f' Rate limited (429). Retrying in {retry_delay:g}s... '
|
|
73
|
+
f'(attempt {attempt + 1}/{max_retries})'
|
|
74
|
+
)
|
|
75
|
+
sleep(retry_delay)
|
|
76
|
+
|
|
77
|
+
return response
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _get_retry_delay(response: requests.Response, attempt: int) -> float:
|
|
81
|
+
"""Return a Retry-After delay, falling back to exponential backoff."""
|
|
82
|
+
retry_after = response.headers.get('Retry-After')
|
|
83
|
+
try:
|
|
84
|
+
return max(float(retry_after), 0)
|
|
85
|
+
except (TypeError, ValueError):
|
|
86
|
+
try:
|
|
87
|
+
retry_after_at = parsedate_to_datetime(retry_after)
|
|
88
|
+
if retry_after_at.tzinfo is None:
|
|
89
|
+
retry_after_at = retry_after_at.replace(tzinfo=timezone.utc)
|
|
90
|
+
return max((retry_after_at - datetime.now(timezone.utc)).total_seconds(), 0)
|
|
91
|
+
except (TypeError, ValueError):
|
|
92
|
+
return min(2 ** attempt, 60)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|