pql-test 0.1.11__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.
pql_test/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """pql_test package — DAX test runner for PBIP semantic models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ # Expose main modules for easier imports
8
+ from pql_test.runner import (
9
+ Environment,
10
+ ModelIdentifier,
11
+ PqlTest,
12
+ TestResult,
13
+ TestSuite,
14
+ XmlaConnectionOptions,
15
+ parse_model_identifier,
16
+ retrieve_tests,
17
+ retrieve_test_by_name,
18
+ run_tests_from_model,
19
+ )
20
+
21
+ __all__ = [
22
+ "__version__",
23
+ "Environment",
24
+ "ModelIdentifier",
25
+ "PqlTest",
26
+ "TestResult",
27
+ "TestSuite",
28
+ "XmlaConnectionOptions",
29
+ "parse_model_identifier",
30
+ "retrieve_tests",
31
+ "retrieve_test_by_name",
32
+ "run_tests_from_model",
33
+ ]
@@ -0,0 +1,37 @@
1
+ """
2
+ pql_test.auth package — Authentication management for pql-test.
3
+
4
+ This package provides:
5
+ - Secure credential storage using platform keyring
6
+ - Token acquisition via azure.identity
7
+ - Support for multiple authentication methods (interactive, service principal, managed identity)
8
+ - Backward compatibility with environment variables and CLI flags
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pql_test.auth.cloud_config import CloudConfig
14
+ from pql_test.auth.exceptions import (
15
+ AuthenticationError,
16
+ CredentialConfigError,
17
+ CredentialStorageError,
18
+ TokenAcquisitionError,
19
+ )
20
+ from pql_test.auth.factory import CredentialFactory
21
+ from pql_test.auth.manager import AuthenticationManager
22
+ from pql_test.auth.models import AuthMethod, AuthStatus, CredentialConfig
23
+ from pql_test.auth.store import CredentialStore
24
+
25
+ __all__ = [
26
+ "AuthenticationManager",
27
+ "CredentialConfig",
28
+ "AuthStatus",
29
+ "AuthMethod",
30
+ "CloudConfig",
31
+ "CredentialStore",
32
+ "CredentialFactory",
33
+ "AuthenticationError",
34
+ "CredentialStorageError",
35
+ "TokenAcquisitionError",
36
+ "CredentialConfigError",
37
+ ]
@@ -0,0 +1,63 @@
1
+ """
2
+ Cloud environment configuration for sovereign clouds.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+
8
+ class CloudConfig:
9
+ """Cloud environment configuration for Azure sovereign clouds."""
10
+
11
+ ENVIRONMENTS = {
12
+ "Public": {
13
+ "authority": "https://login.microsoftonline.com",
14
+ "resource": "https://analysis.windows.net/powerbi/api",
15
+ },
16
+ "USGov": {
17
+ "authority": "https://login.microsoftonline.us",
18
+ "resource": "https://analysis.usgovcloudapi.net/powerbi/api",
19
+ },
20
+ "USGovHigh": {
21
+ "authority": "https://login.microsoftonline.us",
22
+ "resource": "https://high.analysis.usgovcloudapi.net/powerbi/api",
23
+ },
24
+ "USGovDoD": {
25
+ "authority": "https://login.microsoftonline.us",
26
+ "resource": "https://mil.analysis.usgovcloudapi.net/powerbi/api",
27
+ },
28
+ "Germany": {
29
+ "authority": "https://login.microsoftonline.de",
30
+ "resource": "https://analysis.cloudapi.de/powerbi/api",
31
+ },
32
+ "China": {
33
+ "authority": "https://login.chinacloudapi.cn",
34
+ "resource": "https://analysis.chinacloudapi.cn/powerbi/api",
35
+ },
36
+ }
37
+
38
+ def get_authority(self, environment: str) -> str:
39
+ """
40
+ Get Azure AD authority URL for environment.
41
+
42
+ Args:
43
+ environment: Cloud environment name
44
+
45
+ Returns:
46
+ Authority URL for the specified environment (defaults to Public)
47
+ """
48
+ env_config = self.ENVIRONMENTS.get(environment, self.ENVIRONMENTS["Public"])
49
+ return env_config["authority"]
50
+
51
+ def get_resource_scope(self, environment: str) -> str:
52
+ """
53
+ Get Power BI resource scope for environment.
54
+
55
+ Args:
56
+ environment: Cloud environment name
57
+
58
+ Returns:
59
+ Resource scope URL with /.default suffix
60
+ """
61
+ env_config = self.ENVIRONMENTS.get(environment, self.ENVIRONMENTS["Public"])
62
+ resource = env_config["resource"]
63
+ return f"{resource}/.default"
@@ -0,0 +1,29 @@
1
+ """
2
+ Exceptions for authentication operations.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+
8
+ class AuthenticationError(Exception):
9
+ """Base class for authentication errors."""
10
+
11
+ pass
12
+
13
+
14
+ class CredentialStorageError(AuthenticationError):
15
+ """Error storing or retrieving credentials from keyring."""
16
+
17
+ pass
18
+
19
+
20
+ class TokenAcquisitionError(AuthenticationError):
21
+ """Error acquiring access token from Azure AD."""
22
+
23
+ pass
24
+
25
+
26
+ class CredentialConfigError(AuthenticationError):
27
+ """Invalid credential configuration."""
28
+
29
+ pass
@@ -0,0 +1,211 @@
1
+ """
2
+ Credential factory for creating azure.identity credential objects.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ from typing import TYPE_CHECKING
9
+
10
+ try:
11
+ from azure.identity import (
12
+ AuthenticationRecord,
13
+ CertificateCredential,
14
+ ClientAssertionCredential,
15
+ ClientSecretCredential,
16
+ InteractiveBrowserCredential,
17
+ ManagedIdentityCredential,
18
+ TokenCachePersistenceOptions,
19
+ )
20
+ from azure.core.credentials import TokenCredential
21
+ HAS_AZURE_IDENTITY = True
22
+ except ImportError:
23
+ HAS_AZURE_IDENTITY = False
24
+ AuthenticationRecord = None # type: ignore[assignment,misc]
25
+ TokenCachePersistenceOptions = None # type: ignore[assignment,misc]
26
+ if TYPE_CHECKING:
27
+ from azure.core.credentials import TokenCredential
28
+
29
+ from pql_test.auth.cloud_config import CloudConfig
30
+ from pql_test.auth.exceptions import CredentialConfigError
31
+ from pql_test.auth.models import AuthMethod, CredentialConfig
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Default interactive client_id
36
+ # ---------------------------------------------------------------------------
37
+ #
38
+ # Power BI's XMLA endpoint only accepts bearer tokens whose `appid` claim
39
+ # matches a client app the gateway trusts. azure-identity's
40
+ # InteractiveBrowserCredential defaults to the Azure CLI public client
41
+ # (`04b07795-…`), which Power BI XMLA rejects with
42
+ # `Authentication failed for all authenticators`. Verified empirically:
43
+ # with this client_id removed, a fresh interactive sign-in (cleared cache,
44
+ # no .env) fails against XMLA; with it present, it succeeds.
45
+ #
46
+ # The pql-test project registers its own multi-tenant Azure AD application
47
+ # (public client, delegated Power BI permissions) and ships its client_id
48
+ # here as the default for the INTERACTIVE auth method. End users grant
49
+ # consent on first sign-in and never have to register their own app.
50
+ #
51
+ # Forks / enterprises that have registered their own pql-test equivalent
52
+ # can override via the PQL_TEST_INTERACTIVE_CLIENT_ID env var without
53
+ # touching the source.
54
+ PQL_TEST_DEFAULT_INTERACTIVE_CLIENT_ID = "e6341730-1a0b-4a9e-9462-a435d224b5fd"
55
+
56
+
57
+ def _resolve_interactive_client_id(config_client_id: str | None) -> str:
58
+ """Resolution order: explicit config > env-var override > bundled default."""
59
+ if config_client_id:
60
+ return config_client_id
61
+ return (
62
+ os.environ.get("PQL_TEST_INTERACTIVE_CLIENT_ID")
63
+ or PQL_TEST_DEFAULT_INTERACTIVE_CLIENT_ID
64
+ )
65
+
66
+
67
+ def _persistent_token_cache_options(): # type: ignore[no-untyped-def]
68
+ """Build ``TokenCachePersistenceOptions`` so MSAL writes tokens to the
69
+ platform's secure store (Windows DPAPI / macOS Keychain / Linux
70
+ libsecret). Without this, every CLI invocation builds a fresh credential
71
+ with a cold cache and triggers a new interactive sign-in — the user
72
+ sees a browser tab pop on every ``pql-test`` command.
73
+
74
+ Defaults to encrypted-only storage. Set
75
+ ``PQL_TEST_ALLOW_UNENCRYPTED_TOKEN_CACHE=1`` on headless Linux without
76
+ libsecret to opt into unencrypted file-backed caching.
77
+
78
+ Returns ``None`` if azure-identity is missing — the caller treats that
79
+ as "no persistence configured" which falls back to azure-identity's
80
+ default in-memory cache.
81
+ """
82
+ if TokenCachePersistenceOptions is None:
83
+ return None
84
+ allow_unencrypted = os.environ.get(
85
+ "PQL_TEST_ALLOW_UNENCRYPTED_TOKEN_CACHE", ""
86
+ ).lower() in ("1", "true", "yes")
87
+ return TokenCachePersistenceOptions(
88
+ name="pql-test",
89
+ allow_unencrypted_storage=allow_unencrypted,
90
+ )
91
+
92
+
93
+ class CredentialFactory:
94
+ """Factory for creating azure.identity credential objects from configuration."""
95
+
96
+ def create(
97
+ self, config: CredentialConfig, cloud_config: CloudConfig
98
+ ) -> TokenCredential:
99
+ """
100
+ Create TokenCredential from configuration.
101
+
102
+ Args:
103
+ config: Credential configuration
104
+ cloud_config: Cloud environment configuration
105
+
106
+ Returns:
107
+ TokenCredential instance
108
+
109
+ Raises:
110
+ CredentialConfigError: If azure-identity not installed or config is invalid
111
+ """
112
+ if not HAS_AZURE_IDENTITY:
113
+ raise CredentialConfigError(
114
+ "azure-identity is required for authentication. "
115
+ "Install with: pip install azure-identity"
116
+ )
117
+
118
+ authority = cloud_config.get_authority(config.environment)
119
+ cache_opts = _persistent_token_cache_options()
120
+
121
+ if config.method == AuthMethod.INTERACTIVE:
122
+ # Replay the AuthenticationRecord captured on first sign-in so
123
+ # azure-identity can look up the cached token by homeAccountId
124
+ # instead of triggering a fresh browser flow. Without this,
125
+ # cache_persistence_options writes a cache that's never read.
126
+ record = None
127
+ if config.authentication_record_json and AuthenticationRecord is not None:
128
+ try:
129
+ record = AuthenticationRecord.deserialize(
130
+ config.authentication_record_json
131
+ )
132
+ except Exception: # noqa: BLE001
133
+ # Corrupted record — fall through to interactive prompt.
134
+ record = None
135
+
136
+ # When a record is supplied, azure-identity reads tenant_id,
137
+ # authority, and client_id from it directly — passing those
138
+ # kwargs ALSO causes a "got multiple values for keyword argument"
139
+ # TypeError inside MsalCredential.__init__. Only pass them on
140
+ # the first-login path where we have no record.
141
+ if record is not None:
142
+ return InteractiveBrowserCredential(
143
+ cache_persistence_options=cache_opts,
144
+ authentication_record=record,
145
+ )
146
+ return InteractiveBrowserCredential(
147
+ tenant_id=config.tenant_id,
148
+ authority=authority,
149
+ client_id=_resolve_interactive_client_id(config.client_id),
150
+ cache_persistence_options=cache_opts,
151
+ )
152
+
153
+ elif config.method == AuthMethod.SERVICE_PRINCIPAL_SECRET:
154
+ if not all([config.tenant_id, config.client_id, config.client_secret]):
155
+ raise CredentialConfigError(
156
+ "Service principal with secret requires tenant_id, client_id, and client_secret"
157
+ )
158
+ return ClientSecretCredential(
159
+ tenant_id=config.tenant_id,
160
+ client_id=config.client_id,
161
+ client_secret=config.client_secret,
162
+ authority=authority,
163
+ cache_persistence_options=cache_opts,
164
+ )
165
+
166
+ elif config.method == AuthMethod.SERVICE_PRINCIPAL_CERT:
167
+ if not all([config.tenant_id, config.client_id, config.certificate_path]):
168
+ raise CredentialConfigError(
169
+ "Service principal with certificate requires tenant_id, client_id, and certificate_path"
170
+ )
171
+ return CertificateCredential(
172
+ tenant_id=config.tenant_id,
173
+ client_id=config.client_id,
174
+ certificate_path=config.certificate_path,
175
+ password=config.certificate_password,
176
+ authority=authority,
177
+ cache_persistence_options=cache_opts,
178
+ )
179
+
180
+ elif config.method == AuthMethod.SERVICE_PRINCIPAL_FEDERATED:
181
+ if not all([config.tenant_id, config.client_id, config.federated_token]):
182
+ raise CredentialConfigError(
183
+ "Federated credential requires tenant_id, client_id, and federated_token"
184
+ )
185
+
186
+ # Note: azure-identity uses ClientAssertionCredential for federated tokens
187
+ def token_provider() -> str:
188
+ return config.federated_token or ""
189
+
190
+ return ClientAssertionCredential(
191
+ tenant_id=config.tenant_id,
192
+ client_id=config.client_id,
193
+ func=token_provider,
194
+ authority=authority,
195
+ cache_persistence_options=cache_opts,
196
+ )
197
+
198
+ elif config.method == AuthMethod.MANAGED_IDENTITY:
199
+ if config.use_system_assigned_identity:
200
+ return ManagedIdentityCredential()
201
+ else:
202
+ if not config.client_id:
203
+ raise CredentialConfigError(
204
+ "User-assigned managed identity requires client_id"
205
+ )
206
+ return ManagedIdentityCredential(client_id=config.client_id)
207
+
208
+ else:
209
+ raise CredentialConfigError(
210
+ f"Unsupported authentication method: {config.method}"
211
+ )
@@ -0,0 +1,124 @@
1
+ """
2
+ Authentication manager — Main interface for authentication operations.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Optional
8
+
9
+ try:
10
+ from azure.core.credentials import TokenCredential
11
+ except ImportError:
12
+ TokenCredential = None # type: ignore
13
+
14
+ from pql_test.auth.cloud_config import CloudConfig
15
+ from pql_test.auth.exceptions import TokenAcquisitionError
16
+ from pql_test.auth.factory import CredentialFactory
17
+ from pql_test.auth.models import AuthStatus, CredentialConfig
18
+ from pql_test.auth.store import CredentialStore
19
+
20
+
21
+ class AuthenticationManager:
22
+ """
23
+ Manages authentication for pql-test.
24
+
25
+ Handles credential storage, token acquisition, and authentication state.
26
+ Provides a unified interface for all authentication methods.
27
+ """
28
+
29
+ def __init__(self, namespace: str = "pql-test"):
30
+ """
31
+ Initialize authentication manager.
32
+
33
+ Args:
34
+ namespace: Keyring namespace for credential storage
35
+ """
36
+ self._store = CredentialStore(namespace)
37
+ self._factory = CredentialFactory()
38
+ self._cloud_config = CloudConfig()
39
+
40
+ def save_credential(self, config: CredentialConfig) -> None:
41
+ """
42
+ Save credential configuration to secure storage.
43
+
44
+ Args:
45
+ config: Credential configuration to store
46
+
47
+ Raises:
48
+ CredentialStorageError: If credential cannot be stored
49
+ """
50
+ self._store.save(config)
51
+
52
+ def get_credential(self) -> Optional[TokenCredential]:
53
+ """
54
+ Get credential from stored configuration.
55
+
56
+ Returns:
57
+ TokenCredential instance or None if not authenticated
58
+
59
+ Raises:
60
+ CredentialConfigError: If stored config is invalid
61
+ """
62
+ config = self._store.load()
63
+ if config is None:
64
+ return None
65
+
66
+ return self._factory.create(config, self._cloud_config)
67
+
68
+ def get_access_token(
69
+ self, scope: str = "https://analysis.windows.net/powerbi/api/.default"
70
+ ) -> Optional[str]:
71
+ """
72
+ Get access token for the given scope.
73
+
74
+ Args:
75
+ scope: OAuth2 scope for the token
76
+
77
+ Returns:
78
+ Access token string or None if not authenticated
79
+
80
+ Raises:
81
+ TokenAcquisitionError: If token acquisition fails
82
+ """
83
+ credential = self.get_credential()
84
+ if credential is None:
85
+ return None
86
+
87
+ try:
88
+ token_result = credential.get_token(scope)
89
+ return token_result.token
90
+ except Exception as e:
91
+ raise TokenAcquisitionError(
92
+ f"Failed to acquire access token: {e}"
93
+ ) from e
94
+
95
+ def clear_credential(self) -> None:
96
+ """
97
+ Clear stored credential configuration.
98
+
99
+ Raises:
100
+ CredentialStorageError: If credential cannot be cleared
101
+ """
102
+ self._store.delete()
103
+
104
+ def get_status(self) -> AuthStatus:
105
+ """
106
+ Get current authentication status.
107
+
108
+ Returns:
109
+ AuthStatus with current authentication state
110
+ """
111
+ config = self._store.load()
112
+
113
+ if config is None:
114
+ return AuthStatus(authenticated=False)
115
+
116
+ return AuthStatus(
117
+ authenticated=True,
118
+ method=config.method,
119
+ account_name=config.account_name,
120
+ tenant_id=config.tenant_id,
121
+ client_id=config.client_id,
122
+ environment=config.environment,
123
+ token_is_valid=False, # TODO: Implement token validation
124
+ )
@@ -0,0 +1,133 @@
1
+ """
2
+ Data models for authentication configuration and status.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from dataclasses import asdict, dataclass
9
+ from datetime import datetime
10
+ from enum import Enum
11
+ from typing import Optional
12
+
13
+
14
+ class AuthMethod(Enum):
15
+ """Supported authentication methods."""
16
+
17
+ INTERACTIVE = "interactive"
18
+ SERVICE_PRINCIPAL_SECRET = "service_principal_secret"
19
+ SERVICE_PRINCIPAL_CERT = "service_principal_cert"
20
+ SERVICE_PRINCIPAL_FEDERATED = "service_principal_federated"
21
+ MANAGED_IDENTITY = "managed_identity"
22
+
23
+
24
+ @dataclass
25
+ class CredentialConfig:
26
+ """
27
+ Credential configuration stored in keyring.
28
+
29
+ Attributes:
30
+ method: Authentication method
31
+ tenant_id: Azure AD tenant ID (optional for some methods)
32
+ client_id: Client/application ID
33
+ client_secret: Client secret (service principal with secret only)
34
+ certificate_path: Path to certificate file (certificate auth only)
35
+ certificate_password: Certificate password (encrypted cert only)
36
+ federated_token: Federated credential token (federated auth only)
37
+ use_system_assigned_identity: Use system-assigned managed identity
38
+ environment: Cloud environment (Public, USGov, etc.)
39
+ account_name: Display name for status output
40
+ """
41
+
42
+ method: AuthMethod
43
+ tenant_id: Optional[str] = None
44
+ client_id: Optional[str] = None
45
+ client_secret: Optional[str] = None
46
+ certificate_path: Optional[str] = None
47
+ certificate_password: Optional[str] = None
48
+ federated_token: Optional[str] = None
49
+ use_system_assigned_identity: bool = False
50
+ environment: str = "Public"
51
+ account_name: Optional[str] = None
52
+ # Serialized azure-identity AuthenticationRecord (JSON) — captured on
53
+ # interactive sign-in and replayed on subsequent runs so the persistent
54
+ # MSAL token cache can be looked up silently instead of triggering a
55
+ # fresh browser flow. Only populated for AuthMethod.INTERACTIVE.
56
+ authentication_record_json: Optional[str] = None
57
+
58
+ def to_dict(self) -> dict:
59
+ """
60
+ Serialize to dictionary.
61
+
62
+ Returns:
63
+ Dictionary representation with AuthMethod converted to string
64
+ """
65
+ data = asdict(self)
66
+ data["method"] = self.method.value
67
+ return data
68
+
69
+ def to_json(self) -> str:
70
+ """
71
+ Serialize to JSON string.
72
+
73
+ Returns:
74
+ JSON string representation
75
+ """
76
+ return json.dumps(self.to_dict())
77
+
78
+ @classmethod
79
+ def from_dict(cls, data: dict) -> CredentialConfig:
80
+ """
81
+ Deserialize from dictionary.
82
+
83
+ Args:
84
+ data: Dictionary representation
85
+
86
+ Returns:
87
+ CredentialConfig instance
88
+ """
89
+ # Convert method string to enum
90
+ if "method" in data and isinstance(data["method"], str):
91
+ data = data.copy()
92
+ data["method"] = AuthMethod(data["method"])
93
+ return cls(**data)
94
+
95
+ @classmethod
96
+ def from_json(cls, json_str: str) -> CredentialConfig:
97
+ """
98
+ Deserialize from JSON string.
99
+
100
+ Args:
101
+ json_str: JSON string representation
102
+
103
+ Returns:
104
+ CredentialConfig instance
105
+ """
106
+ data = json.loads(json_str)
107
+ return cls.from_dict(data)
108
+
109
+
110
+ @dataclass
111
+ class AuthStatus:
112
+ """
113
+ Current authentication status.
114
+
115
+ Attributes:
116
+ authenticated: Whether user is authenticated
117
+ method: Authentication method used (if authenticated)
118
+ account_name: Display name of authenticated account
119
+ tenant_id: Azure AD tenant ID
120
+ client_id: Client/application ID
121
+ environment: Cloud environment
122
+ token_expires_at: Token expiration timestamp
123
+ token_is_valid: Whether token is currently valid (not expired)
124
+ """
125
+
126
+ authenticated: bool
127
+ method: Optional[AuthMethod] = None
128
+ account_name: Optional[str] = None
129
+ tenant_id: Optional[str] = None
130
+ client_id: Optional[str] = None
131
+ environment: str = "Public"
132
+ token_expires_at: Optional[datetime] = None
133
+ token_is_valid: bool = False