tundri 1.3.2__py3-none-any.whl → 1.3.3__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.
tundri/cli.py CHANGED
@@ -25,7 +25,9 @@ def drop_create(args):
25
25
  console.log("[bold][purple]Drop/create Snowflake objects[/purple] started[/bold]")
26
26
  if args.dry:
27
27
  log_dry_run_info()
28
- is_success = drop_create_objects(args.permifrost_spec_path, args.dry)
28
+ is_success = drop_create_objects(
29
+ args.permifrost_spec_path, args.dry, args.users_to_skip
30
+ )
29
31
  if is_success:
30
32
  console.log(
31
33
  "[bold][purple]\nDrop/create Snowflake objects[/purple] completed successfully[/bold]\n"
@@ -62,6 +64,13 @@ def main():
62
64
  description="tundri - Drop, create and alter Snowflake objects and set permissions with Permifrost"
63
65
  )
64
66
  subparsers = parser.add_subparsers()
67
+ help_str_users_to_skip = """
68
+ Users to ignore from drop, create, and alter operations (space-separated list, case-sensitive).
69
+ Users with admin priviliges can't be inspected by the permifrost user, because
70
+ of them being higher in the role hierarchy then the default tundri inspector
71
+ role. To avoid permission errors, skip those users during object inspection.
72
+ Altering skipped users through tundri won't work and needs to be done manually!
73
+ """
65
74
 
66
75
  # Drop/create functionality
67
76
  parser_drop_create = subparsers.add_parser("drop_create", help="Drop, create and alter Snowflake objects")
@@ -69,6 +78,13 @@ def main():
69
78
  "-p", "--permifrost_spec_path", "--filepath", required=True
70
79
  )
71
80
  parser_drop_create.add_argument("--dry", action="store_true", help="Run in dry mode")
81
+ parser_drop_create.add_argument(
82
+ "--users-to-skip",
83
+ nargs="+",
84
+ metavar="USER_NAME",
85
+ default=["admin", "snowflake", "auto_dba"],
86
+ help=help_str_users_to_skip,
87
+ )
72
88
  parser_drop_create.set_defaults(func=drop_create)
73
89
 
74
90
  # Permifrost functionality
@@ -85,6 +101,13 @@ def main():
85
101
  "-p", "--permifrost_spec_path", "--filepath", required=True
86
102
  )
87
103
  parser_drop_create.add_argument("--dry", action="store_true", help="Run in dry mode")
104
+ parser_drop_create.add_argument(
105
+ "--users-to-skip",
106
+ nargs="+",
107
+ metavar="USER_NAME",
108
+ default=["admin", "snowflake", "auto_dba"],
109
+ help=help_str_users_to_skip,
110
+ )
88
111
  parser_drop_create.set_defaults(func=run)
89
112
 
90
113
  args = parser.parse_args()
tundri/core.py CHANGED
@@ -19,6 +19,7 @@ from tundri.utils import (
19
19
  get_configs,
20
20
  get_snowflake_cursor,
21
21
  format_params,
22
+ get_existing_user,
22
23
  )
23
24
 
24
25
 
@@ -96,20 +97,20 @@ def print_ddl_statements(statements: Dict) -> None:
96
97
  console.log()
97
98
 
98
99
 
99
- def execute_ddl(cursor, statements: List) -> None:
100
+ def execute_ddl(statements: List) -> None:
100
101
  """Execute drop, create and alter statements in sequence for each object type.
101
102
 
102
103
  Args:
103
- cursor: Snowflake API cursor object
104
104
  statements: list with drop, create and alter statements in sequence for all
105
105
  object types
106
106
  """
107
107
  console.log("\n[bold]Executing DDL statements[/bold]:")
108
- for s in statements:
109
- cursor.execute(s)
110
- if s.startswith("USE ROLE"):
111
- continue
112
- console.log(f"[green]\u2713[/green] [italic]{s}[/italic]")
108
+ with get_snowflake_cursor() as cursor:
109
+ for s in statements:
110
+ cursor.execute(s)
111
+ if s.startswith("USE ROLE"):
112
+ continue
113
+ console.log(f"[green]\u2713[/green] [italic]{s}[/italic]")
113
114
 
114
115
 
115
116
  def ignore_system_defined_roles(
@@ -125,6 +126,24 @@ def ignore_system_defined_roles(
125
126
  )
126
127
 
127
128
 
129
+ def ignore_existing_users(
130
+ objects: FrozenSet[SnowflakeObject],
131
+ ) -> FrozenSet[SnowflakeObject]:
132
+ """
133
+ Ignore users that already exist
134
+
135
+ Args:
136
+ cursor: Active Snowflake cursor
137
+ ought_objects: User objects to check
138
+
139
+ Returns:
140
+ "objects" parameter, but pruned of existing users
141
+ """
142
+ with get_snowflake_cursor() as cursor:
143
+ users_list = get_existing_user(cursor) # List of users (Strings)
144
+ return frozenset([obj for obj in objects if obj.name.lower() not in users_list])
145
+
146
+
128
147
  def resolve_objects(
129
148
  existing_objects: FrozenSet[SnowflakeObject],
130
149
  ought_objects: FrozenSet[SnowflakeObject],
@@ -161,6 +180,13 @@ def resolve_objects(
161
180
  # Remove create or drop statements for system-defined roles
162
181
  objects_to_create = ignore_system_defined_roles(objects_to_create)
163
182
  objects_to_drop = ignore_system_defined_roles(objects_to_drop)
183
+ if object_type == "user":
184
+ # Since we are skipping some users with admin priviliges during object inspection,
185
+ # tundri won't know whether those users already exist, and will try to create them
186
+ # even if they already exist. Adding a IF NOT EXIST flag to the CREATE command
187
+ # will only work partially, because tundri still would issue prompts for the
188
+ # affected users
189
+ objects_to_create = ignore_existing_users(objects_to_create)
164
190
 
165
191
  # Prepare CREATE/DROP statements
166
192
  ddl_statements["create"] = [
@@ -216,13 +242,16 @@ def resolve_objects(
216
242
  return ddl_statements
217
243
 
218
244
 
219
- def drop_create_objects(permifrost_spec_path: str, is_dry_run: bool):
245
+ def drop_create_objects(
246
+ permifrost_spec_path: str, is_dry_run: bool, users_to_skip: List[str]
247
+ ):
220
248
  """
221
249
  Drop and create Snowflake objects based on Permifrost specification and inspection of Snowflake metadata.
222
250
 
223
251
  Args:
224
252
  permifrost_spec_path: path to the Permifrost specification file
225
253
  is_dry_run: flag to run the operation in dry-run mode
254
+ users_to_skip: list of users to skip during rom drop, create, alter operations
226
255
 
227
256
  Returns:
228
257
  bool: True if the operation was successful, False otherwise
@@ -230,7 +259,7 @@ def drop_create_objects(permifrost_spec_path: str, is_dry_run: bool):
230
259
  permifrost_spec = load(open(permifrost_spec_path, "r"), Loader=Loader)
231
260
 
232
261
  for object_type in OBJECT_TYPES:
233
- existing_objects = inspect_object_type(object_type)
262
+ existing_objects = inspect_object_type(object_type, users_to_skip)
234
263
  ought_objects = parse_object_type(permifrost_spec, object_type)
235
264
  all_ddl_statements[object_type] = resolve_objects(
236
265
  existing_objects,
@@ -273,6 +302,6 @@ def drop_create_objects(permifrost_spec_path: str, is_dry_run: bool):
273
302
  return False
274
303
 
275
304
  if not is_dry_run:
276
- execute_ddl(get_snowflake_cursor(), ddl_statements_seq)
305
+ execute_ddl(ddl_statements_seq)
277
306
 
278
307
  return True
tundri/inspector.py CHANGED
@@ -1,9 +1,20 @@
1
+ import logging
1
2
  from pprint import pprint
2
- from typing import FrozenSet
3
+ from typing import FrozenSet, List
4
+
5
+ from rich.console import Console
6
+ from rich.logging import RichHandler
3
7
 
4
8
  from tundri.constants import OBJECT_TYPES, OBJECT_TYPE_MAP, INSPECTOR_ROLE
5
- from tundri.objects import SnowflakeObject, Schema
6
- from tundri.utils import plural, get_snowflake_cursor, format_metadata_value
9
+ from tundri.objects import SnowflakeObject, Schema, User
10
+ from tundri.utils import (
11
+ plural,
12
+ get_snowflake_cursor,
13
+ format_metadata_value,
14
+ get_existing_user,
15
+ )
16
+
17
+ from snowflake.connector.errors import ProgrammingError
7
18
 
8
19
  # Column names of SHOW statement are different than parameter names in DDL statements
9
20
  parameter_name_map = {
@@ -14,6 +25,14 @@ parameter_name_map = {
14
25
  }
15
26
 
16
27
 
28
+ logging.basicConfig(
29
+ level="WARN", format="%(message)s", datefmt="[%X]", handlers=[RichHandler()]
30
+ )
31
+ log = logging.getLogger(__name__)
32
+ log.setLevel("INFO")
33
+ console = Console()
34
+
35
+
17
36
  def inspect_schemas() -> FrozenSet[Schema]:
18
37
  """Get schemas that exist based on Snowflake metadata.
19
38
 
@@ -43,17 +62,75 @@ def inspect_schemas() -> FrozenSet[Schema]:
43
62
  return frozenset([Schema(name=name) for name in existing_schema_names])
44
63
 
45
64
 
46
- def inspect_object_type(object_type: str) -> FrozenSet[SnowflakeObject]:
65
+ def inspect_users(users_to_skip: List[str]) -> FrozenSet[User]:
66
+ """
67
+ Get metadata of USER objects, using Snowflake's DESCRIBE command.
68
+
69
+ Note:
70
+ We are using Snowflake's SHOW instead of it's DESCRIBE command to inspect
71
+ objects. For most objects (Databases, Schemas, Warehouses), SHOW allows us to
72
+ fetch object metadata, while DESCRIBE would only the structure/schema of
73
+ a single object.
74
+
75
+ The only exception to this are USER objects: those objects have no internal
76
+ structure, and their metadata essentially describes their structure. Thus,
77
+ SHOW and DESCRIBE return similar information, with DESCRIBE returning a more
78
+ complete set of metadata of a user. The following attributes are missing from
79
+ DESCRIBE and need to be added manually if required:
80
+ [created_on, owner, last_success_login, expires_at_time, locked_until_time,
81
+ has_password, has_rsa_public_key]
82
+
83
+ Returns:
84
+ data: Immutable set of user objects
85
+ """
86
+ data = []
87
+ with get_snowflake_cursor() as cursor:
88
+ users_list = get_existing_user(cursor) # List of users (Strings)
89
+ for user in users_list:
90
+ try:
91
+ cursor.execute(f"USE ROLE {INSPECTOR_ROLE}")
92
+ cursor.execute(f"DESCRIBE USER {user}")
93
+
94
+ # DESCRIBE returns one row per user attribute, while SHOW returns one column
95
+ # per user attribute. Pivot the result of DESCRIBE so it works with the
96
+ # `format_metadata_value()` function
97
+ attributes = {
98
+ row[0]: row[1] for row in cursor
99
+ } # Dict of user attributes in the form "attribute: value"
100
+ formatted_row = {
101
+ key.lower(): format_metadata_value(key.lower(), value)
102
+ for _, (key, value) in enumerate(attributes.items())
103
+ } # `format_metadata_value()` expects keys to be lowercase
104
+ name = formatted_row.pop("name")
105
+ data.append(User(name=name, params=formatted_row))
106
+ except ProgrammingError as e:
107
+ if "insufficient privileges" in e.msg.lower() and user in users_to_skip:
108
+ console.log(
109
+ "[bold][red]WARNING[/bold][/red]: Skipping metadata retrieval",
110
+ f"for user {user}: Permifrost user doesn't have DESCRIBE",
111
+ "privileges on this object",
112
+ )
113
+ else:
114
+ raise e
115
+ return frozenset(data)
116
+
117
+
118
+ def inspect_object_type(
119
+ object_type: str, users_to_skip: List[str]
120
+ ) -> FrozenSet[SnowflakeObject]:
47
121
  """Initialize Snowflake objects of a given type from Snowflake metadata.
48
122
 
49
123
  Args:
50
124
  object_type: Object type e.g. "database", "user", etc
125
+ users_to_skip: list of users to skip during inspection
51
126
 
52
127
  Returns:
53
128
  inspected_objects: set of instances of `SnowflakeObject` subclasses
54
129
  """
55
130
  if object_type == "schema":
56
131
  return inspect_schemas()
132
+ if object_type == "user":
133
+ return inspect_users(users_to_skip)
57
134
 
58
135
  with get_snowflake_cursor() as cursor:
59
136
  cursor.execute(f"USE ROLE {INSPECTOR_ROLE}")
tundri/utils.py CHANGED
@@ -1,7 +1,7 @@
1
1
  import logging
2
2
  import os
3
3
  import subprocess
4
- from typing import Dict, Type, T
4
+ from typing import Dict, Type, T, List
5
5
  from pathlib import Path
6
6
 
7
7
  from dotenv import load_dotenv, dotenv_values
@@ -9,8 +9,9 @@ from dotenv import load_dotenv, dotenv_values
9
9
  from rich.console import Console
10
10
  from rich.logging import RichHandler
11
11
  from snowflake.connector import connect
12
+ from snowflake.connector.cursor import SnowflakeCursor
12
13
 
13
- from tundri.constants import STRING_CASING_CONVERSION_MAP
14
+ from tundri.constants import STRING_CASING_CONVERSION_MAP, INSPECTOR_ROLE
14
15
 
15
16
 
16
17
  logging.basicConfig(
@@ -206,3 +207,18 @@ def log_dry_run_info():
206
207
  console.log(80 * "-")
207
208
  console.log("[bold]Executing in [yellow]dry run mode[/yellow][/bold]")
208
209
  console.log(80 * "-")
210
+
211
+
212
+ def get_existing_user(cursor: SnowflakeCursor) -> List[str]:
213
+ """
214
+ Fetch a list of existing usernames from Snowflake
215
+
216
+ Args:
217
+ cursor: active Snowflake cursor
218
+
219
+ Returns:
220
+ List of names from existing Snowflake user
221
+ """
222
+ cursor.execute(f"USE ROLE {INSPECTOR_ROLE}")
223
+ cursor.execute(f"SHOW USERS")
224
+ return [row[0].lower() for row in cursor] # List of user names (Strings)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tundri
3
- Version: 1.3.2
3
+ Version: 1.3.3
4
4
  Summary: Drop, create and alter Snowflake objects and set permissions with Permifrost
5
5
  Project-URL: Homepage, https://github.com/Gemma-Analytics/tundri
6
6
  Project-URL: Repository, https://github.com/Gemma-Analytics/tundri
@@ -45,7 +45,7 @@ With only Permifrost, one would have to manually create the objects and then run
45
45
 
46
46
  ### Prerequisites
47
47
 
48
- - Credentials to a Snowflake account with the `securityadmin` role
48
+ - Credentials to a Snowflake user account with the `securityadmin` role
49
49
  - A Permifrost spec file
50
50
 
51
51
  ### Install
@@ -117,3 +117,31 @@ Dry run with the example spec file
117
117
  ```bash
118
118
  uv run tundri run --dry -p examples/permifrost.yml
119
119
  ```
120
+
121
+ ## Contributing
122
+
123
+ ### Release process
124
+
125
+ The release process is automated using GitHub Actions. Here's how it works:
126
+
127
+ 1. **Adding new features or bug fixes**
128
+ - PR tests run automatically to verify the changes on each PR
129
+ - Multiple PRs can be merged to main until a release-ready state is reached
130
+
131
+ 1. **Initiating a Release**
132
+ - A maintainer triggers the manual release workflow
133
+ - They specify the version bump type (`major`, `minor`, or `patch`)
134
+ - This creates a release branch and PR with updated version
135
+
136
+ 1. **Release Creation**
137
+ - When the release PR is merged to main:
138
+ - A Git tag is created (e.g., `v1.2.3`)
139
+ - A GitHub release is created
140
+ - The package is published to PyPI
141
+
142
+ The process requires the following GitHub secrets to be configured:
143
+ - `PYPI_API_TOKEN`: For production PyPI publishing
144
+ - `TEST_PYPI_API_TOKEN`: For TestPyPI publishing
145
+ - `SNOWFLAKE_*`: Snowflake credentials for running tests
146
+
147
+ For full details on the release workflow, see [RELEASE_WORKFLOW.md](docs/RELEASE_WORKFLOW.md).
@@ -0,0 +1,12 @@
1
+ tundri/__init__.py,sha256=LG-zblOfMP6hVPKsBg0_Vu7Np90aDwZoQtZkamDOsus,46
2
+ tundri/cli.py,sha256=QWXZSkqrTIxRaG3k_y06f5FgnTxW-1hikPX2wFl4nQ0,3943
3
+ tundri/constants.py,sha256=H4CKhnJpfxQFIalTow07LElNjREmOaV2C2UuxXgrkAo,1200
4
+ tundri/core.py,sha256=8hlhdtU3hbAYFbg2qkthfww8MpEzGNKslcQBJuALNA0,10599
5
+ tundri/inspector.py,sha256=bA5Vl6D8pe0k5rLSVY1w-ms8e0ZWYv8VQ2G9yJuvxZw,6284
6
+ tundri/objects.py,sha256=6nXQk-JgeDlC-ZTSwNtJbxuQ-MvFWyKnirACF1ZC-_M,2230
7
+ tundri/parser.py,sha256=KBxKo-kOMswdYY5QoImcl14vNL6uOg-pe9Q3vUfDX8Y,4767
8
+ tundri/utils.py,sha256=-PHp4iw2GIrtnIqaw3SS5Eyu0DDT4GXnQoC-afGcfSA,7722
9
+ tundri-1.3.3.dist-info/METADATA,sha256=_vnargta2DAprJGnOwbIMjYc94bgHYzgmVcL890jKo0,4876
10
+ tundri-1.3.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
11
+ tundri-1.3.3.dist-info/entry_points.txt,sha256=OyOLF3YkcU4ah14hFwSxSJbhbwMnBLDVE8ymaPbfoYI,43
12
+ tundri-1.3.3.dist-info/RECORD,,
@@ -1,12 +0,0 @@
1
- tundri/__init__.py,sha256=LG-zblOfMP6hVPKsBg0_Vu7Np90aDwZoQtZkamDOsus,46
2
- tundri/cli.py,sha256=sd29AnbTcUU5vDnYWid5q8yTTiiJfjo31YRXGRfrUgw,3003
3
- tundri/constants.py,sha256=H4CKhnJpfxQFIalTow07LElNjREmOaV2C2UuxXgrkAo,1200
4
- tundri/core.py,sha256=4bNENkEnqehTykF2TqmJNyLEg2EjXauUJq1A6CQLHzg,9448
5
- tundri/inspector.py,sha256=zhDnXxFeKLCZR06zKHkBEAxyB4sO8Do0ugdu-PN-VaM,3210
6
- tundri/objects.py,sha256=6nXQk-JgeDlC-ZTSwNtJbxuQ-MvFWyKnirACF1ZC-_M,2230
7
- tundri/parser.py,sha256=KBxKo-kOMswdYY5QoImcl14vNL6uOg-pe9Q3vUfDX8Y,4767
8
- tundri/utils.py,sha256=M7RpcdMD3rhHWnO_GTNBd0jFwJmhos0f2FRXHTINg7I,7235
9
- tundri-1.3.2.dist-info/METADATA,sha256=xhJU41VP3M9wsLWoSvtDvKMbRhVnJsbUauxT_L1CK8w,3842
10
- tundri-1.3.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
11
- tundri-1.3.2.dist-info/entry_points.txt,sha256=OyOLF3YkcU4ah14hFwSxSJbhbwMnBLDVE8ymaPbfoYI,43
12
- tundri-1.3.2.dist-info/RECORD,,
File without changes