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.
Files changed (24) hide show
  1. {msdev_kit-0.2.2 → msdev_kit-0.2.4}/PKG-INFO +52 -32
  2. {msdev_kit-0.2.2 → msdev_kit-0.2.4}/README.md +51 -31
  3. {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/__init__.py +1 -1
  4. {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/dataflow.py +256 -41
  5. {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/dataset.py +10 -14
  6. {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/notebook.py +10 -12
  7. {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/pipeline.py +10 -14
  8. {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/graph/client.py +21 -6
  9. msdev_kit-0.2.4/msdev_kit/http.py +92 -0
  10. {msdev_kit-0.2.2 → msdev_kit-0.2.4}/pyproject.toml +1 -1
  11. {msdev_kit-0.2.2 → msdev_kit-0.2.4}/LICENSE +0 -0
  12. {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/auth.py +0 -0
  13. {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/__init__.py +0 -0
  14. {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/admin.py +0 -0
  15. {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/capacity.py +0 -0
  16. {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/database.py +0 -0
  17. {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/kql.py +0 -0
  18. {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/operations.py +0 -0
  19. {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/report.py +0 -0
  20. {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/utilities.py +0 -0
  21. {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/fabric/workspace.py +0 -0
  22. {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/graph/__init__.py +0 -0
  23. {msdev_kit-0.2.2 → msdev_kit-0.2.4}/msdev_kit/sharepoint/__init__.py +0 -0
  24. {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.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
- ```shell
71
- git clone https://github.com/Bernardo-Rufino/msdev-kit.git
72
- cd msdev-kit
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('fabric'))
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
- All classes use a shared `Auth` object. You can use different service principals for different services instantiate one `Auth` per SPN:
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
- Set up credentials via environment variables or a `.env` file:
144
+ For the bundled examples, copy the template and set the values:
146
145
 
147
146
  ```shell
148
- TENANT_ID='<YOUR_TENANT_ID>'
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('fabric'))
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
- - The Power BI REST API has a **200 requests per hour** rate limit.
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
- End-to-end scripts for the most common scenarios live in [`examples/`](./examples).
428
- They share a small `_setup.py` that builds Auth and the service clients from
429
- environment variables (or `./utils/.env`). Run any of them from the repo root:
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.dataflows
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
- External contributions are welcome via pull requests. Please:
441
-
442
- 1. Open an issue first for non-trivial changes so the design can be discussed.
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
- ```shell
38
- git clone https://github.com/Bernardo-Rufino/msdev-kit.git
39
- cd msdev-kit
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('fabric'))
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
- All classes use a shared `Auth` object. You can use different service principals for different services instantiate one `Auth` per SPN:
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
- Set up credentials via environment variables or a `.env` file:
111
+ For the bundled examples, copy the template and set the values:
113
112
 
114
113
  ```shell
115
- TENANT_ID='<YOUR_TENANT_ID>'
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('fabric'))
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
- - The Power BI REST API has a **200 requests per hour** rate limit.
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
- End-to-end scripts for the most common scenarios live in [`examples/`](./examples).
395
- They share a small `_setup.py` that builds Auth and the service clients from
396
- environment variables (or `./utils/.env`). Run any of them from the repo root:
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.dataflows
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
- External contributions are welcome via pull requests. Please:
408
-
409
- 1. Open an issue first for non-trivial changes so the design can be discussed.
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.
@@ -1,4 +1,4 @@
1
1
  from .auth import Auth
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.2.4"
4
4
  __all__ = ["Auth"]
@@ -7,8 +7,11 @@ import time
7
7
  import base64
8
8
  import requests
9
9
  import pandas as pd
10
- from typing import Dict, List, Optional
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
- Initialize variables.
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(self, method: str, url: str, max_retries: int = 3, **kwargs) -> requests.Response:
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
- Makes an HTTP request with automatic retry on 429 (Too Many Requests).
50
- Respects the Retry-After header when present.
63
+ Compatibility wrapper around the shared HTTP retry helper.
51
64
  """
52
- for attempt in range(max_retries + 1):
53
- response = requests.request(method, url, **kwargs)
54
- if response.status_code != 429:
55
- return response
56
-
57
- retry_after = int(response.headers.get('Retry-After', 5))
58
- print(f" Rate limited (429). Retrying in {retry_after}s... (attempt {attempt + 1}/{max_retries})")
59
- time.sleep(retry_after)
60
-
61
- return response
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(self, workspace_id: str, dataflow_id: str) -> Dict:
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('GET', request_url, headers=self.headers)
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(self, workspace_id: str, dataflow_id: str) -> Dict:
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
- print(f"Extracting definition for dataflow {dataflow_id} from workspace {workspace_id}...")
390
- response = requests.post(api_url, headers=self.headers)
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
- print("Successfully extracted Dataflow Gen2 definition.")
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
- print(f"Error getting Dataflow Gen2 definition: {response.status_code} - {error_message}")
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(self, workspace_id: str, dataflow_id: str) -> Dict:
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
- table, destination_type, workspace_id, item_id, sql_schema,
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
- # Try CI/CD format first
1168
- cicd_result = self.get_dataflow_gen2_definition(workspace_id, dataflow_id)
1169
- is_cicd = cicd_result.get('message') == 'Success'
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
- if is_cicd:
1172
- return self._get_data_destinations_cicd(cicd_result['content'])
1173
- else:
1174
- pbi_result = self._get_dataflow_pbi_definition(workspace_id, dataflow_id)
1175
- if pbi_result.get('message') == 'Success':
1176
- return self._get_data_destinations_standard(pbi_result['content'])
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
- return {'message': f'Failed to fetch dataflow definition. CI/CD: {cicd_result.get("message")}. PBI: {pbi_result.get("message")}', 'content': ''}
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
- 'table': q['name'],
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
- 'table': q['name'],
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
- 'table': q['name'],
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
- 'table': table_name,
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
- 'table': table_name,
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
- 'table': bind_match.group(1),
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
- Makes an HTTP request with automatic retry on 429 (Too Many Requests).
32
- Respects the Retry-After header when present.
32
+ Compatibility wrapper around the shared HTTP retry helper.
33
33
  """
34
- for attempt in range(max_retries + 1):
35
- response = requests.request(method, url, **kwargs)
36
- if response.status_code != 429:
37
- return response
38
-
39
- retry_after = int(response.headers.get("Retry-After", 5))
40
- print(
41
- f" Rate limited (429). Retrying in {retry_after}s... (attempt {attempt + 1}/{max_retries})"
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
- Makes an HTTP request with automatic retry on 429 (Too Many Requests).
20
- Respects the Retry-After header when present.
20
+ Compatibility wrapper around the shared HTTP retry helper.
21
21
  """
22
- for attempt in range(max_retries + 1):
23
- response = requests.request(method, url, **kwargs)
24
- if response.status_code != 429:
25
- return response
26
-
27
- retry_after = int(response.headers.get('Retry-After', 5))
28
- print(f" Rate limited (429). Retrying in {retry_after}s... (attempt {attempt + 1}/{max_retries})")
29
- time.sleep(retry_after)
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
- Makes an HTTP request with automatic retry on 429 (Too Many Requests).
27
- Respects the Retry-After header when present.
27
+ Compatibility wrapper around the shared HTTP retry helper.
28
28
  """
29
- for attempt in range(max_retries + 1):
30
- response = requests.request(method, url, **kwargs)
31
- if response.status_code != 429:
32
- return response
33
-
34
- retry_after = int(response.headers.get("Retry-After", 5))
35
- print(
36
- f" Rate limited (429). Retrying in {retry_after}s... (attempt {attempt + 1}/{max_retries})"
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 = requests.get(
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 = requests.get(
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 = requests.get(
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 = requests.get(url, headers=self._headers(), params=params, timeout=30)
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 = requests.post(
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 = requests.delete(
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)
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "msdev-kit"
3
3
  description = "Microsoft developer toolkit: Fabric, MS Graph, and SharePoint"
4
- version = "0.2.2"
4
+ version = "0.2.4"
5
5
  requires-python = ">=3.10"
6
6
  readme = "README.md"
7
7
  license = {text = "MIT"}
File without changes
File without changes