tundri 1.3.1__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 +24 -1
- tundri/core.py +39 -10
- tundri/inspector.py +81 -4
- tundri/utils.py +18 -2
- {tundri-1.3.1.dist-info → tundri-1.3.3.dist-info}/METADATA +31 -3
- tundri-1.3.3.dist-info/RECORD +12 -0
- tundri-1.3.1.dist-info/RECORD +0 -12
- {tundri-1.3.1.dist-info → tundri-1.3.3.dist-info}/WHEEL +0 -0
- {tundri-1.3.1.dist-info → tundri-1.3.3.dist-info}/entry_points.txt +0 -0
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(
|
|
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(
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
@@ -26,7 +26,7 @@ Requires-Dist: snowflake-connector-python
|
|
|
26
26
|
Description-Content-Type: text/markdown
|
|
27
27
|
|
|
28
28
|
<div align="center">
|
|
29
|
-
<img src="docs/images/logo.jpg" alt="
|
|
29
|
+
<img src="docs/images/logo.jpg" alt="tundri Logo" width="200">
|
|
30
30
|
</div>
|
|
31
31
|
|
|
32
32
|
**tundri** is a Python package to declaratively create, drop, and alter Snowflake objects and manage their permissions with [Permifrost](https://gitlab.com/gitlab-data/permifrost).
|
|
@@ -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,,
|
tundri-1.3.1.dist-info/RECORD
DELETED
|
@@ -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.1.dist-info/METADATA,sha256=zDTIBCpyQlXRotAQZXmcp6lHDGl3ToYFOOf0dUimJXE,3853
|
|
10
|
-
tundri-1.3.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
11
|
-
tundri-1.3.1.dist-info/entry_points.txt,sha256=OyOLF3YkcU4ah14hFwSxSJbhbwMnBLDVE8ymaPbfoYI,43
|
|
12
|
-
tundri-1.3.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|