d365fo-client 0.2.4__py3-none-any.whl → 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. d365fo_client/__init__.py +7 -1
  2. d365fo_client/auth.py +9 -21
  3. d365fo_client/cli.py +25 -13
  4. d365fo_client/client.py +8 -4
  5. d365fo_client/config.py +52 -30
  6. d365fo_client/credential_sources.py +5 -0
  7. d365fo_client/main.py +1 -1
  8. d365fo_client/mcp/__init__.py +3 -1
  9. d365fo_client/mcp/auth_server/__init__.py +5 -0
  10. d365fo_client/mcp/auth_server/auth/__init__.py +30 -0
  11. d365fo_client/mcp/auth_server/auth/auth.py +372 -0
  12. d365fo_client/mcp/auth_server/auth/oauth_proxy.py +989 -0
  13. d365fo_client/mcp/auth_server/auth/providers/__init__.py +0 -0
  14. d365fo_client/mcp/auth_server/auth/providers/apikey.py +83 -0
  15. d365fo_client/mcp/auth_server/auth/providers/azure.py +393 -0
  16. d365fo_client/mcp/auth_server/auth/providers/bearer.py +25 -0
  17. d365fo_client/mcp/auth_server/auth/providers/jwt.py +547 -0
  18. d365fo_client/mcp/auth_server/auth/redirect_validation.py +65 -0
  19. d365fo_client/mcp/auth_server/dependencies.py +136 -0
  20. d365fo_client/mcp/client_manager.py +16 -67
  21. d365fo_client/mcp/fastmcp_main.py +407 -0
  22. d365fo_client/mcp/fastmcp_server.py +598 -0
  23. d365fo_client/mcp/fastmcp_utils.py +431 -0
  24. d365fo_client/mcp/main.py +40 -13
  25. d365fo_client/mcp/mixins/__init__.py +24 -0
  26. d365fo_client/mcp/mixins/base_tools_mixin.py +55 -0
  27. d365fo_client/mcp/mixins/connection_tools_mixin.py +50 -0
  28. d365fo_client/mcp/mixins/crud_tools_mixin.py +311 -0
  29. d365fo_client/mcp/mixins/database_tools_mixin.py +685 -0
  30. d365fo_client/mcp/mixins/label_tools_mixin.py +87 -0
  31. d365fo_client/mcp/mixins/metadata_tools_mixin.py +565 -0
  32. d365fo_client/mcp/mixins/performance_tools_mixin.py +109 -0
  33. d365fo_client/mcp/mixins/profile_tools_mixin.py +713 -0
  34. d365fo_client/mcp/mixins/sync_tools_mixin.py +321 -0
  35. d365fo_client/mcp/prompts/action_execution.py +1 -1
  36. d365fo_client/mcp/prompts/sequence_analysis.py +1 -1
  37. d365fo_client/mcp/tools/crud_tools.py +3 -3
  38. d365fo_client/mcp/tools/sync_tools.py +1 -1
  39. d365fo_client/mcp/utilities/__init__.py +1 -0
  40. d365fo_client/mcp/utilities/auth.py +34 -0
  41. d365fo_client/mcp/utilities/logging.py +58 -0
  42. d365fo_client/mcp/utilities/types.py +426 -0
  43. d365fo_client/metadata_v2/sync_manager_v2.py +2 -0
  44. d365fo_client/metadata_v2/sync_session_manager.py +7 -7
  45. d365fo_client/models.py +139 -139
  46. d365fo_client/output.py +2 -2
  47. d365fo_client/profile_manager.py +62 -27
  48. d365fo_client/profiles.py +118 -113
  49. d365fo_client/settings.py +367 -0
  50. d365fo_client/sync_models.py +85 -2
  51. d365fo_client/utils.py +2 -1
  52. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.1.dist-info}/METADATA +273 -18
  53. d365fo_client-0.3.1.dist-info/RECORD +85 -0
  54. d365fo_client-0.3.1.dist-info/entry_points.txt +4 -0
  55. d365fo_client-0.2.4.dist-info/RECORD +0 -56
  56. d365fo_client-0.2.4.dist-info/entry_points.txt +0 -3
  57. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.1.dist-info}/WHEEL +0 -0
  58. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.1.dist-info}/licenses/LICENSE +0 -0
  59. {d365fo_client-0.2.4.dist-info → d365fo_client-0.3.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,83 @@
1
+ """API Key authentication provider for FastMCP.
2
+
3
+ This provider implements simple API key authentication using the Authorization header.
4
+ Suitable for service-to-service authentication and simpler deployment scenarios.
5
+
6
+ IMPORTANT: FastMCP uses BearerAuthBackend which extracts tokens from the Authorization header
7
+ and calls token_verifier.verify_token(). Clients must send the API key as:
8
+ Authorization: Bearer <your-api-key>
9
+
10
+ The token_verifier.verify_token() method performs constant-time comparison of the API key.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import secrets
16
+
17
+ from pydantic import SecretStr
18
+
19
+ from ..auth import AccessToken, TokenVerifier
20
+ from d365fo_client.mcp.utilities.logging import get_logger
21
+
22
+ logger = get_logger(__name__)
23
+
24
+
25
+ class APIKeyVerifier(TokenVerifier):
26
+ """API Key token verifier for FastMCP.
27
+
28
+ This is a TokenVerifier that validates API keys sent as Bearer tokens.
29
+ FastMCP's BearerAuthBackend extracts the token from "Authorization: Bearer <token>"
30
+ and passes it to this verifier's verify_token() method.
31
+
32
+ This is a simpler alternative to OAuth for scenarios where:
33
+ - Service-to-service authentication is needed
34
+ - Simplified deployment without OAuth infrastructure
35
+ - Single-user or trusted client scenarios
36
+
37
+ Security features:
38
+ - Constant-time comparison to prevent timing attacks
39
+ - SecretStr storage to prevent accidental logging
40
+ - No token expiration (suitable for long-lived API keys)
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ api_key: SecretStr,
46
+ base_url: str | None = None,
47
+ required_scopes: list[str] | None = None,
48
+ ):
49
+ """Initialize API key provider.
50
+
51
+ Args:
52
+ api_key: The secret API key value
53
+ base_url: Base URL of the server
54
+ required_scopes: Required scopes (for compatibility, not enforced for API keys)
55
+ """
56
+ super().__init__(base_url=base_url, required_scopes=required_scopes)
57
+ self.api_key = api_key
58
+
59
+ async def verify_token(self, token: str) -> AccessToken | None:
60
+ """Verify API key token.
61
+
62
+ This method is called by FastMCP's BearerAuthBackend after extracting
63
+ the token from "Authorization: Bearer <token>" header.
64
+
65
+ Args:
66
+ token: The API key extracted from the Authorization header
67
+
68
+ Returns:
69
+ AccessToken if valid, None otherwise
70
+ """
71
+ # Constant-time comparison to prevent timing attacks
72
+ if secrets.compare_digest(token, self.api_key.get_secret_value()):
73
+ logger.debug("API key authentication successful")
74
+ return AccessToken(
75
+ token=token,
76
+ scopes=self.required_scopes or [],
77
+ client_id="api_key_client", # Fixed client_id for API key auth
78
+ expires_at=None, # API keys don't expire
79
+ resource=None,
80
+ )
81
+
82
+ logger.warning("Invalid API key provided")
83
+ return None
@@ -0,0 +1,393 @@
1
+ """Azure (Microsoft Entra) OAuth provider for FastMCP.
2
+
3
+ This provider implements Azure/Microsoft Entra ID OAuth authentication
4
+ using the OAuth Proxy pattern for non-DCR OAuth flows.
5
+ """
6
+
7
+ from __future__ import annotations
8
+ import json
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from unittest import result
12
+
13
+ import httpx
14
+ from mcp.server.auth.provider import AuthorizationParams
15
+ from mcp.shared.auth import OAuthClientInformationFull
16
+ from pydantic import SecretStr, field_validator
17
+ from pydantic_settings import BaseSettings, SettingsConfigDict
18
+
19
+ from ..auth import AccessToken, TokenVerifier
20
+ from ..oauth_proxy import OAuthProxy
21
+ from d365fo_client.mcp.utilities.auth import parse_scopes
22
+ from d365fo_client.mcp.utilities.logging import get_logger
23
+ from d365fo_client.mcp.utilities.types import NotSet, NotSetT
24
+
25
+ logger = get_logger(__name__)
26
+
27
+
28
+ class AzureProviderSettings(BaseSettings):
29
+ """Settings for Azure OAuth provider."""
30
+
31
+ model_config = SettingsConfigDict(
32
+ env_prefix="FASTMCP_SERVER_AUTH_AZURE_",
33
+ env_file=".env",
34
+ extra="ignore",
35
+ )
36
+
37
+ client_id: str | None = None
38
+ client_secret: SecretStr | None = None
39
+ tenant_id: str | None = None
40
+ base_url: str | None = None
41
+ redirect_path: str | None = None
42
+ required_scopes: list[str] | None = None
43
+ timeout_seconds: int | None = None
44
+ allowed_client_redirect_uris: list[str] | None = None
45
+ clients_storage_path: str | None = None
46
+
47
+ @field_validator("required_scopes", mode="before")
48
+ @classmethod
49
+ def _parse_scopes(cls, v):
50
+ return parse_scopes(v)
51
+
52
+
53
+ class AzureTokenVerifier(TokenVerifier):
54
+ """Token verifier for Azure OAuth tokens.
55
+
56
+ Azure tokens are JWTs, but we verify them by calling the Microsoft Graph API
57
+ to get user information and validate the token.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ *,
63
+ required_scopes: list[str] | None = None,
64
+ timeout_seconds: int = 10,
65
+ ):
66
+ """Initialize the Azure token verifier.
67
+
68
+ Args:
69
+ required_scopes: Required OAuth scopes
70
+ timeout_seconds: HTTP request timeout
71
+ """
72
+ super().__init__(required_scopes=required_scopes)
73
+ self.timeout_seconds = timeout_seconds
74
+
75
+ async def verify_token(self, token: str) -> AccessToken | None:
76
+ """Verify Azure OAuth token by calling Microsoft Graph API."""
77
+ try:
78
+ async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
79
+ # Use Microsoft Graph API to validate token and get user info
80
+ response = await client.get(
81
+ "https://graph.microsoft.com/v1.0/me",
82
+ headers={
83
+ "Authorization": f"Bearer {token}",
84
+ "User-Agent": "FastMCP-Azure-OAuth",
85
+ },
86
+ )
87
+
88
+ if response.status_code != 200:
89
+ logger.debug(
90
+ "Azure token verification failed: %d - %s",
91
+ response.status_code,
92
+ response.text[:200],
93
+ )
94
+ return None
95
+
96
+ user_data = response.json()
97
+
98
+ # Create AccessToken with Azure user info
99
+ return AccessToken(
100
+ token=token,
101
+ client_id=str(user_data.get("id", "unknown")),
102
+ scopes=self.required_scopes or [],
103
+ expires_at=None,
104
+ claims={
105
+ "sub": user_data.get("id"),
106
+ "email": user_data.get("mail")
107
+ or user_data.get("userPrincipalName"),
108
+ "name": user_data.get("displayName"),
109
+ "given_name": user_data.get("givenName"),
110
+ "family_name": user_data.get("surname"),
111
+ "job_title": user_data.get("jobTitle"),
112
+ "office_location": user_data.get("officeLocation"),
113
+ },
114
+ )
115
+
116
+ except httpx.RequestError as e:
117
+ logger.debug("Failed to verify Azure token: %s", e)
118
+ return None
119
+ except Exception as e:
120
+ logger.debug("Azure token verification error: %s", e)
121
+ return None
122
+
123
+
124
+ class AzureProvider(OAuthProxy):
125
+ """Azure (Microsoft Entra) OAuth provider for FastMCP.
126
+
127
+ This provider implements Azure/Microsoft Entra ID authentication using the
128
+ OAuth Proxy pattern. It supports both organizational accounts and personal
129
+ Microsoft accounts depending on the tenant configuration.
130
+
131
+ Features:
132
+ - Transparent OAuth proxy to Azure/Microsoft identity platform
133
+ - Automatic token validation via Microsoft Graph API
134
+ - User information extraction
135
+ - Support for different tenant configurations (common, organizations, consumers)
136
+
137
+ Setup Requirements:
138
+ 1. Register an application in Azure Portal (portal.azure.com)
139
+ 2. Configure redirect URI as: http://localhost:8000/auth/callback
140
+ 3. Note your Application (client) ID and create a client secret
141
+ 4. Optionally note your Directory (tenant) ID for single-tenant apps
142
+
143
+ Example:
144
+ ```python
145
+ from fastmcp import FastMCP
146
+ from fastmcp.server.auth.providers.azure import AzureProvider
147
+
148
+ auth = AzureProvider(
149
+ client_id="your-client-id",
150
+ client_secret="your-client-secret",
151
+ tenant_id="your-tenant-id", # Required: your Azure tenant ID from Azure Portal
152
+ base_url="http://localhost:8000"
153
+ )
154
+
155
+ mcp = FastMCP("My App", auth=auth)
156
+ ```
157
+ """
158
+
159
+ def __init__(
160
+ self,
161
+ *,
162
+ client_id: str | NotSetT = NotSet,
163
+ client_secret: str | NotSetT = NotSet,
164
+ tenant_id: str | NotSetT = NotSet,
165
+ base_url: str | NotSetT = NotSet,
166
+ redirect_path: str | NotSetT = NotSet,
167
+ required_scopes: list[str] | None | NotSetT = NotSet,
168
+ timeout_seconds: int | NotSetT = NotSet,
169
+ allowed_client_redirect_uris: list[str] | NotSetT = NotSet,
170
+ clients_storage_path: str | NotSetT = NotSet, # Path to store clients data
171
+ ):
172
+ """Initialize Azure OAuth provider.
173
+
174
+ Args:
175
+ client_id: Azure application (client) ID
176
+ client_secret: Azure client secret
177
+ tenant_id: Azure tenant ID (your specific tenant ID, "organizations", or "consumers")
178
+ base_url: Public URL of your FastMCP server (for OAuth callbacks)
179
+ redirect_path: Redirect path configured in Azure (defaults to "/auth/callback")
180
+ required_scopes: Required scopes (defaults to ["User.Read", "email", "openid", "profile"])
181
+ timeout_seconds: HTTP request timeout for Azure API calls
182
+ allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients.
183
+ If None (default), all URIs are allowed. If empty list, no URIs are allowed.
184
+ """
185
+ settings = AzureProviderSettings.model_validate(
186
+ {
187
+ k: v
188
+ for k, v in {
189
+ "client_id": client_id,
190
+ "client_secret": client_secret,
191
+ "tenant_id": tenant_id,
192
+ "base_url": base_url,
193
+ "redirect_path": redirect_path,
194
+ "required_scopes": required_scopes,
195
+ "timeout_seconds": timeout_seconds,
196
+ "allowed_client_redirect_uris": allowed_client_redirect_uris,
197
+ "clients_storage_path": clients_storage_path,
198
+ }.items()
199
+ if v is not NotSet
200
+ }
201
+ )
202
+
203
+ # Validate required settings
204
+ if not settings.client_id:
205
+ raise ValueError(
206
+ "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID"
207
+ )
208
+ if not settings.client_secret:
209
+ raise ValueError(
210
+ "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_CLIENT_SECRET"
211
+ )
212
+
213
+ # Validate tenant_id is provided
214
+ if not settings.tenant_id:
215
+ raise ValueError(
216
+ "tenant_id is required - set via parameter or FASTMCP_SERVER_AUTH_AZURE_TENANT_ID. "
217
+ "Use your Azure tenant ID (found in Azure Portal), 'organizations', or 'consumers'"
218
+ )
219
+
220
+ # Apply defaults
221
+ tenant_id_final = settings.tenant_id
222
+
223
+ redirect_path_final = settings.redirect_path or "/auth/callback"
224
+ timeout_seconds_final = settings.timeout_seconds or 10
225
+ # Default scopes for Azure - User.Read gives us access to user info via Graph API
226
+ scopes_final = settings.required_scopes or [
227
+ "User.Read",
228
+ "email",
229
+ "openid",
230
+ "profile",
231
+ ]
232
+ allowed_client_redirect_uris_final = settings.allowed_client_redirect_uris
233
+
234
+ # Extract secret string from SecretStr
235
+ client_secret_str = (
236
+ settings.client_secret.get_secret_value() if settings.client_secret else ""
237
+ )
238
+
239
+ # Create Azure token verifier
240
+ token_verifier = AzureTokenVerifier(
241
+ required_scopes=scopes_final,
242
+ timeout_seconds=timeout_seconds_final,
243
+ )
244
+
245
+ # Build Azure OAuth endpoints with tenant
246
+ authorization_endpoint = (
247
+ f"https://login.microsoftonline.com/{tenant_id_final}/oauth2/v2.0/authorize"
248
+ )
249
+ token_endpoint = (
250
+ f"https://login.microsoftonline.com/{tenant_id_final}/oauth2/v2.0/token"
251
+ )
252
+
253
+ # Initialize OAuth proxy with Azure endpoints
254
+ super().__init__(
255
+ upstream_authorization_endpoint=authorization_endpoint,
256
+ upstream_token_endpoint=token_endpoint,
257
+ upstream_client_id=settings.client_id,
258
+ upstream_client_secret=client_secret_str,
259
+ token_verifier=token_verifier,
260
+ base_url=settings.base_url, #type: ignore[arg-type]
261
+ redirect_path=redirect_path_final,
262
+ issuer_url=settings.base_url,
263
+ allowed_client_redirect_uris=allowed_client_redirect_uris_final,
264
+ )
265
+
266
+ self.clients_storage_path = settings.clients_storage_path
267
+
268
+ self._load_clients()
269
+
270
+ logger.info(
271
+ "Initialized Azure OAuth provider for client %s with tenant %s",
272
+ settings.client_id,
273
+ tenant_id_final,
274
+ )
275
+
276
+ async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
277
+ """Authorize request, removing 'resource' parameter if present."""
278
+
279
+ if params.resource:
280
+ params.resource = None # Azure does not use 'resource' parameter
281
+
282
+ return await super().authorize(client, params)
283
+
284
+
285
+ async def register_client(self, client_info: OAuthClientInformationFull) -> None:
286
+ """Register a new MCP client, validating redirect URIs if configured."""
287
+ await super().register_client(client_info)
288
+ try:
289
+ self._save_clients()
290
+ except Exception as e:
291
+ logger.error(f"Failed to persist client registration: {e}")
292
+ # Don't raise here as the client is already registered in memory
293
+
294
+ def _save_clients(self) -> None:
295
+ """Save client data to persistent storage.
296
+
297
+ Raises:
298
+ ValueError: If clients_storage_path is not configured
299
+ OSError: If file operations fail
300
+ """
301
+ if not self.clients_storage_path:
302
+ logger.warning("No clients storage path configured. Skipping client save.")
303
+ return
304
+
305
+ try:
306
+ # Ensure the storage directory exists
307
+ storage_dir = Path(self.clients_storage_path)
308
+ storage_dir.mkdir(parents=True, exist_ok=True)
309
+
310
+ client_json_path = storage_dir / "clients.json"
311
+
312
+ # Convert OAuthClientInformationFull objects to dictionaries for JSON serialization
313
+ # Use mode="json" to properly serialize complex types like AnyUrl
314
+ clients_dict = {}
315
+ for client_id, client in self._clients.items():
316
+ try:
317
+ if hasattr(client, 'model_dump'):
318
+ # Use json mode to ensure proper serialization of complex types (e.g., AnyUrl)
319
+ clients_dict[client_id] = client.model_dump(mode="json")
320
+ else:
321
+ # Fallback for non-Pydantic objects (shouldn't happen with OAuthClientInformationFull)
322
+ clients_dict[client_id] = client.__dict__
323
+ except Exception as client_error:
324
+ logger.error(f"Failed to serialize client {client_id}: {client_error}")
325
+ continue
326
+
327
+ # Write to temporary file first, then rename for atomic operation
328
+ temp_path = client_json_path.with_suffix('.tmp')
329
+ with temp_path.open("w") as f:
330
+ json.dump(clients_dict, f, indent=2, ensure_ascii=False)
331
+
332
+ # Atomic rename
333
+ temp_path.replace(client_json_path)
334
+
335
+ logger.debug(f"Successfully saved {len(clients_dict)} clients to {client_json_path}")
336
+
337
+ except Exception as e:
338
+ logger.error(f"Failed to save client data to {self.clients_storage_path}: {e}")
339
+ raise
340
+
341
+ def _load_clients(self) -> None:
342
+ """Load client data from persistent storage.
343
+
344
+ Loads clients from the JSON file if it exists and is valid.
345
+ Invalid client data is logged and skipped.
346
+ """
347
+ if not self.clients_storage_path:
348
+ logger.debug("No clients storage path configured. Skipping client load.")
349
+ return
350
+
351
+ try:
352
+ client_json_path = Path(self.clients_storage_path) / "clients.json"
353
+
354
+ if not client_json_path.exists():
355
+ logger.debug(f"Client storage file {client_json_path} does not exist. Starting with empty client registry.")
356
+ return
357
+
358
+ # Read and parse the JSON file
359
+ with client_json_path.open("r", encoding="utf-8") as f:
360
+ clients_data = json.load(f)
361
+
362
+ if not isinstance(clients_data, dict):
363
+ logger.error(f"Invalid client data format in {client_json_path}: expected dict, got {type(clients_data)}")
364
+ return
365
+
366
+ loaded_count = 0
367
+ for client_id, client_info in clients_data.items():
368
+ try:
369
+ # Validate client_id is a string
370
+ if not isinstance(client_id, str):
371
+ logger.warning(f"Skipping client with non-string ID: {client_id} (type: {type(client_id)})")
372
+ continue
373
+
374
+ # Validate and restore the client object
375
+ if not isinstance(client_info, dict):
376
+ logger.warning(f"Skipping client {client_id}: invalid data format (expected dict, got {type(client_info)})")
377
+ continue
378
+
379
+ # Use Pydantic model_validate to restore the object with proper validation
380
+ client_obj = OAuthClientInformationFull.model_validate(client_info)
381
+ self._clients[client_id] = client_obj
382
+ loaded_count += 1
383
+
384
+ except Exception as client_error:
385
+ logger.error(f"Failed to load client {client_id}: {client_error}")
386
+ continue
387
+
388
+ logger.info(f"Successfully loaded {loaded_count} clients from {client_json_path}")
389
+
390
+ except json.JSONDecodeError as e:
391
+ logger.error(f"Invalid JSON in client storage file {client_json_path}: {e}")
392
+ except Exception as e:
393
+ logger.error(f"Failed to load clients from {client_json_path}: {e}")
@@ -0,0 +1,25 @@
1
+ """Backwards compatibility shim for BearerAuthProvider.
2
+
3
+ The BearerAuthProvider class has been moved to fastmcp.server.auth.providers.jwt.JWTVerifier
4
+ for better organization. This module provides a backwards-compatible import.
5
+ """
6
+
7
+ import warnings
8
+
9
+
10
+ from ..providers.jwt import JWKData, JWKSData, RSAKeyPair
11
+ from ..providers.jwt import JWTVerifier as BearerAuthProvider
12
+
13
+ # Re-export for backwards compatibility
14
+ __all__ = ["BearerAuthProvider", "RSAKeyPair", "JWKData", "JWKSData"]
15
+
16
+ # Deprecated in 2.11
17
+ # if fastmcp.settings.deprecation_warnings:
18
+ # warnings.warn(
19
+ # "The `fastmcp.server.auth.providers.bearer` module is deprecated "
20
+ # "and will be removed in a future version. "
21
+ # "Please use `fastmcp.server.auth.providers.jwt.JWTVerifier` "
22
+ # "instead of this module's BearerAuthProvider.",
23
+ # DeprecationWarning,
24
+ # stacklevel=2,
25
+ # )