snowflake-cli 3.10.1__py3-none-any.whl → 3.12.0__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 (61) hide show
  1. snowflake/cli/__about__.py +1 -1
  2. snowflake/cli/_app/auth/__init__.py +13 -0
  3. snowflake/cli/_app/auth/errors.py +28 -0
  4. snowflake/cli/_app/auth/oidc_providers.py +393 -0
  5. snowflake/cli/_app/cli_app.py +0 -1
  6. snowflake/cli/_app/constants.py +10 -0
  7. snowflake/cli/_app/printing.py +153 -19
  8. snowflake/cli/_app/snow_connector.py +35 -0
  9. snowflake/cli/_plugins/auth/__init__.py +4 -2
  10. snowflake/cli/_plugins/auth/keypair/commands.py +2 -0
  11. snowflake/cli/_plugins/auth/oidc/__init__.py +13 -0
  12. snowflake/cli/_plugins/auth/oidc/commands.py +47 -0
  13. snowflake/cli/_plugins/auth/oidc/manager.py +66 -0
  14. snowflake/cli/_plugins/auth/oidc/plugin_spec.py +30 -0
  15. snowflake/cli/_plugins/connection/commands.py +37 -3
  16. snowflake/cli/_plugins/dbt/commands.py +37 -8
  17. snowflake/cli/_plugins/dbt/manager.py +144 -12
  18. snowflake/cli/_plugins/dcm/commands.py +102 -136
  19. snowflake/cli/_plugins/dcm/manager.py +136 -89
  20. snowflake/cli/_plugins/logs/commands.py +7 -0
  21. snowflake/cli/_plugins/logs/manager.py +21 -1
  22. snowflake/cli/_plugins/nativeapp/sf_sql_facade.py +3 -1
  23. snowflake/cli/_plugins/notebook/notebook_entity.py +2 -0
  24. snowflake/cli/_plugins/notebook/notebook_entity_model.py +8 -1
  25. snowflake/cli/_plugins/object/command_aliases.py +16 -1
  26. snowflake/cli/_plugins/object/commands.py +27 -1
  27. snowflake/cli/_plugins/object/manager.py +12 -1
  28. snowflake/cli/_plugins/snowpark/commands.py +8 -1
  29. snowflake/cli/_plugins/snowpark/common.py +1 -0
  30. snowflake/cli/_plugins/snowpark/package/anaconda_packages.py +29 -5
  31. snowflake/cli/_plugins/snowpark/package_utils.py +44 -3
  32. snowflake/cli/_plugins/spcs/services/manager.py +5 -4
  33. snowflake/cli/_plugins/sql/lexer/types.py +1 -0
  34. snowflake/cli/_plugins/sql/repl.py +100 -26
  35. snowflake/cli/_plugins/sql/repl_commands.py +607 -0
  36. snowflake/cli/_plugins/sql/statement_reader.py +44 -20
  37. snowflake/cli/api/artifacts/bundle_map.py +32 -2
  38. snowflake/cli/api/artifacts/regex_resolver.py +54 -0
  39. snowflake/cli/api/artifacts/upload.py +5 -1
  40. snowflake/cli/api/artifacts/utils.py +12 -1
  41. snowflake/cli/api/cli_global_context.py +7 -0
  42. snowflake/cli/api/commands/decorators.py +7 -0
  43. snowflake/cli/api/commands/flags.py +26 -0
  44. snowflake/cli/api/config.py +24 -0
  45. snowflake/cli/api/connections.py +1 -0
  46. snowflake/cli/api/console/abc.py +13 -2
  47. snowflake/cli/api/console/console.py +20 -0
  48. snowflake/cli/api/constants.py +9 -0
  49. snowflake/cli/api/entities/utils.py +10 -6
  50. snowflake/cli/api/feature_flags.py +1 -0
  51. snowflake/cli/api/identifiers.py +18 -1
  52. snowflake/cli/api/project/schemas/entities/entities.py +0 -6
  53. snowflake/cli/api/rendering/sql_templates.py +2 -0
  54. snowflake/cli/api/utils/dict_utils.py +42 -1
  55. {snowflake_cli-3.10.1.dist-info → snowflake_cli-3.12.0.dist-info}/METADATA +15 -41
  56. {snowflake_cli-3.10.1.dist-info → snowflake_cli-3.12.0.dist-info}/RECORD +59 -52
  57. snowflake/cli/_plugins/dcm/dcm_project_entity_model.py +0 -59
  58. snowflake/cli/_plugins/sql/snowsql_commands.py +0 -331
  59. {snowflake_cli-3.10.1.dist-info → snowflake_cli-3.12.0.dist-info}/WHEEL +0 -0
  60. {snowflake_cli-3.10.1.dist-info → snowflake_cli-3.12.0.dist-info}/entry_points.txt +0 -0
  61. {snowflake_cli-3.10.1.dist-info → snowflake_cli-3.12.0.dist-info}/licenses/LICENSE +0 -0
@@ -16,7 +16,7 @@ from __future__ import annotations
16
16
 
17
17
  from enum import Enum, unique
18
18
 
19
- VERSION = "3.10.1"
19
+ VERSION = "3.12.0"
20
20
 
21
21
 
22
22
  @unique
@@ -0,0 +1,13 @@
1
+ # Copyright (c) 2024 Snowflake Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
@@ -0,0 +1,28 @@
1
+ class OidcProviderError(Exception):
2
+ """Base exception for OIDC provider related errors."""
3
+
4
+ ...
5
+
6
+
7
+ class OidcProviderNotFoundError(OidcProviderError):
8
+ """Exception raised when requested OIDC provider is not found or unknown."""
9
+
10
+ ...
11
+
12
+
13
+ class OidcProviderUnavailableError(OidcProviderError):
14
+ """Exception raised when OIDC provider is not available in current environment."""
15
+
16
+ ...
17
+
18
+
19
+ class OidcProviderAutoDetectionError(OidcProviderError):
20
+ """Exception raised when auto-detection of OIDC provider fails."""
21
+
22
+ ...
23
+
24
+
25
+ class OidcTokenRetrievalError(OidcProviderError):
26
+ """Exception raised when OIDC token cannot be retrieved."""
27
+
28
+ ...
@@ -0,0 +1,393 @@
1
+ # Copyright (c) 2024 Snowflake Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import importlib
16
+ import inspect
17
+ import logging
18
+ import os
19
+ from abc import ABC, abstractmethod
20
+ from enum import Enum
21
+ from typing import Dict, List, Literal, Optional, Type
22
+
23
+ import id as oidc_id
24
+ from snowflake.cli._app.auth.errors import (
25
+ OidcProviderAutoDetectionError,
26
+ OidcProviderNotFoundError,
27
+ OidcProviderUnavailableError,
28
+ OidcTokenRetrievalError,
29
+ )
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ ACTIONS_ID_TOKEN_REQUEST_URL_ENV: Literal[
35
+ "ACTIONS_ID_TOKEN_REQUEST_URL"
36
+ ] = "ACTIONS_ID_TOKEN_REQUEST_URL"
37
+ GITHUB_ACTIONS_ENV: Literal["GITHUB_ACTIONS"] = "GITHUB_ACTIONS"
38
+ SNOWFLAKE_AUDIENCE_ENV: Literal["SNOWFLAKE_AUDIENCE"] = "SNOWFLAKE_AUDIENCE"
39
+
40
+
41
+ class OidcProviderType(Enum):
42
+ """Enum for OIDC provider types."""
43
+
44
+ GITHUB = "github"
45
+
46
+
47
+ class OidcProviderTypeWithAuto(Enum):
48
+ """Extended version of OidcProviderType with AUTO."""
49
+
50
+ AUTO = "auto"
51
+ GITHUB = "github"
52
+
53
+
54
+ class OidcTokenProvider(ABC):
55
+ """
56
+ Abstract base class for OIDC token providers.
57
+ Each CI environment should implement this interface.
58
+ """
59
+
60
+ @property
61
+ @abstractmethod
62
+ def provider_name(self) -> str:
63
+ """
64
+ Returns the name of the CI provider (e.g., 'github', 'gitlab', 'azure-devops').
65
+ """
66
+ pass
67
+
68
+ @property
69
+ @abstractmethod
70
+ def is_available(self) -> bool:
71
+ """
72
+ Checks if this provider is available in the current environment.
73
+ Should return True if the provider can detect credentials in the current context.
74
+ """
75
+ pass
76
+
77
+ @property
78
+ @abstractmethod
79
+ def issuer(self) -> str:
80
+ """
81
+ Returns the OIDC issuer URL for this provider.
82
+
83
+ Returns:
84
+ The OIDC issuer URL
85
+ """
86
+ pass
87
+
88
+ @abstractmethod
89
+ def get_token(self) -> str:
90
+ """
91
+ Retrieves the OIDC token from the CI environment.
92
+
93
+ Returns:
94
+ The OIDC token string
95
+
96
+ Raises:
97
+ OidcProviderError: If token cannot be retrieved
98
+ """
99
+ pass
100
+
101
+
102
+ class GitHubOidcProvider(OidcTokenProvider):
103
+ """
104
+ OIDC token provider for GitHub Actions.
105
+ """
106
+
107
+ @property
108
+ def _is_ci(self):
109
+ logger.debug("Checking if GitHub Actions environment is available")
110
+
111
+ # Check if we're in a GitHub Actions environment
112
+ github_actions_env = os.getenv(GITHUB_ACTIONS_ENV)
113
+ logger.debug(
114
+ "%s environment variable: %s",
115
+ GITHUB_ACTIONS_ENV,
116
+ github_actions_env,
117
+ )
118
+
119
+ is_github_actions = github_actions_env == "true"
120
+ logger.debug("Running in GitHub Actions: %s", is_github_actions)
121
+ return is_github_actions
122
+
123
+ @property
124
+ def audience(self) -> str:
125
+ """
126
+ Returns the audience URL for GitHub OIDC.
127
+
128
+ Returns:
129
+ The audience URL, defaults to 'snowflakecomputing.com' if SNOWFLAKE_AUDIENCE environment variable is not set
130
+ """
131
+ return os.getenv(SNOWFLAKE_AUDIENCE_ENV, "snowflakecomputing.com")
132
+
133
+ @property
134
+ def issuer(self) -> str:
135
+ """
136
+ Returns the GitHub OIDC issuer URL.
137
+
138
+ Returns:
139
+ The GitHub OIDC issuer URL from ACTIONS_ID_TOKEN_REQUEST_URL environment variable,
140
+ or the default GitHub issuer URL if the environment variable is not set
141
+ """
142
+ issuer_url = os.getenv(ACTIONS_ID_TOKEN_REQUEST_URL_ENV)
143
+ if not issuer_url and self._is_ci:
144
+ raise OidcTokenRetrievalError(
145
+ "%s environment variable is not set. "
146
+ "This variable is required for Github Actions OIDC authentication"
147
+ % ACTIONS_ID_TOKEN_REQUEST_URL_ENV
148
+ )
149
+ return issuer_url or "https://token.actions.githubusercontent.com"
150
+
151
+ @property
152
+ def provider_name(self) -> str:
153
+ return OidcProviderType.GITHUB.value
154
+
155
+ @property
156
+ def is_available(self) -> bool:
157
+ """
158
+ Checks if GitHub Actions environment is available.
159
+ """
160
+ return self._is_ci
161
+
162
+ def get_token(self) -> str:
163
+ """
164
+ Retrieves the OIDC token from GitHub Actions.
165
+ """
166
+ logger.debug("Retrieving OIDC token from GitHub Actions")
167
+
168
+ try:
169
+ logger.debug("Detecting OIDC credentials for token retrieval")
170
+ # Use configurable audience for workload identity
171
+ token = oidc_id.detect_credential(self.audience)
172
+ if not token:
173
+ logger.error("No OIDC credentials detected")
174
+ raise OidcTokenRetrievalError(
175
+ "No OIDC credentials detected. This command should be run in a GitHub Actions environment."
176
+ )
177
+
178
+ logger.info("Successfully retrieved OIDC token")
179
+ return token
180
+ except Exception as e:
181
+ logger.error("Failed to detect OIDC credentials: %s", str(e))
182
+ raise OidcTokenRetrievalError(
183
+ "Failed to detect OIDC credentials: %s" % str(e)
184
+ )
185
+
186
+
187
+ class OidcProviderRegistry:
188
+ """
189
+ Registry for managing OIDC token providers.
190
+ Handles registration, storage, and retrieval of providers.
191
+ """
192
+
193
+ def __init__(self) -> None:
194
+ self._providers: Dict[str, Type[OidcTokenProvider]] = {}
195
+ self._auto_discover_providers()
196
+
197
+ def _auto_discover_providers(self) -> None:
198
+ """
199
+ Auto-discovers all OIDC token providers in the current module.
200
+ """
201
+ logger.debug("Auto-discovering OIDC token providers")
202
+ current_module = importlib.import_module(__name__)
203
+
204
+ for name, obj in inspect.getmembers(current_module):
205
+ if (
206
+ inspect.isclass(obj)
207
+ and issubclass(obj, OidcTokenProvider)
208
+ and obj != OidcTokenProvider
209
+ ):
210
+ provider_instance = obj()
211
+ provider_name = provider_instance.provider_name
212
+ logger.debug("Discovered OIDC provider: %s (%s)", provider_name, name)
213
+ self._providers[provider_name] = obj
214
+
215
+ logger.info(
216
+ "Auto-discovered %d OIDC provider(s): %s",
217
+ len(self._providers),
218
+ list(self._providers.keys()),
219
+ )
220
+
221
+ def register_provider(self, provider_class: Type[OidcTokenProvider]) -> None:
222
+ """
223
+ Manually register a provider class.
224
+ """
225
+ provider_instance = provider_class()
226
+ self._providers[provider_instance.provider_name] = provider_class
227
+
228
+ def get_provider(self, provider_name: str) -> Optional[OidcTokenProvider]:
229
+ """
230
+ Get a specific provider by name.
231
+ """
232
+ provider_class = self._providers.get(provider_name)
233
+ if provider_class:
234
+ return provider_class()
235
+ return None
236
+
237
+ def get_provider_class(
238
+ self, provider_name: str
239
+ ) -> Optional[Type[OidcTokenProvider]]:
240
+ """
241
+ Get a specific provider class by name.
242
+ """
243
+ return self._providers.get(provider_name)
244
+
245
+ @property
246
+ def provider_names(self) -> List[str]:
247
+ """
248
+ List all registered provider names.
249
+ """
250
+ return list(self._providers.keys())
251
+
252
+ @property
253
+ def all_providers(self) -> List[OidcTokenProvider]:
254
+ """
255
+ Get instances of all registered providers.
256
+ """
257
+ return [provider_class() for provider_class in self._providers.values()]
258
+
259
+
260
+ # Global registry instance
261
+ _registry = OidcProviderRegistry()
262
+
263
+
264
+ def get_oidc_provider(provider_name: str) -> OidcTokenProvider:
265
+ """
266
+ Get a specific OIDC provider by name without checking availability.
267
+
268
+ Args:
269
+ provider_name: Name of the provider to get
270
+
271
+ Returns:
272
+ The requested OIDC provider instance
273
+
274
+ Raises:
275
+ OidcProviderNotFoundError: If provider is unknown
276
+ """
277
+ provider = _registry.get_provider(provider_name)
278
+
279
+ if not provider:
280
+ providers_list = ", ".join(_registry.provider_names)
281
+ raise OidcProviderNotFoundError(
282
+ "Unknown provider '%s'. Available providers: %s"
283
+ % (
284
+ provider_name,
285
+ providers_list,
286
+ )
287
+ )
288
+
289
+ return provider
290
+
291
+
292
+ def get_active_oidc_provider(provider_name: str) -> OidcTokenProvider:
293
+ """
294
+ Get a specific OIDC provider by name and ensure it's available.
295
+
296
+ Args:
297
+ provider_name: Name of the provider to get
298
+
299
+ Returns:
300
+ The requested OIDC provider instance
301
+
302
+ Raises:
303
+ OidcProviderNotFoundError: If provider is unknown
304
+ OidcProviderUnavailableError: If provider is not available
305
+ """
306
+ provider = get_oidc_provider(provider_name)
307
+
308
+ if not provider.is_available:
309
+ raise OidcProviderUnavailableError(
310
+ "Provider '%s' is not available in the current environment." % provider_name
311
+ )
312
+
313
+ return provider
314
+
315
+
316
+ def get_oidc_provider_class(provider_name: str) -> Type[OidcTokenProvider]:
317
+ """
318
+ Get a specific OIDC provider class by name.
319
+
320
+ Args:
321
+ provider_name: Name of the provider to get
322
+
323
+ Returns:
324
+ The requested OIDC provider class
325
+
326
+ Raises:
327
+ OidcProviderNotFoundError: If provider is unknown
328
+ """
329
+ provider_class = _registry.get_provider_class(provider_name)
330
+
331
+ if not provider_class:
332
+ providers_list = ", ".join(_registry.provider_names)
333
+ raise OidcProviderNotFoundError(
334
+ "Unknown provider '%s'. Available providers: %s"
335
+ % (
336
+ provider_name,
337
+ providers_list,
338
+ )
339
+ )
340
+
341
+ return provider_class
342
+
343
+
344
+ def auto_detect_oidc_provider() -> OidcTokenProvider:
345
+ """
346
+ Auto-detect a single available OIDC provider in the current environment.
347
+
348
+ Returns:
349
+ The single available OIDC provider
350
+
351
+ Raises:
352
+ OidcProviderAutoDetectionError: If no providers are available or multiple providers are available
353
+ """
354
+ available = [
355
+ provider for provider in _registry.all_providers if provider.is_available
356
+ ]
357
+ available_names = [p.provider_name for p in available]
358
+
359
+ all_providers = _registry.provider_names
360
+ match (len(available), all_providers):
361
+ case (1, _):
362
+ # Happy path - single provider found
363
+ logger.info("Found 1 available provider: %s", available_names[0])
364
+ return available[0]
365
+ case (0, providers) if providers:
366
+ # No providers available but some are registered
367
+ providers_list = ", ".join(providers)
368
+ msg = (
369
+ "No OIDC provider detected in current environment. "
370
+ "Available providers: %s. "
371
+ "Use --type <provider> to specify a provider explicitly."
372
+ ) % providers_list
373
+ logger.info(msg)
374
+ raise OidcProviderAutoDetectionError(msg)
375
+ case (0, _):
376
+ # No providers available and none are registered
377
+ msg = "No OIDC providers are registered."
378
+ logger.info(msg)
379
+ raise OidcProviderAutoDetectionError(msg)
380
+ case _:
381
+ # Multiple providers available - raise error
382
+ providers_list = ", ".join(available_names)
383
+ msg = (
384
+ "Multiple OIDC providers detected: %s. "
385
+ "Please specify which provider to use with --type <provider>."
386
+ ) % providers_list
387
+ logger.info(msg)
388
+ raise OidcProviderAutoDetectionError(msg)
389
+
390
+ # This line should never be reached, but helps mypy understand all paths are covered
391
+ raise OidcProviderAutoDetectionError(
392
+ "Unexpected state in auto_detect_oidc_provider"
393
+ )
@@ -256,7 +256,6 @@ class CliAppFactory:
256
256
  "--commands-registration",
257
257
  help="Commands registration",
258
258
  hidden=True,
259
- is_eager=True,
260
259
  callback=self._commands_registration_callback(),
261
260
  ),
262
261
  ) -> None:
@@ -21,3 +21,13 @@ PARAM_APPLICATION_NAME: Literal["snowcli"] = "snowcli"
21
21
  # This is also defined on server side. Changing this parameter would require
22
22
  # a change in https://github.com/snowflakedb/snowflake
23
23
  INTERNAL_APPLICATION_NAME: Literal["SNOWFLAKE_CLI"] = "SNOWFLAKE_CLI"
24
+
25
+ # Authenticator types
26
+ AUTHENTICATOR_WORKLOAD_IDENTITY: Literal["WORKLOAD_IDENTITY"] = "WORKLOAD_IDENTITY"
27
+ AUTHENTICATOR_SNOWFLAKE_JWT: Literal["SNOWFLAKE_JWT"] = "SNOWFLAKE_JWT"
28
+ AUTHENTICATOR_USERNAME_PASSWORD_MFA: Literal[
29
+ "username_password_mfa"
30
+ ] = "username_password_mfa"
31
+ AUTHENTICATOR_OAUTH_AUTHORIZATION_CODE: Literal[
32
+ "OAUTH_AUTHORIZATION_CODE"
33
+ ] = "OAUTH_AUTHORIZATION_CODE"
@@ -22,7 +22,7 @@ from decimal import Decimal
22
22
  from json import JSONEncoder
23
23
  from pathlib import Path
24
24
  from textwrap import indent
25
- from typing import TextIO
25
+ from typing import Any, Dict, TextIO
26
26
 
27
27
  from rich import box, get_console
28
28
  from rich import print as rich_print
@@ -61,13 +61,114 @@ class CustomJSONEncoder(JSONEncoder):
61
61
  return list(o.result)
62
62
  if isinstance(o, (date, datetime, time)):
63
63
  return o.isoformat()
64
- if isinstance(o, (Path, Decimal)):
64
+ if isinstance(o, Path):
65
+ return o.as_posix()
66
+ if isinstance(o, Decimal):
65
67
  return str(o)
66
68
  if isinstance(o, bytearray):
67
69
  return o.hex()
68
70
  return super().default(o)
69
71
 
70
72
 
73
+ class StreamingJSONEncoder(JSONEncoder):
74
+ """Streaming JSON encoder that doesn't materialize generators into lists"""
75
+
76
+ def default(self, o):
77
+ if isinstance(o, str):
78
+ return sanitize_for_terminal(o)
79
+ if isinstance(o, (ObjectResult, MessageResult)):
80
+ return o.result
81
+ if isinstance(o, (CollectionResult, MultipleResults)):
82
+ raise TypeError(
83
+ f"CollectionResult should be handled by streaming functions, not encoder"
84
+ )
85
+ if isinstance(o, (date, datetime, time)):
86
+ return o.isoformat()
87
+ if isinstance(o, Path):
88
+ return o.as_posix()
89
+ if isinstance(o, Decimal):
90
+ return str(o)
91
+ if isinstance(o, bytearray):
92
+ return o.hex()
93
+ return super().default(o)
94
+
95
+
96
+ def _print_json_item_with_array_indentation(item: Any, indent: int):
97
+ """Print a JSON item with proper indentation for array context"""
98
+ if indent:
99
+ indented_output = json.dumps(item, cls=StreamingJSONEncoder, indent=indent)
100
+ indented_lines = indented_output.split("\n")
101
+ for i, line in enumerate(indented_lines):
102
+ if i == 0:
103
+ print(" " * indent + line, end="")
104
+ else:
105
+ print("\n" + " " * indent + line, end="")
106
+ else:
107
+ json.dump(item, sys.stdout, cls=StreamingJSONEncoder, separators=(",", ":"))
108
+
109
+
110
+ def _stream_collection_as_json(result: CollectionResult, indent: int = 4):
111
+ """Stream a CollectionResult as a JSON array without loading all data into memory"""
112
+ items = iter(result.result)
113
+ try:
114
+ first_item = next(items)
115
+ except StopIteration:
116
+ print("[]", end="")
117
+ return
118
+
119
+ print("[")
120
+
121
+ _print_json_item_with_array_indentation(first_item, indent)
122
+
123
+ for item in items:
124
+ print(",")
125
+ _print_json_item_with_array_indentation(item, indent)
126
+
127
+ print("\n]", end="")
128
+
129
+
130
+ def _stream_collection_as_csv(result: CollectionResult):
131
+ """Stream a CollectionResult as CSV without loading all data into memory"""
132
+ items = iter(result.result)
133
+ try:
134
+ first_item = next(items)
135
+ except StopIteration:
136
+ return
137
+
138
+ fieldnames = list(first_item.keys())
139
+ if not isinstance(first_item, dict):
140
+ raise TypeError("CSV output requires dictionary items")
141
+
142
+ writer = csv.DictWriter(sys.stdout, fieldnames=fieldnames, lineterminator="\n")
143
+ writer.writeheader()
144
+ _write_csv_row(writer, first_item)
145
+
146
+ for item in items:
147
+ _write_csv_row(writer, item)
148
+
149
+
150
+ def _write_csv_row(writer: csv.DictWriter, row_data: Dict[str, Any]):
151
+ """Write a single CSV row, handling special data types"""
152
+ processed_row = {}
153
+ for key, value in row_data.items():
154
+ if isinstance(value, str):
155
+ processed_row[key] = sanitize_for_terminal(value)
156
+ elif isinstance(value, (date, datetime, time)):
157
+ processed_row[key] = value.isoformat()
158
+ elif isinstance(value, Path):
159
+ processed_row[key] = value.as_posix()
160
+ elif isinstance(value, Decimal):
161
+ processed_row[key] = str(value)
162
+ elif isinstance(value, bytearray):
163
+ processed_row[key] = value.hex()
164
+ elif value is None:
165
+ processed_row[key] = ""
166
+ else:
167
+ processed_row[key] = str(value)
168
+
169
+ writer.writerow(processed_row)
170
+
171
+
71
172
  def _get_format_type() -> OutputFormat:
72
173
  output_format = get_cli_context().output_format
73
174
  if output_format:
@@ -110,12 +211,13 @@ def is_structured_format(output_format):
110
211
  def print_structured(
111
212
  result: CommandResult, output_format: OutputFormat = OutputFormat.JSON
112
213
  ):
113
- """Handles outputs like json, yml and other structured and parsable formats."""
214
+ """Handles outputs like json, csv and other structured and parsable formats with streaming."""
114
215
  printed_end_line = False
216
+
115
217
  if isinstance(result, MultipleResults):
116
218
  if output_format == OutputFormat.CSV:
117
219
  for command_result in result.result:
118
- _print_csv_result(command_result)
220
+ _print_csv_result_streaming(command_result)
119
221
  print(flush=True)
120
222
  printed_end_line = True
121
223
  else:
@@ -125,35 +227,67 @@ def print_structured(
125
227
  # instead of joining all the values into a JSON array or CSV entry set
126
228
  for r in result.result:
127
229
  if output_format == OutputFormat.CSV:
128
- _print_csv_result(r.result)
230
+ _print_csv_result_streaming(r)
129
231
  else:
130
- json.dump(r, sys.stdout, cls=CustomJSONEncoder)
232
+ json.dump(r, sys.stdout, cls=StreamingJSONEncoder)
131
233
  print(flush=True)
132
234
  printed_end_line = True
133
235
  else:
134
236
  if output_format == OutputFormat.CSV:
135
- _print_csv_result(result)
237
+ _print_csv_result_streaming(result)
136
238
  printed_end_line = True
137
239
  else:
138
- json.dump(result, sys.stdout, cls=CustomJSONEncoder, indent=4)
240
+ _print_json_result_streaming(result)
241
+
139
242
  # Adds empty line at the end
140
243
  if not printed_end_line:
141
244
  print(flush=True)
142
245
 
143
246
 
144
- def _print_csv_result(result: CommandResult):
145
- data = json.loads(json.dumps(result, cls=CustomJSONEncoder))
247
+ def _print_json_result_streaming(result: CommandResult):
248
+ """Print a single CommandResult as JSON with streaming support"""
249
+ if isinstance(result, CollectionResult):
250
+ _stream_collection_as_json(result, indent=4)
251
+ elif isinstance(result, (ObjectResult, MessageResult)):
252
+ json.dump(result, sys.stdout, cls=StreamingJSONEncoder, indent=4)
253
+ else:
254
+ json.dump(result, sys.stdout, cls=StreamingJSONEncoder, indent=4)
255
+
256
+
257
+ def _print_object_result_as_csv(result: ObjectResult):
258
+ """Print an ObjectResult as a single-row CSV.
259
+
260
+ Converts the object's key-value pairs into a CSV with headers
261
+ from the keys and a single data row from the values.
262
+ """
263
+ data = result.result
146
264
  if isinstance(data, dict):
147
- writer = csv.DictWriter(sys.stdout, [*data], lineterminator="\n")
148
- writer.writeheader()
149
- writer.writerow(data)
150
- elif isinstance(data, list):
151
- if not data:
152
- return
153
- writer = csv.DictWriter(sys.stdout, [*data[0]], lineterminator="\n")
265
+ writer = csv.DictWriter(
266
+ sys.stdout, fieldnames=list(data.keys()), lineterminator="\n"
267
+ )
154
268
  writer.writeheader()
155
- for entry in data:
156
- writer.writerow(entry)
269
+ _write_csv_row(writer, data)
270
+
271
+
272
+ def _print_message_result_as_csv(result: MessageResult):
273
+ """Print a MessageResult as CSV with a single 'message' column.
274
+
275
+ Creates a simple CSV structure with one column named 'message'
276
+ containing the sanitized message text.
277
+ """
278
+ writer = csv.DictWriter(sys.stdout, fieldnames=["message"], lineterminator="\n")
279
+ writer.writeheader()
280
+ writer.writerow({"message": sanitize_for_terminal(result.message)})
281
+
282
+
283
+ def _print_csv_result_streaming(result: CommandResult):
284
+ """Print a single CommandResult as CSV with streaming support"""
285
+ if isinstance(result, CollectionResult):
286
+ _stream_collection_as_csv(result)
287
+ elif isinstance(result, ObjectResult):
288
+ _print_object_result_as_csv(result)
289
+ elif isinstance(result, MessageResult):
290
+ _print_message_result_as_csv(result)
157
291
 
158
292
 
159
293
  def _stream_json(result):