fakesnow 0.8.0__tar.gz → 0.8.1__tar.gz

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 (28) hide show
  1. {fakesnow-0.8.0/fakesnow.egg-info → fakesnow-0.8.1}/PKG-INFO +24 -3
  2. {fakesnow-0.8.0 → fakesnow-0.8.1}/README.md +22 -1
  3. fakesnow-0.8.1/fakesnow/__main__.py +4 -0
  4. fakesnow-0.8.1/fakesnow/cli.py +28 -0
  5. {fakesnow-0.8.0 → fakesnow-0.8.1}/fakesnow/fakes.py +77 -11
  6. {fakesnow-0.8.0 → fakesnow-0.8.1}/fakesnow/transforms.py +66 -5
  7. {fakesnow-0.8.0 → fakesnow-0.8.1/fakesnow.egg-info}/PKG-INFO +24 -3
  8. {fakesnow-0.8.0 → fakesnow-0.8.1}/fakesnow.egg-info/SOURCES.txt +4 -0
  9. fakesnow-0.8.1/fakesnow.egg-info/entry_points.txt +2 -0
  10. {fakesnow-0.8.0 → fakesnow-0.8.1}/fakesnow.egg-info/requires.txt +1 -1
  11. {fakesnow-0.8.0 → fakesnow-0.8.1}/fakesnow.egg-info/top_level.txt +1 -0
  12. {fakesnow-0.8.0 → fakesnow-0.8.1}/pyproject.toml +7 -2
  13. fakesnow-0.8.1/tests/test_cli.py +17 -0
  14. {fakesnow-0.8.0 → fakesnow-0.8.1}/tests/test_fakes.py +132 -39
  15. {fakesnow-0.8.0 → fakesnow-0.8.1}/tests/test_patch.py +1 -1
  16. {fakesnow-0.8.0 → fakesnow-0.8.1}/tests/test_transforms.py +35 -2
  17. {fakesnow-0.8.0 → fakesnow-0.8.1}/LICENSE +0 -0
  18. {fakesnow-0.8.0 → fakesnow-0.8.1}/MANIFEST.in +0 -0
  19. {fakesnow-0.8.0 → fakesnow-0.8.1}/fakesnow/__init__.py +0 -0
  20. {fakesnow-0.8.0 → fakesnow-0.8.1}/fakesnow/checks.py +0 -0
  21. {fakesnow-0.8.0 → fakesnow-0.8.1}/fakesnow/expr.py +0 -0
  22. {fakesnow-0.8.0 → fakesnow-0.8.1}/fakesnow/fixtures.py +0 -0
  23. {fakesnow-0.8.0 → fakesnow-0.8.1}/fakesnow/info_schema.py +2 -2
  24. {fakesnow-0.8.0 → fakesnow-0.8.1}/fakesnow/py.typed +0 -0
  25. {fakesnow-0.8.0 → fakesnow-0.8.1}/fakesnow.egg-info/dependency_links.txt +0 -0
  26. {fakesnow-0.8.0 → fakesnow-0.8.1}/setup.cfg +0 -0
  27. {fakesnow-0.8.0 → fakesnow-0.8.1}/tests/test_checks.py +0 -0
  28. {fakesnow-0.8.0 → fakesnow-0.8.1}/tests/test_expr.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fakesnow
3
- Version: 0.8.0
3
+ Version: 0.8.1
4
4
  Summary: Fake Snowflake Connector for Python. Run Snowflake DB locally.
5
5
  License: MIT License
6
6
 
@@ -32,7 +32,7 @@ License-File: LICENSE
32
32
  Requires-Dist: duckdb~=0.9.2
33
33
  Requires-Dist: pyarrow
34
34
  Requires-Dist: snowflake-connector-python
35
- Requires-Dist: sqlglot~=19.5.1
35
+ Requires-Dist: sqlglot~=20.5.0
36
36
  Provides-Extra: dev
37
37
  Requires-Dist: black~=23.9; extra == "dev"
38
38
  Requires-Dist: build~=1.0; extra == "dev"
@@ -63,6 +63,24 @@ pip install fakesnow
63
63
 
64
64
  ## Usage
65
65
 
66
+ Run script.py with fakesnow:
67
+
68
+ ```shell
69
+ fakesnow script.py
70
+ ```
71
+
72
+ Or pytest
73
+
74
+ ```shell
75
+ fakesnow -m pytest
76
+ ```
77
+
78
+ `fakesnow` executes `fakesnow.patch` before running the script or module.
79
+
80
+ ### fakesnow.patch
81
+
82
+ eg:
83
+
66
84
  ```python
67
85
  import fakesnow
68
86
  import snowflake.connector
@@ -91,6 +109,8 @@ with fakesnow.patch("mymodule.write_pandas"):
91
109
  ...
92
110
  ```
93
111
 
112
+ ### pytest fixtures
113
+
94
114
  pytest [fixtures](fakesnow/fixtures.py) are provided for testing. Example _conftest.py_:
95
115
 
96
116
  ```python
@@ -146,7 +166,8 @@ For more detail see [tests/test_fakes.py](tests/test_fakes.py)
146
166
 
147
167
  ## Caveats
148
168
 
149
- - VARCHAR field sizes are not enforced unlike Snowflake which will error with "User character length limit (xxx) exceeded by string" when you try to insert a string longer than the column limit.
169
+ - The order of rows is non deterministic and may not match Snowflake unless ORDER BY is fully specified.
170
+ - VARCHAR field sizes are not enforced. Unlike Snowflake which errors with "User character length limit (xxx) exceeded by string" when an inserted string the exceeds the column limit.
150
171
 
151
172
  ## Contributing
152
173
 
@@ -14,6 +14,24 @@ pip install fakesnow
14
14
 
15
15
  ## Usage
16
16
 
17
+ Run script.py with fakesnow:
18
+
19
+ ```shell
20
+ fakesnow script.py
21
+ ```
22
+
23
+ Or pytest
24
+
25
+ ```shell
26
+ fakesnow -m pytest
27
+ ```
28
+
29
+ `fakesnow` executes `fakesnow.patch` before running the script or module.
30
+
31
+ ### fakesnow.patch
32
+
33
+ eg:
34
+
17
35
  ```python
18
36
  import fakesnow
19
37
  import snowflake.connector
@@ -42,6 +60,8 @@ with fakesnow.patch("mymodule.write_pandas"):
42
60
  ...
43
61
  ```
44
62
 
63
+ ### pytest fixtures
64
+
45
65
  pytest [fixtures](fakesnow/fixtures.py) are provided for testing. Example _conftest.py_:
46
66
 
47
67
  ```python
@@ -97,7 +117,8 @@ For more detail see [tests/test_fakes.py](tests/test_fakes.py)
97
117
 
98
118
  ## Caveats
99
119
 
100
- - VARCHAR field sizes are not enforced unlike Snowflake which will error with "User character length limit (xxx) exceeded by string" when you try to insert a string longer than the column limit.
120
+ - The order of rows is non deterministic and may not match Snowflake unless ORDER BY is fully specified.
121
+ - VARCHAR field sizes are not enforced. Unlike Snowflake which errors with "User character length limit (xxx) exceeded by string" when an inserted string the exceeds the column limit.
101
122
 
102
123
  ## Contributing
103
124
 
@@ -0,0 +1,4 @@
1
+ if __name__ == "__main__":
2
+ import fakesnow.cli
3
+
4
+ fakesnow.cli.main()
@@ -0,0 +1,28 @@
1
+ import runpy
2
+ import sys
3
+ from collections.abc import Sequence
4
+
5
+ import fakesnow
6
+
7
+ USAGE = "Usage: fakesnow <path> | -m <module> [<arg>]..."
8
+
9
+
10
+ def main(args: Sequence[str] = sys.argv) -> int:
11
+ if len(args) < 2 or (len(args) == 2 and args[1] == "-m"):
12
+ print(USAGE, file=sys.stderr)
13
+ return 42
14
+
15
+ with fakesnow.patch():
16
+ if args[1] == "-m":
17
+ module = args[2]
18
+ sys.argv = args[2:]
19
+
20
+ # add current directory to path to mimic python -m
21
+ sys.path.insert(0, "")
22
+ runpy.run_module(module, run_name="__main__", alter_sys=True)
23
+ else:
24
+ path = args[1]
25
+ sys.argv = args[1:]
26
+ runpy.run_path(path, run_name="__main__")
27
+
28
+ return 0
@@ -1,9 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  import re
5
+ import sys
4
6
  from collections.abc import Iterable, Iterator, Sequence
7
+ from string import Template
5
8
  from types import TracebackType
6
- from typing import TYPE_CHECKING, Any, Literal, Optional, Union, cast
9
+ from typing import TYPE_CHECKING, Any, Literal, Optional, cast
7
10
 
8
11
  import duckdb
9
12
 
@@ -26,6 +29,12 @@ import fakesnow.info_schema as info_schema
26
29
  import fakesnow.transforms as transforms
27
30
 
28
31
  SCHEMA_UNSET = "schema_unset"
32
+ SUCCESS_SQL = "SELECT 'Statement executed successfully.' as status"
33
+ DATABASE_CREATED_SQL = Template("SELECT 'Database ${name} successfully created.' as status")
34
+ TABLE_CREATED_SQL = Template("SELECT 'Table ${name} successfully created.' as status")
35
+ DROPPED_SQL = Template("SELECT '${name} successfully dropped.' as status")
36
+ SCHEMA_CREATED_SQL = Template("SELECT 'Schema ${name} successfully created.' as status")
37
+ INSERTED_SQL = Template("SELECT ${count} as 'number of rows inserted'")
29
38
 
30
39
 
31
40
  class FakeSnowflakeCursor:
@@ -49,6 +58,7 @@ class FakeSnowflakeCursor:
49
58
  self._last_sql = None
50
59
  self._last_params = None
51
60
  self._sqlstate = None
61
+ self._arraysize = 1
52
62
  self._converter = snowflake.connector.converter.SnowflakeConverter()
53
63
 
54
64
  def __enter__(self) -> Self:
@@ -62,6 +72,19 @@ class FakeSnowflakeCursor:
62
72
  ) -> bool:
63
73
  return False
64
74
 
75
+ @property
76
+ def arraysize(self) -> int:
77
+ return self._arraysize
78
+
79
+ @arraysize.setter
80
+ def arraysize(self, value: int) -> None:
81
+ self._arraysize = value
82
+
83
+ def close(self) -> bool:
84
+ self._last_sql = None
85
+ self._last_params = None
86
+ return True
87
+
65
88
  def describe(self, command: str, *args: Any, **kwargs: Any) -> list[ResultMetadata]:
66
89
  """Return the schema of the result without executing the query.
67
90
 
@@ -79,8 +102,9 @@ class FakeSnowflakeCursor:
79
102
  def description(self) -> list[ResultMetadata]:
80
103
  # use a cursor to avoid destroying an unfetched result on the main connection
81
104
  with self._duck_conn.cursor() as cur:
82
- assert self._conn.database, "Not implemented when database is None"
83
- assert self._conn.schema, "Not implemented when schema is None"
105
+ # TODO: allow sql alchemy connection with no database or schema
106
+ assert self._conn.database, ".description not implemented when database is None"
107
+ assert self._conn.schema, ".description not implemented when schema is None"
84
108
 
85
109
  # match database and schema used on the main connection
86
110
  cur.execute(f"SET SCHEMA = '{self._conn.database}.{self._conn.schema}'")
@@ -145,6 +169,8 @@ class FakeSnowflakeCursor:
145
169
  .transform(transforms.parse_json)
146
170
  # indices_to_json_extract must be before regex_substr
147
171
  .transform(transforms.indices_to_json_extract)
172
+ .transform(transforms.json_extract_as_varchar)
173
+ .transform(transforms.flatten)
148
174
  .transform(transforms.regex_replace)
149
175
  .transform(transforms.regex_substr)
150
176
  .transform(transforms.values_columns)
@@ -162,7 +188,8 @@ class FakeSnowflakeCursor:
162
188
  try:
163
189
  self._last_sql = sql
164
190
  self._last_params = params
165
- # print(f"{sql};")
191
+ if os.environ.get("FAKESNOW_DEBUG"):
192
+ print(f"{sql};", file=sys.stderr)
166
193
  self._duck_conn.execute(sql, params)
167
194
  except duckdb.BinderException as e:
168
195
  msg = e.args[0]
@@ -171,6 +198,15 @@ class FakeSnowflakeCursor:
171
198
  # minimal processing to make it look like a snowflake exception, message content may differ
172
199
  msg = cast(str, e.args[0]).split("\n")[0]
173
200
  raise snowflake.connector.errors.ProgrammingError(msg=msg, errno=2003, sqlstate="42S02") from None
201
+ except duckdb.TransactionException as e:
202
+ if "cannot rollback - no transaction is active" in str(
203
+ e
204
+ ) or "cannot commit - no transaction is active" in str(e):
205
+ # snowflake doesn't error on rollback or commit outside a tx
206
+ self._duck_conn.execute(SUCCESS_SQL)
207
+ self._last_sql = SUCCESS_SQL
208
+ else:
209
+ raise e
174
210
 
175
211
  if cmd == "USE DATABASE" and (ident := expression.find(exp.Identifier)) and isinstance(ident.this, str):
176
212
  self._conn.database = ident.this.upper()
@@ -183,6 +219,33 @@ class FakeSnowflakeCursor:
183
219
  if create_db_name := transformed.args.get("create_db_name"):
184
220
  # we created a new database, so create the info schema extensions
185
221
  self._duck_conn.execute(info_schema.creation_sql(create_db_name))
222
+ created_sql = DATABASE_CREATED_SQL.substitute(name=create_db_name)
223
+ self._duck_conn.execute(created_sql)
224
+ self._last_sql = created_sql
225
+
226
+ if cmd == "CREATE SCHEMA" and (ident := expression.find(exp.Identifier)) and isinstance(ident.this, str):
227
+ name = ident.this if ident.quoted else ident.this.upper()
228
+ created_sql = SCHEMA_CREATED_SQL.substitute(name=name)
229
+ self._duck_conn.execute(created_sql)
230
+ self._last_sql = created_sql
231
+
232
+ if cmd == "CREATE TABLE" and (ident := expression.find(exp.Identifier)) and isinstance(ident.this, str):
233
+ name = ident.this if ident.quoted else ident.this.upper()
234
+ created_sql = TABLE_CREATED_SQL.substitute(name=name)
235
+ self._duck_conn.execute(created_sql)
236
+ self._last_sql = created_sql
237
+
238
+ if cmd.startswith("DROP") and (ident := expression.find(exp.Identifier)) and isinstance(ident.this, str):
239
+ name = ident.this if ident.quoted else ident.this.upper()
240
+ dropped_sql = DROPPED_SQL.substitute(name=name)
241
+ self._duck_conn.execute(dropped_sql)
242
+ self._last_sql = dropped_sql
243
+
244
+ if cmd == "INSERT":
245
+ (count,) = self._duck_conn.fetchall()[0]
246
+ inserted_sql = INSERTED_SQL.substitute(count=count)
247
+ self._duck_conn.execute(inserted_sql)
248
+ self._last_sql = inserted_sql
186
249
 
187
250
  if table_comment := cast(tuple[exp.Table, str], transformed.args.get("table_comment")):
188
251
  # record table comment
@@ -232,19 +295,22 @@ class FakeSnowflakeCursor:
232
295
  return self._duck_conn.fetch_df()
233
296
 
234
297
  def fetchone(self) -> dict | tuple | None:
298
+ result = self.fetchmany(1)
299
+ return result[0] if result else None
300
+
301
+ def fetchmany(self, size: int | None = None) -> list[tuple] | list[dict]:
302
+ # https://peps.python.org/pep-0249/#fetchmany
303
+ size = size or self._arraysize
235
304
  if not self._use_dict_result:
236
- return cast(Union[tuple, None], self._duck_conn.fetchone())
305
+ return cast(list[tuple], self._duck_conn.fetchmany(size))
237
306
 
238
307
  if not self._arrow_table:
239
308
  self._arrow_table = self._duck_conn.fetch_arrow_table()
240
- self._arrow_table_fetch_one_index = -1
309
+ self._arrow_table_fetch_index = -size
241
310
 
242
- self._arrow_table_fetch_one_index += 1
311
+ self._arrow_table_fetch_index += size
243
312
 
244
- try:
245
- return self._arrow_table.take([self._arrow_table_fetch_one_index]).to_pylist()[0]
246
- except pyarrow.lib.ArrowIndexError:
247
- return None
313
+ return self._arrow_table.slice(offset=self._arrow_table_fetch_index, length=size).to_pylist()
248
314
 
249
315
  def get_result_batches(self) -> list[ResultBatch] | None:
250
316
  # rows_per_batch is approximate
@@ -155,6 +155,49 @@ def extract_text_length(expression: exp.Expression) -> exp.Expression:
155
155
  return expression
156
156
 
157
157
 
158
+ def flatten(expression: exp.Expression) -> exp.Expression:
159
+ """Flatten an array.
160
+
161
+ See https://docs.snowflake.com/en/sql-reference/functions/flatten
162
+
163
+ TODO: return index.
164
+ TODO: support objects.
165
+ """
166
+ if (
167
+ isinstance(expression, exp.Lateral)
168
+ and isinstance(expression.this, exp.Explode)
169
+ and (alias := expression.args.get("alias"))
170
+ # always true; when no explicit alias provided this will be _flattened
171
+ and isinstance(alias, exp.TableAlias)
172
+ ):
173
+ explode_expression = expression.this.this.expression
174
+
175
+ return exp.Lateral(
176
+ this=exp.Unnest(
177
+ expressions=[
178
+ exp.Anonymous(
179
+ # duckdb unnests in reserve, so we reverse the list to match
180
+ # the order of the original array (and snowflake)
181
+ this="list_reverse",
182
+ expressions=[
183
+ exp.Cast(
184
+ this=explode_expression,
185
+ to=exp.DataType(
186
+ this=exp.DataType.Type.ARRAY,
187
+ expressions=[exp.DataType(this=exp.DataType.Type.JSON, nested=False, prefix=False)],
188
+ nested=True,
189
+ ),
190
+ )
191
+ ],
192
+ )
193
+ ]
194
+ ),
195
+ alias=exp.TableAlias(this=alias.this, columns=[exp.Identifier(this="VALUE", quoted=False)]),
196
+ )
197
+
198
+ return expression
199
+
200
+
158
201
  def float_to_double(expression: exp.Expression) -> exp.Expression:
159
202
  """Convert float to double for 64 bit precision.
160
203
 
@@ -176,10 +219,10 @@ def indices_to_json_extract(expression: exp.Expression) -> exp.Expression:
176
219
  and object indices, see
177
220
  https://docs.snowflake.com/en/sql-reference/data-types-semistructured#accessing-elements-of-an-object-by-key
178
221
 
179
- Duckdb uses the -> operator, or the json_extract function, see
222
+ Duckdb uses the -> operator, aka the json_extract function, see
180
223
  https://duckdb.org/docs/extensions/json#json-extraction-functions
181
224
 
182
- This works for Snowflake arrays too because we convert them to JSON[] in duckdb.
225
+ This works for Snowflake arrays too because we convert them to JSON in duckdb.
183
226
  """
184
227
  if (
185
228
  isinstance(expression, exp.Bracket)
@@ -259,6 +302,20 @@ def integer_precision(expression: exp.Expression) -> exp.Expression:
259
302
  return expression
260
303
 
261
304
 
305
+ def json_extract_as_varchar(expression: exp.Expression) -> exp.Expression:
306
+ """Return raw unquoted string when casting json extraction to varchar.
307
+
308
+ This must run after indices_to_json_extract.
309
+
310
+ Duckdb uses the ->> operator, aka the json_extract_string function, see
311
+ https://duckdb.org/docs/extensions/json#json-extraction-functions
312
+ """
313
+ if isinstance(expression, exp.Cast) and (extract := expression.this) and isinstance(extract, exp.JSONExtract):
314
+ return exp.JSONExtractScalar(this=extract.this, expression=extract.expression)
315
+
316
+ return expression
317
+
318
+
262
319
  def object_construct(expression: exp.Expression) -> exp.Expression:
263
320
  """Convert object_construct to return a json string
264
321
 
@@ -355,9 +412,13 @@ def regex_substr(expression: exp.Expression) -> exp.Expression:
355
412
 
356
413
  # which occurrence of the pattern to match
357
414
  try:
358
- occurrence = expression.args["occurrence"]
415
+ occurrence = int(expression.args["occurrence"].this)
359
416
  except KeyError:
360
- occurrence = exp.Literal(this="1", is_string=False)
417
+ occurrence = 1
418
+
419
+ # the duckdb dialect increments bracket (ie: index) expressions by 1 because duckdb is 1-indexed,
420
+ # so we need to compensate by subtracting 1
421
+ occurrence = exp.Literal(this=str(occurrence - 1), is_string=False)
361
422
 
362
423
  try:
363
424
  regex_parameters_value = str(expression.args["parameters"].this)
@@ -428,7 +489,7 @@ def set_schema(expression: exp.Expression, current_database: str | None) -> exp.
428
489
  name = f"{expression.this.name}.main"
429
490
  else:
430
491
  # SCHEMA
431
- if db := expression.this.args.get("db"):
492
+ if db := expression.this.args.get("db"): # noqa: SIM108
432
493
  db_name = db.name
433
494
  else:
434
495
  # isn't qualified with a database
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fakesnow
3
- Version: 0.8.0
3
+ Version: 0.8.1
4
4
  Summary: Fake Snowflake Connector for Python. Run Snowflake DB locally.
5
5
  License: MIT License
6
6
 
@@ -32,7 +32,7 @@ License-File: LICENSE
32
32
  Requires-Dist: duckdb~=0.9.2
33
33
  Requires-Dist: pyarrow
34
34
  Requires-Dist: snowflake-connector-python
35
- Requires-Dist: sqlglot~=19.5.1
35
+ Requires-Dist: sqlglot~=20.5.0
36
36
  Provides-Extra: dev
37
37
  Requires-Dist: black~=23.9; extra == "dev"
38
38
  Requires-Dist: build~=1.0; extra == "dev"
@@ -63,6 +63,24 @@ pip install fakesnow
63
63
 
64
64
  ## Usage
65
65
 
66
+ Run script.py with fakesnow:
67
+
68
+ ```shell
69
+ fakesnow script.py
70
+ ```
71
+
72
+ Or pytest
73
+
74
+ ```shell
75
+ fakesnow -m pytest
76
+ ```
77
+
78
+ `fakesnow` executes `fakesnow.patch` before running the script or module.
79
+
80
+ ### fakesnow.patch
81
+
82
+ eg:
83
+
66
84
  ```python
67
85
  import fakesnow
68
86
  import snowflake.connector
@@ -91,6 +109,8 @@ with fakesnow.patch("mymodule.write_pandas"):
91
109
  ...
92
110
  ```
93
111
 
112
+ ### pytest fixtures
113
+
94
114
  pytest [fixtures](fakesnow/fixtures.py) are provided for testing. Example _conftest.py_:
95
115
 
96
116
  ```python
@@ -146,7 +166,8 @@ For more detail see [tests/test_fakes.py](tests/test_fakes.py)
146
166
 
147
167
  ## Caveats
148
168
 
149
- - VARCHAR field sizes are not enforced unlike Snowflake which will error with "User character length limit (xxx) exceeded by string" when you try to insert a string longer than the column limit.
169
+ - The order of rows is non deterministic and may not match Snowflake unless ORDER BY is fully specified.
170
+ - VARCHAR field sizes are not enforced. Unlike Snowflake which errors with "User character length limit (xxx) exceeded by string" when an inserted string the exceeds the column limit.
150
171
 
151
172
  ## Contributing
152
173
 
@@ -3,7 +3,9 @@ MANIFEST.in
3
3
  README.md
4
4
  pyproject.toml
5
5
  fakesnow/__init__.py
6
+ fakesnow/__main__.py
6
7
  fakesnow/checks.py
8
+ fakesnow/cli.py
7
9
  fakesnow/expr.py
8
10
  fakesnow/fakes.py
9
11
  fakesnow/fixtures.py
@@ -13,9 +15,11 @@ fakesnow/transforms.py
13
15
  fakesnow.egg-info/PKG-INFO
14
16
  fakesnow.egg-info/SOURCES.txt
15
17
  fakesnow.egg-info/dependency_links.txt
18
+ fakesnow.egg-info/entry_points.txt
16
19
  fakesnow.egg-info/requires.txt
17
20
  fakesnow.egg-info/top_level.txt
18
21
  tests/test_checks.py
22
+ tests/test_cli.py
19
23
  tests/test_expr.py
20
24
  tests/test_fakes.py
21
25
  tests/test_patch.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fakesnow = fakesnow.cli:main
@@ -1,7 +1,7 @@
1
1
  duckdb~=0.9.2
2
2
  pyarrow
3
3
  snowflake-connector-python
4
- sqlglot~=19.5.1
4
+ sqlglot~=20.5.0
5
5
 
6
6
  [dev]
7
7
  black~=23.9
@@ -1,2 +1,3 @@
1
1
  dist
2
2
  fakesnow
3
+ notebooks
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "fakesnow"
3
3
  description = "Fake Snowflake Connector for Python. Run Snowflake DB locally."
4
- version = "0.8.0"
4
+ version = "0.8.1"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
7
7
  classifiers = ["License :: OSI Approved :: MIT License"]
@@ -11,12 +11,15 @@ dependencies = [
11
11
  "duckdb~=0.9.2",
12
12
  "pyarrow",
13
13
  "snowflake-connector-python",
14
- "sqlglot~=19.5.1",
14
+ "sqlglot~=20.5.0",
15
15
  ]
16
16
 
17
17
  [project.urls]
18
18
  homepage = "https://github.com/tekumara/fakesnow"
19
19
 
20
+ [project.scripts]
21
+ fakesnow = "fakesnow.cli:main"
22
+
20
23
  [project.optional-dependencies]
21
24
  dev = [
22
25
  "black~=23.9",
@@ -81,6 +84,8 @@ select = [
81
84
  "PERF",
82
85
  # ruff-specific
83
86
  "RUF",
87
+ # flake8-simplify
88
+ "SIM"
84
89
  ]
85
90
  ignore = [
86
91
  # allow untyped self and cls args, and no return type from dunder methods
@@ -0,0 +1,17 @@
1
+ from pytest import CaptureFixture
2
+
3
+ import fakesnow.cli
4
+
5
+
6
+ def test_run_module(capsys: CaptureFixture) -> None:
7
+ fakesnow.cli.main(["pytest", "-m", "tests.hello", "frobnitz"])
8
+
9
+ captured = capsys.readouterr()
10
+ assert captured.out == "('Hello fake frobnitz!',)\n"
11
+
12
+
13
+ def test_run_path(capsys: CaptureFixture) -> None:
14
+ fakesnow.cli.main(["pytest", "tests/hello.py", "frobnitz"])
15
+
16
+ captured = capsys.readouterr()
17
+ assert captured.out == "('Hello fake frobnitz!',)\n"
@@ -50,6 +50,10 @@ def test_binding_qmark(conn: snowflake.connector.SnowflakeConnection):
50
50
  assert cur.fetchall() == [(1, "Jenny", True)]
51
51
 
52
52
 
53
+ def test_close(cur: snowflake.connector.cursor.SnowflakeCursor):
54
+ assert cur.close() is True
55
+
56
+
53
57
  def test_connect_auto_create(_fakesnow: None):
54
58
  with snowflake.connector.connect(database="db1", schema="schema1"):
55
59
  # creates db1 and schema1
@@ -278,6 +282,45 @@ def test_describe_info_schema_columns(cur: snowflake.connector.cursor.SnowflakeC
278
282
  assert cur.description == expected_metadata
279
283
 
280
284
 
285
+ ## descriptions are needed for ipython-sql/jupysql which describes every statement
286
+
287
+
288
+ def test_description_create_database(dcur: snowflake.connector.cursor.DictCursor):
289
+ dcur.execute("create database example")
290
+ assert dcur.fetchall() == [{"status": "Database EXAMPLE successfully created."}]
291
+ assert dcur.description == [ResultMetadata(name='status', type_code=2, display_size=None, internal_size=16777216, precision=None, scale=None, is_nullable=True)] # fmt: skip
292
+ # TODO: support drop database
293
+ # dcur.execute("drop database example")
294
+ # assert dcur.fetchall() == [{"status": "EXAMPLE successfully dropped."}]
295
+ # assert dcur.description == [ResultMetadata(name='status', type_code=2, display_size=None, internal_size=16777216, precision=None, scale=None, is_nullable=True)] # fmt: skip
296
+
297
+
298
+ def test_description_create_drop_schema(dcur: snowflake.connector.cursor.DictCursor):
299
+ dcur.execute("create schema example")
300
+ assert dcur.fetchall() == [{"status": "Schema EXAMPLE successfully created."}]
301
+ assert dcur.description == [ResultMetadata(name='status', type_code=2, display_size=None, internal_size=16777216, precision=None, scale=None, is_nullable=True)] # fmt: skip
302
+ dcur.execute("drop schema example")
303
+ assert dcur.fetchall() == [{"status": "EXAMPLE successfully dropped."}]
304
+ assert dcur.description == [ResultMetadata(name='status', type_code=2, display_size=None, internal_size=16777216, precision=None, scale=None, is_nullable=True)] # fmt: skip
305
+
306
+
307
+ def test_description_create_drop_table(dcur: snowflake.connector.cursor.DictCursor):
308
+ dcur.execute("create table example (X int)")
309
+ assert dcur.fetchall() == [{"status": "Table EXAMPLE successfully created."}]
310
+ assert dcur.description == [ResultMetadata(name='status', type_code=2, display_size=None, internal_size=16777216, precision=None, scale=None, is_nullable=True)] # fmt: skip
311
+ dcur.execute("drop table example")
312
+ assert dcur.fetchall() == [{"status": "EXAMPLE successfully dropped."}]
313
+ assert dcur.description == [ResultMetadata(name='status', type_code=2, display_size=None, internal_size=16777216, precision=None, scale=None, is_nullable=True)] # fmt: skip
314
+
315
+
316
+ def test_description_insert(dcur: snowflake.connector.cursor.DictCursor):
317
+ dcur.execute("create table example (X int)")
318
+ dcur.execute("insert into example values (1), (2)")
319
+ assert dcur.fetchall() == [{"number of rows inserted": 2}]
320
+ # TODO: Snowflake is actually precision=19, is_nullable=False
321
+ assert dcur.description == [ResultMetadata(name='number of rows inserted', type_code=0, display_size=None, internal_size=None, precision=38, scale=0, is_nullable=True)] # fmt: skip
322
+
323
+
281
324
  def test_executemany(cur: snowflake.connector.cursor.SnowflakeCursor):
282
325
  cur.execute("create table customers (ID int, FIRST_NAME varchar, LAST_NAME varchar)")
283
326
 
@@ -296,49 +339,65 @@ def test_execute_string(conn: snowflake.connector.SnowflakeConnection):
296
339
  assert [(1,)] == cur2.fetchall()
297
340
 
298
341
 
299
- def test_fetchall(cur: snowflake.connector.cursor.SnowflakeCursor):
300
- cur.execute("create table customers (ID int, FIRST_NAME varchar, LAST_NAME varchar)")
301
- cur.execute("insert into customers values (1, 'Jenny', 'P')")
302
- cur.execute("insert into customers values (2, 'Jasper', 'M')")
303
- cur.execute("select id, first_name, last_name from customers")
304
-
305
- assert cur.fetchall() == [(1, "Jenny", "P"), (2, "Jasper", "M")]
306
-
307
-
308
- def test_fetchall_dict_cursor(conn: snowflake.connector.SnowflakeConnection):
309
- with conn.cursor(snowflake.connector.cursor.DictCursor) as cur:
342
+ def test_fetchall(conn: snowflake.connector.SnowflakeConnection):
343
+ with conn.cursor() as cur:
310
344
  cur.execute("create table customers (ID int, FIRST_NAME varchar, LAST_NAME varchar)")
311
345
  cur.execute("insert into customers values (1, 'Jenny', 'P')")
312
346
  cur.execute("insert into customers values (2, 'Jasper', 'M')")
313
347
  cur.execute("select id, first_name, last_name from customers")
314
348
 
349
+ assert cur.fetchall() == [(1, "Jenny", "P"), (2, "Jasper", "M")]
350
+
351
+ with conn.cursor(snowflake.connector.cursor.DictCursor) as cur:
352
+ cur.execute("select id, first_name, last_name from customers")
353
+
315
354
  assert cur.fetchall() == [
316
355
  {"ID": 1, "FIRST_NAME": "Jenny", "LAST_NAME": "P"},
317
356
  {"ID": 2, "FIRST_NAME": "Jasper", "LAST_NAME": "M"},
318
357
  ]
319
358
 
320
359
 
321
- def test_fetchone(cur: snowflake.connector.cursor.SnowflakeCursor):
322
- cur.execute("create table customers (ID int, FIRST_NAME varchar, LAST_NAME varchar)")
323
- cur.execute("insert into customers values (1, 'Jenny', 'P')")
324
- cur.execute("insert into customers values (2, 'Jasper', 'M')")
325
- cur.execute("select id, first_name, last_name from customers")
326
-
327
- assert cur.fetchone() == (1, "Jenny", "P")
328
- assert cur.fetchone() == (2, "Jasper", "M")
329
- assert not cur.fetchone()
360
+ def test_fetchone(conn: snowflake.connector.SnowflakeConnection):
361
+ with conn.cursor() as cur:
362
+ cur.execute("create table customers (ID int, FIRST_NAME varchar, LAST_NAME varchar)")
363
+ cur.execute("insert into customers values (1, 'Jenny', 'P')")
364
+ cur.execute("insert into customers values (2, 'Jasper', 'M')")
365
+ cur.execute("select id, first_name, last_name from customers")
330
366
 
367
+ assert cur.fetchone() == (1, "Jenny", "P")
368
+ assert cur.fetchone() == (2, "Jasper", "M")
369
+ assert cur.fetchone() is None
331
370
 
332
- def test_fetchone_dict_cursor(conn: snowflake.connector.SnowflakeConnection):
333
371
  with conn.cursor(snowflake.connector.cursor.DictCursor) as cur:
372
+ cur.execute("select id, first_name, last_name from customers")
373
+
374
+ assert cur.fetchone() == {"ID": 1, "FIRST_NAME": "Jenny", "LAST_NAME": "P"}
375
+ assert cur.fetchone() == {"ID": 2, "FIRST_NAME": "Jasper", "LAST_NAME": "M"}
376
+ assert cur.fetchone() is None
377
+
378
+
379
+ def test_fetchmany(conn: snowflake.connector.SnowflakeConnection):
380
+ with conn.cursor() as cur:
334
381
  cur.execute("create table customers (ID int, FIRST_NAME varchar, LAST_NAME varchar)")
335
382
  cur.execute("insert into customers values (1, 'Jenny', 'P')")
336
383
  cur.execute("insert into customers values (2, 'Jasper', 'M')")
384
+ cur.execute("insert into customers values (3, 'Jeremy', 'K')")
337
385
  cur.execute("select id, first_name, last_name from customers")
338
386
 
339
- assert cur.fetchone() == {"ID": 1, "FIRST_NAME": "Jenny", "LAST_NAME": "P"}
340
- assert cur.fetchone() == {"ID": 2, "FIRST_NAME": "Jasper", "LAST_NAME": "M"}
341
- assert not cur.fetchone()
387
+ assert cur.fetchmany(2) == [(1, "Jenny", "P"), (2, "Jasper", "M")]
388
+ assert cur.fetchmany(2) == [(3, "Jeremy", "K")]
389
+ assert cur.fetchmany(2) == []
390
+
391
+ with conn.cursor(snowflake.connector.cursor.DictCursor) as cur:
392
+ cur.execute("select id, first_name, last_name from customers")
393
+ assert cur.fetchmany(2) == [
394
+ {"ID": 1, "FIRST_NAME": "Jenny", "LAST_NAME": "P"},
395
+ {"ID": 2, "FIRST_NAME": "Jasper", "LAST_NAME": "M"},
396
+ ]
397
+ assert cur.fetchmany(2) == [
398
+ {"ID": 3, "FIRST_NAME": "Jeremy", "LAST_NAME": "K"},
399
+ ]
400
+ assert cur.fetchmany(2) == []
342
401
 
343
402
 
344
403
  def test_fetch_pandas_all(cur: snowflake.connector.cursor.SnowflakeCursor):
@@ -357,6 +416,23 @@ def test_fetch_pandas_all(cur: snowflake.connector.cursor.SnowflakeCursor):
357
416
  assert_frame_equal(cur.fetch_pandas_all(), expected_df)
358
417
 
359
418
 
419
+ def test_flatten(cur: snowflake.connector.cursor.SnowflakeCursor):
420
+ cur.execute(
421
+ """
422
+ select t.id, flat.value:fruit from
423
+ (
424
+ select 1, parse_json('[{"fruit":"banana"}]')
425
+ union
426
+ select 2, parse_json('[{"fruit":"coconut"}, {"fruit":"durian"}]')
427
+ ) as t(id, fruits), lateral flatten(input => t.fruits) AS flat
428
+ order by id
429
+ """
430
+ # duckdb lateral join order is non-deterministic so order by id
431
+ # within an id the order of fruits should match the json array
432
+ )
433
+ assert cur.fetchall() == [(1, '"banana"'), (2, '"coconut"'), (2, '"durian"')]
434
+
435
+
360
436
  def test_floats_are_64bit(cur: snowflake.connector.cursor.SnowflakeCursor):
361
437
  cur.execute("create or replace table example (f float, f4 float4, f8 float8, d double, r real)")
362
438
  cur.execute("insert into example values (1.23, 1.23, 1.23, 1.23, 1.23)")
@@ -365,6 +441,12 @@ def test_floats_are_64bit(cur: snowflake.connector.cursor.SnowflakeCursor):
365
441
  assert cur.fetchall() == [(1.23, 1.23, 1.23, 1.23, 1.23)]
366
442
 
367
443
 
444
+ def test_get_path_as_varchar(cur: snowflake.connector.cursor.SnowflakeCursor):
445
+ cur.execute("""select parse_json('{"fruit":"banana"}'):fruit::varchar""")
446
+ # converting json to varchar returns unquoted string
447
+ assert cur.fetchall() == [("banana",)]
448
+
449
+
368
450
  def test_get_result_batches(cur: snowflake.connector.cursor.SnowflakeCursor):
369
451
  cur.execute("create table customers (ID int, FIRST_NAME varchar, LAST_NAME varchar)")
370
452
  cur.execute("insert into customers values (1, 'Jenny', 'P')")
@@ -657,21 +739,6 @@ def test_to_decimal(cur: snowflake.connector.cursor.SnowflakeCursor):
657
739
  ]
658
740
 
659
741
 
660
- def test_write_pandas_timestamp_ntz(conn: snowflake.connector.SnowflakeConnection):
661
- # compensate for https://github.com/duckdb/duckdb/issues/7980
662
- with conn.cursor() as cur:
663
- cur.execute("create table example (UPDATE_AT_NTZ timestamp_ntz(9))")
664
- # cur.execute("create table example (UPDATE_AT_NTZ timestamp)")
665
-
666
- now_utc = datetime.datetime.now(pytz.utc)
667
- df = pd.DataFrame([(now_utc,)], columns=["UPDATE_AT_NTZ"])
668
- snowflake.connector.pandas_tools.write_pandas(conn, df, "EXAMPLE")
669
-
670
- cur.execute("select * from example")
671
-
672
- assert cur.fetchall() == [(now_utc.replace(tzinfo=None),)]
673
-
674
-
675
742
  def test_transactions(conn: snowflake.connector.SnowflakeConnection):
676
743
  conn.execute_string(
677
744
  """CREATE TABLE table1 (i int);
@@ -688,6 +755,17 @@ def test_transactions(conn: snowflake.connector.SnowflakeConnection):
688
755
  cur.execute("SELECT * FROM table1")
689
756
  assert cur.fetchall() == [(2,)]
690
757
 
758
+ # check rollback and commit without transaction is a success (to mimic snowflake)
759
+ # also check description can be retrieved, needed for ipython-sql/jupysql which runs description implicitly
760
+ with conn.cursor() as cur:
761
+ cur.execute("COMMIT")
762
+ assert cur.description == [ResultMetadata(name='status', type_code=2, display_size=None, internal_size=16777216, precision=None, scale=None, is_nullable=True)] # fmt: skip
763
+ assert cur.fetchall() == [("Statement executed successfully.",)]
764
+
765
+ cur.execute("ROLLBACK")
766
+ assert cur.description == [ResultMetadata(name='status', type_code=2, display_size=None, internal_size=16777216, precision=None, scale=None, is_nullable=True)] # fmt: skip
767
+ assert cur.fetchall() == [("Statement executed successfully.",)]
768
+
691
769
 
692
770
  def test_unquoted_identifiers_are_upper_cased(conn: snowflake.connector.SnowflakeConnection):
693
771
  with conn.cursor(snowflake.connector.cursor.DictCursor) as cur:
@@ -768,6 +846,21 @@ def test_write_pandas(conn: snowflake.connector.SnowflakeConnection):
768
846
  assert cur.fetchall() == [(1, "Jenny", "P"), (2, "Jasper", "M")]
769
847
 
770
848
 
849
+ def test_write_pandas_timestamp_ntz(conn: snowflake.connector.SnowflakeConnection):
850
+ # compensate for https://github.com/duckdb/duckdb/issues/7980
851
+ with conn.cursor() as cur:
852
+ cur.execute("create table example (UPDATE_AT_NTZ timestamp_ntz(9))")
853
+ # cur.execute("create table example (UPDATE_AT_NTZ timestamp)")
854
+
855
+ now_utc = datetime.datetime.now(pytz.utc)
856
+ df = pd.DataFrame([(now_utc,)], columns=["UPDATE_AT_NTZ"])
857
+ snowflake.connector.pandas_tools.write_pandas(conn, df, "EXAMPLE")
858
+
859
+ cur.execute("select * from example")
860
+
861
+ assert cur.fetchall() == [(now_utc.replace(tzinfo=None),)]
862
+
863
+
771
864
  def test_write_pandas_partial_columns(conn: snowflake.connector.SnowflakeConnection):
772
865
  with conn.cursor() as cur:
773
866
  cur.execute("create table customers (ID int, FIRST_NAME varchar, LAST_NAME varchar)")
@@ -39,7 +39,7 @@ def test_patch_other_unloaded_module() -> None:
39
39
  def test_cannot_patch_twice(_fakesnow_no_auto_create: None) -> None:
40
40
  # _fakesnow is the first patch
41
41
 
42
- with pytest.raises(AssertionError) as excinfo:
42
+ with pytest.raises(AssertionError) as excinfo: # noqa: SIM117
43
43
  # second patch will fail
44
44
  with fakesnow.patch():
45
45
  pass
@@ -7,11 +7,13 @@ from fakesnow.transforms import (
7
7
  drop_schema_cascade,
8
8
  extract_comment,
9
9
  extract_text_length,
10
+ flatten,
10
11
  float_to_double,
11
12
  indices_to_json_extract,
12
13
  information_schema_columns_snowflake,
13
14
  information_schema_tables_ext,
14
15
  integer_precision,
16
+ json_extract_as_varchar,
15
17
  object_construct,
16
18
  parse_json,
17
19
  regex_replace,
@@ -67,6 +69,25 @@ def test_extract_text_length() -> None:
67
69
  assert e.args["text_lengths"] == [("t1", 16777216), ("t2", 10), ("t3", 20)]
68
70
 
69
71
 
72
+ def test_flatten() -> None:
73
+ assert (
74
+ sqlglot.parse_one(
75
+ """
76
+ select t.id, flat.value:fruit from
77
+ (
78
+ select 1, parse_json('[{"fruit":"banana"}]')
79
+ union
80
+ select 2, parse_json('[{"fruit":"coconut"}, {"fruit":"durian"}]')
81
+ ) as t(id, fruits), lateral flatten(input => t.fruits) AS flat
82
+ """,
83
+ read="snowflake",
84
+ ).transform(flatten)
85
+ # needed to transform flat.value:fruit to flat.value -> '$.fruit'
86
+ .transform(indices_to_json_extract).sql(dialect="duckdb")
87
+ == """SELECT t.id, flat.value -> '$.fruit' FROM (SELECT 1, JSON('[{"fruit":"banana"}]') UNION SELECT 2, JSON('[{"fruit":"coconut"}, {"fruit":"durian"}]')) AS t(id, fruits), LATERAL UNNEST(LIST_REVERSE(CAST(t.fruits AS JSON[]))) AS flat(VALUE)""" # noqa: E501
88
+ )
89
+
90
+
70
91
  def test_float_to_double() -> None:
71
92
  assert (
72
93
  sqlglot.parse_one("create table example (f float, f4 float4, f8 float8, d double, r real)")
@@ -121,6 +142,18 @@ def test_information_schema_tables_ext() -> None:
121
142
  )
122
143
 
123
144
 
145
+ def test_json_extract_as_varchar() -> None:
146
+ assert (
147
+ sqlglot.parse_one(
148
+ """select parse_json('{"fruit":"banana"}'):fruit::varchar""",
149
+ read="snowflake",
150
+ )
151
+ # needed first
152
+ .transform(indices_to_json_extract).transform(json_extract_as_varchar).sql(dialect="duckdb")
153
+ == """SELECT JSON('{"fruit":"banana"}') ->> '$.fruit'"""
154
+ )
155
+
156
+
124
157
  def test_object_construct() -> None:
125
158
  assert (
126
159
  sqlglot.parse_one("SELECT OBJECT_CONSTRUCT('a',1,'b','BBBB', 'c',null)", read="snowflake")
@@ -150,7 +183,7 @@ def test_regex_substr() -> None:
150
183
  assert (
151
184
  sqlglot.parse_one("SELECT regexp_substr(string1, 'the\\\\W+\\\\w+')", read="snowflake")
152
185
  .transform(regex_substr)
153
- .sql()
186
+ .sql(dialect="duckdb")
154
187
  == "SELECT REGEXP_EXTRACT_ALL(string1[1 : ], 'the\\W+\\w+', 0, '')[1]"
155
188
  )
156
189
 
@@ -188,7 +221,7 @@ def test_timestamp_ntz_ns() -> None:
188
221
  def test_to_date() -> None:
189
222
  assert (
190
223
  sqlglot.parse_one("SELECT to_date(to_timestamp(0))").transform(to_date).sql()
191
- == "SELECT CAST(DATE_TRUNC('day', TO_TIMESTAMP(0)) AS DATE)"
224
+ == "SELECT CAST(DATE_TRUNC('DAY', TO_TIMESTAMP(0)) AS DATE)"
192
225
  )
193
226
 
194
227
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -1,7 +1,7 @@
1
- from string import Template
2
-
3
1
  """Info schema extension tables/views used for storing snowflake metadata not captured by duckdb."""
4
2
 
3
+ from string import Template
4
+
5
5
  # use ext prefix in columns to disambiguate when joining with information_schema.tables
6
6
  SQL_CREATE_INFORMATION_SCHEMA_TABLES_EXT = Template(
7
7
  """
File without changes
File without changes
File without changes
File without changes