snowflake-cli 3.2.2__py3-none-any.whl → 3.3.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 (55) hide show
  1. snowflake/cli/__about__.py +1 -1
  2. snowflake/cli/_app/constants.py +4 -0
  3. snowflake/cli/_app/snow_connector.py +12 -0
  4. snowflake/cli/_app/telemetry.py +10 -3
  5. snowflake/cli/_plugins/connection/util.py +12 -19
  6. snowflake/cli/_plugins/helpers/commands.py +207 -1
  7. snowflake/cli/_plugins/nativeapp/artifacts.py +10 -4
  8. snowflake/cli/_plugins/nativeapp/codegen/compiler.py +41 -17
  9. snowflake/cli/_plugins/nativeapp/codegen/setup/native_app_setup_processor.py +7 -0
  10. snowflake/cli/_plugins/nativeapp/codegen/snowpark/python_processor.py +4 -1
  11. snowflake/cli/_plugins/nativeapp/codegen/templates/templates_processor.py +42 -32
  12. snowflake/cli/_plugins/nativeapp/commands.py +92 -2
  13. snowflake/cli/_plugins/nativeapp/constants.py +5 -0
  14. snowflake/cli/_plugins/nativeapp/entities/application.py +221 -288
  15. snowflake/cli/_plugins/nativeapp/entities/application_package.py +772 -89
  16. snowflake/cli/_plugins/nativeapp/entities/application_package_child_interface.py +43 -0
  17. snowflake/cli/_plugins/nativeapp/feature_flags.py +5 -1
  18. snowflake/cli/_plugins/nativeapp/release_channel/__init__.py +13 -0
  19. snowflake/cli/_plugins/nativeapp/release_channel/commands.py +212 -0
  20. snowflake/cli/_plugins/nativeapp/release_directive/__init__.py +13 -0
  21. snowflake/cli/_plugins/nativeapp/release_directive/commands.py +165 -0
  22. snowflake/cli/_plugins/nativeapp/same_account_install_method.py +9 -17
  23. snowflake/cli/_plugins/nativeapp/sf_facade_exceptions.py +80 -0
  24. snowflake/cli/_plugins/nativeapp/sf_sql_facade.py +999 -75
  25. snowflake/cli/_plugins/nativeapp/utils.py +11 -0
  26. snowflake/cli/_plugins/nativeapp/v2_conversions/compat.py +5 -1
  27. snowflake/cli/_plugins/nativeapp/version/commands.py +31 -4
  28. snowflake/cli/_plugins/notebook/manager.py +4 -2
  29. snowflake/cli/_plugins/snowpark/snowpark_entity.py +234 -4
  30. snowflake/cli/_plugins/spcs/common.py +129 -0
  31. snowflake/cli/_plugins/spcs/services/commands.py +134 -14
  32. snowflake/cli/_plugins/spcs/services/manager.py +169 -1
  33. snowflake/cli/_plugins/stage/manager.py +12 -4
  34. snowflake/cli/_plugins/streamlit/manager.py +8 -1
  35. snowflake/cli/_plugins/streamlit/streamlit_entity.py +153 -2
  36. snowflake/cli/_plugins/workspace/commands.py +3 -2
  37. snowflake/cli/_plugins/workspace/manager.py +8 -4
  38. snowflake/cli/api/cli_global_context.py +22 -1
  39. snowflake/cli/api/config.py +6 -2
  40. snowflake/cli/api/connections.py +12 -1
  41. snowflake/cli/api/constants.py +9 -1
  42. snowflake/cli/api/entities/common.py +85 -0
  43. snowflake/cli/api/entities/utils.py +9 -8
  44. snowflake/cli/api/errno.py +60 -3
  45. snowflake/cli/api/feature_flags.py +20 -4
  46. snowflake/cli/api/metrics.py +21 -27
  47. snowflake/cli/api/project/definition_conversion.py +1 -2
  48. snowflake/cli/api/project/schemas/project_definition.py +27 -6
  49. snowflake/cli/api/project/schemas/v1/streamlit/streamlit.py +1 -1
  50. snowflake/cli/api/project/util.py +45 -0
  51. {snowflake_cli-3.2.2.dist-info → snowflake_cli-3.3.0.dist-info}/METADATA +12 -12
  52. {snowflake_cli-3.2.2.dist-info → snowflake_cli-3.3.0.dist-info}/RECORD +55 -50
  53. {snowflake_cli-3.2.2.dist-info → snowflake_cli-3.3.0.dist-info}/WHEEL +1 -1
  54. {snowflake_cli-3.2.2.dist-info → snowflake_cli-3.3.0.dist-info}/entry_points.txt +0 -0
  55. {snowflake_cli-3.2.2.dist-info → snowflake_cli-3.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -14,4 +14,4 @@
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
- VERSION = "3.2.2"
17
+ VERSION = "3.3.0"
@@ -17,3 +17,7 @@ from __future__ import annotations
17
17
  from typing import Literal
18
18
 
19
19
  PARAM_APPLICATION_NAME: Literal["snowcli"] = "snowcli"
20
+
21
+ # This is also defined on server side. Changing this parameter would require
22
+ # a change in https://github.com/snowflakedb/snowflake
23
+ INTERNAL_APPLICATION_NAME: Literal["SNOWFLAKE_CLI"] = "SNOWFLAKE_CLI"
@@ -21,7 +21,9 @@ from typing import Dict, Optional
21
21
 
22
22
  import snowflake.connector
23
23
  from click.exceptions import ClickException
24
+ from snowflake.cli.__about__ import VERSION
24
25
  from snowflake.cli._app.constants import (
26
+ INTERNAL_APPLICATION_NAME,
25
27
  PARAM_APPLICATION_NAME,
26
28
  )
27
29
  from snowflake.cli._app.secret import SecretType
@@ -35,6 +37,7 @@ from snowflake.cli.api.exceptions import (
35
37
  InvalidConnectionConfiguration,
36
38
  SnowflakeConnectionError,
37
39
  )
40
+ from snowflake.cli.api.feature_flags import FeatureFlag
38
41
  from snowflake.cli.api.secure_path import SecurePath
39
42
  from snowflake.connector import SnowflakeConnection
40
43
  from snowflake.connector.errors import DatabaseError, ForbiddenError
@@ -150,6 +153,8 @@ def connect_to_snowflake(
150
153
 
151
154
  _update_connection_application_name(connection_parameters)
152
155
 
156
+ _update_internal_application_info(connection_parameters)
157
+
153
158
  try:
154
159
  # Whatever output is generated when creating connection,
155
160
  # we don't want it in our output. This is particularly important
@@ -238,6 +243,13 @@ def _update_connection_application_name(connection_parameters: Dict):
238
243
  connection_parameters.update(connection_application_params)
239
244
 
240
245
 
246
+ def _update_internal_application_info(connection_parameters: Dict):
247
+ """Update internal application data if ENABLE_SEPARATE_AUTHENTICATION_POLICY_ID is enabled."""
248
+ if FeatureFlag.ENABLE_SEPARATE_AUTHENTICATION_POLICY_ID.is_enabled():
249
+ connection_parameters["internal_application_name"] = INTERNAL_APPLICATION_NAME
250
+ connection_parameters["internal_application_version"] = VERSION
251
+
252
+
241
253
  def _load_pem_from_file(private_key_file: str) -> SecretType:
242
254
  with SecurePath(private_key_file).open(
243
255
  "rb", read_file_limit_mb=DEFAULT_SIZE_LIMIT_MB
@@ -67,6 +67,10 @@ class CLITelemetryField(Enum):
67
67
  CONFIG_FEATURE_FLAGS = "config_feature_flags"
68
68
  # Metrics
69
69
  COUNTERS = "counters"
70
+ SPANS = "spans"
71
+ COMPLETED_SPANS = "completed_spans"
72
+ NUM_SPANS_PAST_DEPTH_LIMIT = "num_spans_past_depth_limit"
73
+ NUM_SPANS_PAST_TOTAL_LIMIT = "num_spans_past_total_limit"
70
74
  # Information
71
75
  EVENT = "event"
72
76
  ERROR_MSG = "error_msg"
@@ -129,9 +133,12 @@ def _get_command_metrics() -> TelemetryDict:
129
133
  cli_context = get_cli_context()
130
134
 
131
135
  return {
132
- CLITelemetryField.COUNTERS: {
133
- **cli_context.metrics.counters,
134
- }
136
+ CLITelemetryField.COUNTERS: cli_context.metrics.counters,
137
+ CLITelemetryField.SPANS: {
138
+ CLITelemetryField.COMPLETED_SPANS.value: cli_context.metrics.completed_spans,
139
+ CLITelemetryField.NUM_SPANS_PAST_DEPTH_LIMIT.value: cli_context.metrics.num_spans_past_depth_limit,
140
+ CLITelemetryField.NUM_SPANS_PAST_TOTAL_LIMIT.value: cli_context.metrics.num_spans_past_total_limit,
141
+ },
135
142
  }
136
143
 
137
144
 
@@ -19,7 +19,6 @@ import logging
19
19
  import os
20
20
  from enum import Enum
21
21
  from functools import lru_cache
22
- from textwrap import dedent
23
22
  from typing import Any, Dict, Optional
24
23
 
25
24
  from click.exceptions import ClickException
@@ -57,11 +56,12 @@ class UIParameter(Enum):
57
56
  NA_ENFORCE_MANDATORY_FILTERS = (
58
57
  "ENFORCE_MANDATORY_FILTERS_FOR_SAME_ACCOUNT_INSTALLATION"
59
58
  )
59
+ NA_FEATURE_RELEASE_CHANNELS = "FEATURE_RELEASE_CHANNELS"
60
60
 
61
61
 
62
62
  def get_ui_parameter(
63
63
  conn: SnowflakeConnection, parameter: UIParameter, default: Any
64
- ) -> str:
64
+ ) -> Any:
65
65
  """
66
66
  Returns the value of a single UI parameter.
67
67
  If the parameter is not found, the default value is returned.
@@ -77,21 +77,19 @@ def get_ui_parameters(conn: SnowflakeConnection) -> Dict[UIParameter, Any]:
77
77
  Returns the UI parameters from the SYSTEM$BOOTSTRAP_DATA_REQUEST function
78
78
  """
79
79
 
80
- parameters_to_fetch = sorted([param.value for param in UIParameter])
80
+ parameters_to_fetch = [param.value for param in UIParameter]
81
81
 
82
- query = dedent(
83
- f"""
84
- select value['value']::string as PARAM_VALUE, value['name']::string as PARAM_NAME from table(flatten(
85
- input => parse_json(SYSTEM$BOOTSTRAP_DATA_REQUEST()),
86
- path => 'clientParamsInfo'
87
- )) where value['name'] in ('{"', '".join(parameters_to_fetch)}');
88
- """
89
- )
82
+ # Parsing of the Json and the filtering is happening here in Snowflake CLI
83
+ # in order to avoid requiring a warehouse in Snowflake
84
+ query = "call system$bootstrap_data_request('CLIENT_PARAMS_INFO')"
85
+ *_, cursor = conn.execute_string(query)
90
86
 
91
- *_, cursor = conn.execute_string(query, cursor_class=DictCursor)
87
+ json_map = json.loads(cursor.fetchone()[0])
92
88
 
93
89
  return {
94
- UIParameter(row["PARAM_NAME"]): row["PARAM_VALUE"] for row in cursor.fetchall()
90
+ UIParameter(row["name"]): row["value"]
91
+ for row in json_map["clientParamsInfo"]
92
+ if row["name"] in parameters_to_fetch
95
93
  }
96
94
 
97
95
 
@@ -103,12 +101,7 @@ def is_regionless_redirect(conn: SnowflakeConnection) -> bool:
103
101
  assume it's regionless, as this is true for most production deployments.
104
102
  """
105
103
  try:
106
- return (
107
- get_ui_parameter(
108
- conn, UIParameter.NA_ENABLE_REGIONLESS_REDIRECT, "true"
109
- ).lower()
110
- == "true"
111
- )
104
+ return get_ui_parameter(conn, UIParameter.NA_ENABLE_REGIONLESS_REDIRECT, True)
112
105
  except:
113
106
  log.warning(
114
107
  "Cannot determine regionless redirect; assuming True.", exc_info=True
@@ -14,17 +14,30 @@
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
+ import logging
18
+ from pathlib import Path
19
+ from typing import Any, List, Optional
20
+
17
21
  import typer
18
22
  import yaml
19
23
  from click import ClickException
20
24
  from snowflake.cli.api.commands.snow_typer import SnowTyperFactory
21
- from snowflake.cli.api.output.types import MessageResult
25
+ from snowflake.cli.api.config import (
26
+ ConnectionConfig,
27
+ add_connection_to_proper_file,
28
+ get_all_connections,
29
+ set_config_value,
30
+ )
31
+ from snowflake.cli.api.console import cli_console
32
+ from snowflake.cli.api.output.types import CommandResult, MessageResult
22
33
  from snowflake.cli.api.project.definition_conversion import (
23
34
  convert_project_definition_to_v2,
24
35
  )
25
36
  from snowflake.cli.api.project.definition_manager import DefinitionManager
26
37
  from snowflake.cli.api.secure_path import SecurePath
27
38
 
39
+ log = logging.getLogger(__name__)
40
+
28
41
  app = SnowTyperFactory(
29
42
  name="helpers",
30
43
  help="Helper commands.",
@@ -88,3 +101,196 @@ def v1_to_v2(
88
101
  width=float("inf"), # Don't break lines
89
102
  )
90
103
  return MessageResult("Project definition migrated to version 2.")
104
+
105
+
106
+ @app.command(name="import-snowsql-connections", requires_connection=False)
107
+ def import_snowsql_connections(
108
+ custom_snowsql_config_files: Optional[List[Path]] = typer.Option(
109
+ None,
110
+ "--snowsql-config-file",
111
+ help="Specifies file paths to custom SnowSQL configuration. The option can be used multiple times to specify more than 1 file.",
112
+ dir_okay=False,
113
+ exists=True,
114
+ ),
115
+ default_cli_connection_name: str = typer.Option(
116
+ "default",
117
+ "--default-connection-name",
118
+ help="Specifies the name which will be given in Snowflake CLI to the default connection imported from SnowSQL.",
119
+ ),
120
+ **options,
121
+ ) -> CommandResult:
122
+ """Import your existing connections from your SnowSQL configuration."""
123
+
124
+ snowsql_config_files: list[Path] = custom_snowsql_config_files or [
125
+ Path("/etc/snowsql.cnf"),
126
+ Path("/etc/snowflake/snowsql.cnf"),
127
+ Path("/usr/local/etc/snowsql.cnf"),
128
+ Path.home() / Path(".snowsql.cnf"),
129
+ Path.home() / Path(".snowsql/config"),
130
+ ]
131
+ snowsql_config_secure_paths: list[SecurePath] = [
132
+ SecurePath(p) for p in snowsql_config_files
133
+ ]
134
+
135
+ all_imported_connections = _read_all_connections_from_snowsql(
136
+ default_cli_connection_name, snowsql_config_secure_paths
137
+ )
138
+ _validate_and_save_connections_imported_from_snowsql(
139
+ default_cli_connection_name, all_imported_connections
140
+ )
141
+ return MessageResult(
142
+ "Connections successfully imported from SnowSQL to Snowflake CLI."
143
+ )
144
+
145
+
146
+ def _read_all_connections_from_snowsql(
147
+ default_cli_connection_name: str, snowsql_config_files: List[SecurePath]
148
+ ) -> dict[str, dict]:
149
+ import configparser
150
+
151
+ imported_default_connection: dict[str, Any] = {}
152
+ imported_named_connections: dict[str, dict] = {}
153
+
154
+ for file in snowsql_config_files:
155
+ if not file.exists():
156
+ cli_console.step(
157
+ f"SnowSQL config file [{str(file.path)}] does not exist. Skipping."
158
+ )
159
+ continue
160
+
161
+ cli_console.step(f"Trying to read connections from [{str(file.path)}].")
162
+ snowsql_config = configparser.ConfigParser()
163
+ snowsql_config.read(file.path)
164
+
165
+ if "connections" in snowsql_config and snowsql_config.items("connections"):
166
+ cli_console.step(
167
+ f"Reading SnowSQL's default connection configuration from [{str(file.path)}]"
168
+ )
169
+ snowsql_default_connection = snowsql_config.items("connections")
170
+ imported_default_connection.update(
171
+ _convert_connection_from_snowsql_config_section(
172
+ snowsql_default_connection
173
+ )
174
+ )
175
+
176
+ other_snowsql_connection_section_names = [
177
+ section_name
178
+ for section_name in snowsql_config.sections()
179
+ if section_name.startswith("connections.")
180
+ ]
181
+ for snowsql_connection_section_name in other_snowsql_connection_section_names:
182
+ cli_console.step(
183
+ f"Reading SnowSQL's connection configuration [{snowsql_connection_section_name}] from [{str(file.path)}]"
184
+ )
185
+ snowsql_named_connection = snowsql_config.items(
186
+ snowsql_connection_section_name
187
+ )
188
+ if not snowsql_named_connection:
189
+ cli_console.step(
190
+ f"Empty connection configuration [{snowsql_connection_section_name}] in [{str(file.path)}]. Skipping."
191
+ )
192
+ continue
193
+
194
+ connection_name = snowsql_connection_section_name.removeprefix(
195
+ "connections."
196
+ )
197
+ imported_named_conenction = _convert_connection_from_snowsql_config_section(
198
+ snowsql_named_connection
199
+ )
200
+ if connection_name in imported_named_connections:
201
+ imported_named_connections[connection_name].update(
202
+ imported_named_conenction
203
+ )
204
+ else:
205
+ imported_named_connections[connection_name] = imported_named_conenction
206
+
207
+ def imported_default_connection_as_named_connection():
208
+ name = _validate_imported_default_connection_name(
209
+ default_cli_connection_name, imported_named_connections
210
+ )
211
+ return {name: imported_default_connection}
212
+
213
+ named_default_connection = (
214
+ imported_default_connection_as_named_connection()
215
+ if imported_default_connection
216
+ else {}
217
+ )
218
+
219
+ return imported_named_connections | named_default_connection
220
+
221
+
222
+ def _validate_imported_default_connection_name(
223
+ name_candidate: str, other_snowsql_connections: dict[str, dict]
224
+ ) -> str:
225
+ if name_candidate in other_snowsql_connections:
226
+ new_name_candidate = typer.prompt(
227
+ f"Chosen default connection name '{name_candidate}' is already taken by other connection being imported from SnowSQL. Please choose a different name for your default connection"
228
+ )
229
+ return _validate_imported_default_connection_name(
230
+ new_name_candidate, other_snowsql_connections
231
+ )
232
+ else:
233
+ return name_candidate
234
+
235
+
236
+ def _convert_connection_from_snowsql_config_section(
237
+ snowsql_connection: list[tuple[str, Any]]
238
+ ) -> dict[str, Any]:
239
+ from ast import literal_eval
240
+
241
+ key_names_replacements = {
242
+ "accountname": "account",
243
+ "username": "user",
244
+ "databasename": "database",
245
+ "dbname": "database",
246
+ "schemaname": "schema",
247
+ "warehousename": "warehouse",
248
+ "rolename": "role",
249
+ "private_key_path": "private_key_file",
250
+ }
251
+
252
+ def parse_value(value: Any):
253
+ try:
254
+ parsed_value = literal_eval(value)
255
+ except Exception:
256
+ parsed_value = value
257
+ return parsed_value
258
+
259
+ cli_connection: dict[str, Any] = {}
260
+ for key, value in snowsql_connection:
261
+ cli_key = key_names_replacements.get(key, key)
262
+ cli_value = parse_value(value)
263
+ cli_connection[cli_key] = cli_value
264
+ return cli_connection
265
+
266
+
267
+ def _validate_and_save_connections_imported_from_snowsql(
268
+ default_cli_connection_name: str, all_imported_connections: dict[str, Any]
269
+ ):
270
+ existing_cli_connection_names: set[str] = set(get_all_connections().keys())
271
+ imported_connections_to_save: dict[str, Any] = {}
272
+ for (
273
+ imported_connection_name,
274
+ imported_connection,
275
+ ) in all_imported_connections.items():
276
+ if imported_connection_name in existing_cli_connection_names:
277
+ override_cli_connection = typer.confirm(
278
+ f"Connection '{imported_connection_name}' already exists in Snowflake CLI, do you want to use SnowSQL definition and override existing connection in Snowflake CLI?"
279
+ )
280
+ if not override_cli_connection:
281
+ continue
282
+ imported_connections_to_save[imported_connection_name] = imported_connection
283
+
284
+ for name, connection in imported_connections_to_save.items():
285
+ cli_console.step(f"Saving [{name}] connection in Snowflake CLI's config.")
286
+ add_connection_to_proper_file(name, ConnectionConfig.from_dict(connection))
287
+
288
+ if default_cli_connection_name in imported_connections_to_save:
289
+ cli_console.step(
290
+ f"Setting [{default_cli_connection_name}] connection as Snowflake CLI's default connection."
291
+ )
292
+ set_config_value(
293
+ section=None,
294
+ key="default_connection_name",
295
+ value=default_cli_connection_name,
296
+ )
@@ -22,6 +22,7 @@ from textwrap import dedent
22
22
  from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Tuple, Union
23
23
 
24
24
  from click.exceptions import ClickException
25
+ from snowflake.cli.api.cli_global_context import span
25
26
  from snowflake.cli.api.constants import DEFAULT_SIZE_LIMIT_MB
26
27
  from snowflake.cli.api.project.schemas.v1.native_app.path_mapping import PathMapping
27
28
  from snowflake.cli.api.project.util import to_identifier
@@ -649,6 +650,7 @@ def resolve_without_follow(path: Path) -> Path:
649
650
  return Path(os.path.abspath(path))
650
651
 
651
652
 
653
+ @span("bundle")
652
654
  def build_bundle(
653
655
  project_root: Path,
654
656
  deploy_root: Path,
@@ -775,12 +777,16 @@ def find_version_info_in_manifest_file(
775
777
  label: Optional[str] = None
776
778
 
777
779
  version_info = manifest_content.get("version", None)
778
- if version_info:
779
- if name_field in version_info:
780
+ if version_info is not None:
781
+ if not isinstance(version_info, dict):
782
+ raise ClickException(
783
+ "Error occurred while reading manifest.yml. Received unexpected version format."
784
+ )
785
+ if version_info.get(name_field) is not None:
780
786
  version_name = to_identifier(str(version_info[name_field]))
781
- if patch_field in version_info:
787
+ if version_info.get(patch_field) is not None:
782
788
  patch_number = int(version_info[patch_field])
783
- if label_field in version_info:
789
+ if version_info.get(label_field) is not None:
784
790
  label = str(version_info[label_field])
785
791
 
786
792
  return VersionInfo(version_name, patch_number, label)
@@ -33,7 +33,6 @@ from snowflake.cli._plugins.nativeapp.codegen.snowpark.python_processor import (
33
33
  from snowflake.cli._plugins.nativeapp.codegen.templates.templates_processor import (
34
34
  TemplatesProcessor,
35
35
  )
36
- from snowflake.cli._plugins.nativeapp.feature_flags import FeatureFlag
37
36
  from snowflake.cli.api.cli_global_context import get_cli_context
38
37
  from snowflake.cli.api.console import cli_console as cc
39
38
  from snowflake.cli.api.metrics import CLICounterField
@@ -41,15 +40,7 @@ from snowflake.cli.api.project.schemas.v1.native_app.path_mapping import (
41
40
  ProcessorMapping,
42
41
  )
43
42
 
44
- SNOWPARK_PROCESSOR = "snowpark"
45
- NA_SETUP_PROCESSOR = "native app setup"
46
- TEMPLATES_PROCESSOR = "templates"
47
-
48
- _REGISTERED_PROCESSORS_BY_NAME = {
49
- SNOWPARK_PROCESSOR: SnowparkAnnotationProcessor,
50
- NA_SETUP_PROCESSOR: NativeAppSetupProcessor,
51
- TEMPLATES_PROCESSOR: TemplatesProcessor,
52
- }
43
+ ProcessorClassType = type[ArtifactProcessor]
53
44
 
54
45
 
55
46
  class NativeAppCompiler:
@@ -66,10 +57,28 @@ class NativeAppCompiler:
66
57
  bundle_ctx: BundleContext,
67
58
  ):
68
59
  self._assert_absolute_paths(bundle_ctx)
60
+ self._processor_classes_by_name: Dict[str, ProcessorClassType] = {}
69
61
  self._bundle_ctx = bundle_ctx
70
62
  # dictionary of all processors created and shared between different artifact objects.
71
63
  self.cached_processors: Dict[str, ArtifactProcessor] = {}
72
64
 
65
+ self.register(SnowparkAnnotationProcessor)
66
+ self.register(NativeAppSetupProcessor)
67
+ self.register(TemplatesProcessor)
68
+
69
+ def register(self, processor_cls: ProcessorClassType):
70
+ """
71
+ Registers a processor class to enable.
72
+ """
73
+
74
+ name = getattr(processor_cls, "NAME", None)
75
+ assert name is not None
76
+
77
+ if name in self._processor_classes_by_name:
78
+ raise ValueError(f"Processor {name} is already registered")
79
+
80
+ self._processor_classes_by_name[str(name)] = processor_cls
81
+
73
82
  @staticmethod
74
83
  def _assert_absolute_paths(bundle_ctx: BundleContext):
75
84
  for name in ["Project", "Deploy", "Bundle", "Generated"]:
@@ -88,7 +97,10 @@ class NativeAppCompiler:
88
97
  if not self._should_invoke_processors():
89
98
  return
90
99
 
91
- with cc.phase("Invoking artifact processors"):
100
+ with (
101
+ cc.phase("Invoking artifact processors"),
102
+ get_cli_context().metrics.span("artifact_processors"),
103
+ ):
92
104
  if self._bundle_ctx.generated_root.exists():
93
105
  raise ClickException(
94
106
  f"Path {self._bundle_ctx.generated_root} already exists. Please choose a different name for your generated directory in the project definition file."
@@ -125,8 +137,8 @@ class NativeAppCompiler:
125
137
  if current_processor is not None:
126
138
  return current_processor
127
139
 
128
- processor_factory = _REGISTERED_PROCESSORS_BY_NAME.get(processor_name)
129
- if processor_factory is None:
140
+ processor_cls = self._processor_classes_by_name.get(processor_name)
141
+ if processor_cls is None:
130
142
  # No registered processor with the specified name
131
143
  return None
132
144
 
@@ -138,7 +150,7 @@ class NativeAppCompiler:
138
150
  processor_ctx.generated_root = (
139
151
  self._bundle_ctx.generated_root / processor_subdirectory
140
152
  )
141
- current_processor = processor_factory(processor_ctx)
153
+ current_processor = processor_cls(processor_ctx)
142
154
  self.cached_processors[processor_name] = current_processor
143
155
 
144
156
  return current_processor
@@ -151,6 +163,18 @@ class NativeAppCompiler:
151
163
  return False
152
164
 
153
165
  def _is_enabled(self, processor: ProcessorMapping) -> bool:
154
- if processor.name.lower() == NA_SETUP_PROCESSOR:
155
- return FeatureFlag.ENABLE_NATIVE_APP_PYTHON_SETUP.is_enabled()
156
- return True
166
+ """
167
+ Determines is a process is enabled. All processors are considered enabled
168
+ unless they are explicitly disabled, typically via a feature flag.
169
+ """
170
+ processor_name = processor.name.lower()
171
+ processor_cls = self._processor_classes_by_name.get(processor_name)
172
+ if processor_cls is None:
173
+ # Unknown processor, consider it enabled, even though trying to
174
+ # invoke it later will raise an exception
175
+ return True
176
+
177
+ # if the processor class defines a static method named "is_enabled", then
178
+ # call it. Otherwise, it's considered enabled by default.
179
+ is_enabled_fn = getattr(processor_cls, "is_enabled", lambda: True)
180
+ return is_enabled_fn()
@@ -36,6 +36,7 @@ from snowflake.cli._plugins.nativeapp.codegen.sandbox import (
36
36
  SandboxEnvBuilder,
37
37
  execute_script_in_sandbox,
38
38
  )
39
+ from snowflake.cli._plugins.nativeapp.feature_flags import FeatureFlag
39
40
  from snowflake.cli._plugins.stage.diff import to_stage_path
40
41
  from snowflake.cli.api.console import cli_console as cc
41
42
  from snowflake.cli.api.project.schemas.v1.native_app.path_mapping import (
@@ -74,9 +75,15 @@ def safe_set(d: dict, *keys: str, **kwargs) -> None:
74
75
 
75
76
 
76
77
  class NativeAppSetupProcessor(ArtifactProcessor):
78
+ NAME = "native app setup"
79
+
77
80
  def __init__(self, *args, **kwargs):
78
81
  super().__init__(*args, **kwargs)
79
82
 
83
+ @staticmethod
84
+ def is_enabled() -> bool:
85
+ return FeatureFlag.ENABLE_NATIVE_APP_PYTHON_SETUP.is_enabled()
86
+
80
87
  def process(
81
88
  self,
82
89
  artifact_to_process: PathMapping,
@@ -48,7 +48,7 @@ from snowflake.cli._plugins.nativeapp.codegen.snowpark.models import (
48
48
  NativeAppExtensionFunction,
49
49
  )
50
50
  from snowflake.cli._plugins.stage.diff import to_stage_path
51
- from snowflake.cli.api.cli_global_context import get_cli_context
51
+ from snowflake.cli.api.cli_global_context import get_cli_context, span
52
52
  from snowflake.cli.api.console import cli_console as cc
53
53
  from snowflake.cli.api.metrics import CLICounterField
54
54
  from snowflake.cli.api.project.schemas.v1.native_app.path_mapping import (
@@ -164,9 +164,12 @@ class SnowparkAnnotationProcessor(ArtifactProcessor):
164
164
  and generate SQL code for creation of extension functions based on those discovered objects.
165
165
  """
166
166
 
167
+ NAME = "snowpark"
168
+
167
169
  def __init__(self, *args, **kwargs):
168
170
  super().__init__(*args, **kwargs)
169
171
 
172
+ @span("snowpark_processor")
170
173
  def process(
171
174
  self,
172
175
  artifact_to_process: PathMapping,
@@ -23,7 +23,7 @@ from snowflake.cli._plugins.nativeapp.codegen.artifact_processor import (
23
23
  ArtifactProcessor,
24
24
  )
25
25
  from snowflake.cli._plugins.nativeapp.exceptions import InvalidTemplateInFileError
26
- from snowflake.cli.api.cli_global_context import get_cli_context
26
+ from snowflake.cli.api.cli_global_context import get_cli_context, span
27
27
  from snowflake.cli.api.console import cli_console as cc
28
28
  from snowflake.cli.api.metrics import CLICounterField
29
29
  from snowflake.cli.api.project.schemas.v1.native_app.path_mapping import (
@@ -49,6 +49,8 @@ class TemplatesProcessor(ArtifactProcessor):
49
49
  Processor class to perform template expansion on all relevant artifacts (specified in the project definition file).
50
50
  """
51
51
 
52
+ NAME = "templates"
53
+
52
54
  def expand_templates_in_file(
53
55
  self, src: Path, dest: Path, template_context: dict[str, Any] | None = None
54
56
  ) -> None:
@@ -58,39 +60,47 @@ class TemplatesProcessor(ArtifactProcessor):
58
60
  if src.is_dir():
59
61
  return
60
62
 
61
- with self.edit_file(dest) as file:
62
- if not has_client_side_templates(file.contents) and not (
63
- _is_sql_file(dest) and has_sql_templates(file.contents)
64
- ):
65
- return
66
-
67
- src_file_name = src.relative_to(self._bundle_ctx.project_root)
68
- cc.step(f"Expanding templates in {src_file_name}")
69
- with cc.indented():
70
- try:
71
- jinja_env = (
72
- choose_sql_jinja_env_based_on_template_syntax(
73
- file.contents, reference_name=src_file_name
63
+ src_file_name = src.relative_to(self._bundle_ctx.project_root)
64
+
65
+ try:
66
+ with self.edit_file(dest) as file:
67
+ if not has_client_side_templates(file.contents) and not (
68
+ _is_sql_file(dest) and has_sql_templates(file.contents)
69
+ ):
70
+ return
71
+ cc.step(f"Expanding templates in {src_file_name}")
72
+ with cc.indented():
73
+ try:
74
+ jinja_env = (
75
+ choose_sql_jinja_env_based_on_template_syntax(
76
+ file.contents, reference_name=src_file_name
77
+ )
78
+ if _is_sql_file(dest)
79
+ else get_client_side_jinja_env()
80
+ )
81
+ expanded_template = jinja_env.from_string(file.contents).render(
82
+ template_context or get_cli_context().template_context
74
83
  )
75
- if _is_sql_file(dest)
76
- else get_client_side_jinja_env()
77
- )
78
- expanded_template = jinja_env.from_string(file.contents).render(
79
- template_context or get_cli_context().template_context
80
- )
81
-
82
- # For now, we are printing the source file path in the error message
83
- # instead of the destination file path to make it easier for the user
84
- # to identify the file that has the error, and edit the correct file.
85
- except jinja2.TemplateSyntaxError as e:
86
- raise InvalidTemplateInFileError(src_file_name, e, e.lineno) from e
87
-
88
- except jinja2.UndefinedError as e:
89
- raise InvalidTemplateInFileError(src_file_name, e) from e
90
-
91
- if expanded_template != file.contents:
92
- file.edited_contents = expanded_template
93
84
 
85
+ # For now, we are printing the source file path in the error message
86
+ # instead of the destination file path to make it easier for the user
87
+ # to identify the file that has the error, and edit the correct file.
88
+ except jinja2.TemplateSyntaxError as e:
89
+ raise InvalidTemplateInFileError(
90
+ src_file_name, e, e.lineno
91
+ ) from e
92
+
93
+ except jinja2.UndefinedError as e:
94
+ raise InvalidTemplateInFileError(src_file_name, e) from e
95
+
96
+ if expanded_template != file.contents:
97
+ file.edited_contents = expanded_template
98
+ except UnicodeDecodeError as err:
99
+ cc.warning(
100
+ f"Could not read file {src_file_name}, error: {err.reason}. Skipping this file."
101
+ )
102
+
103
+ @span("templates_processor")
94
104
  def process(
95
105
  self,
96
106
  artifact_to_process: PathMapping,