fakesnow 0.9.35__py3-none-any.whl → 0.9.36__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.
- fakesnow/__init__.py +51 -0
- fakesnow/conn.py +1 -2
- fakesnow/cursor.py +15 -0
- fakesnow/fixtures.py +7 -0
- fakesnow/server.py +4 -1
- fakesnow/transforms/__init__.py +1 -0
- fakesnow/transforms/copy_into.py +163 -0
- {fakesnow-0.9.35.dist-info → fakesnow-0.9.36.dist-info}/METADATA +118 -34
- {fakesnow-0.9.35.dist-info → fakesnow-0.9.36.dist-info}/RECORD +13 -12
- {fakesnow-0.9.35.dist-info → fakesnow-0.9.36.dist-info}/WHEEL +1 -1
- {fakesnow-0.9.35.dist-info → fakesnow-0.9.36.dist-info}/entry_points.txt +0 -0
- {fakesnow-0.9.35.dist-info → fakesnow-0.9.36.dist-info}/licenses/LICENSE +0 -0
- {fakesnow-0.9.35.dist-info → fakesnow-0.9.36.dist-info}/top_level.txt +0 -0
fakesnow/__init__.py
CHANGED
@@ -91,3 +91,54 @@ def patch(
|
|
91
91
|
finally:
|
92
92
|
stack.close()
|
93
93
|
fs.duck_conn.close()
|
94
|
+
|
95
|
+
|
96
|
+
@contextmanager
|
97
|
+
def server(port: int | None = None, session_parameters: dict[str, str | int | bool] | None = None) -> Iterator[dict]:
|
98
|
+
"""Start a fake snowflake server in a separate thread and yield connection kwargs.
|
99
|
+
|
100
|
+
Args:
|
101
|
+
port (int | None, optional): Port to run the server on. If None, an available port is chosen. Defaults to None.
|
102
|
+
|
103
|
+
Yields:
|
104
|
+
Iterator[dict]: Connection parameters for the fake snowflake server.
|
105
|
+
"""
|
106
|
+
import socket
|
107
|
+
import threading
|
108
|
+
from time import sleep
|
109
|
+
|
110
|
+
import uvicorn
|
111
|
+
|
112
|
+
import fakesnow.server
|
113
|
+
|
114
|
+
# find an unused TCP port between 1024-65535
|
115
|
+
if not port:
|
116
|
+
with contextlib.closing(socket.socket(type=socket.SOCK_STREAM)) as sock:
|
117
|
+
sock.bind(("127.0.0.1", 0))
|
118
|
+
port = sock.getsockname()[1]
|
119
|
+
|
120
|
+
assert port
|
121
|
+
server = uvicorn.Server(uvicorn.Config(fakesnow.server.app, port=port, log_level="info"))
|
122
|
+
thread = threading.Thread(target=server.run, name="Server", daemon=True)
|
123
|
+
thread.start()
|
124
|
+
|
125
|
+
while not server.started:
|
126
|
+
sleep(0.1)
|
127
|
+
|
128
|
+
try:
|
129
|
+
yield dict(
|
130
|
+
user="fake",
|
131
|
+
password="snow",
|
132
|
+
account="fakesnow",
|
133
|
+
host="localhost",
|
134
|
+
port=port,
|
135
|
+
protocol="http",
|
136
|
+
# disable telemetry
|
137
|
+
session_parameters={"CLIENT_OUT_OF_BAND_TELEMETRY_ENABLED": False} | (session_parameters or {}),
|
138
|
+
# disable retries on error
|
139
|
+
network_timeout=1,
|
140
|
+
)
|
141
|
+
finally:
|
142
|
+
server.should_exit = True
|
143
|
+
# wait for server thread to end
|
144
|
+
thread.join()
|
fakesnow/conn.py
CHANGED
@@ -6,8 +6,7 @@ from pathlib import Path
|
|
6
6
|
from types import TracebackType
|
7
7
|
from typing import Any
|
8
8
|
|
9
|
-
import snowflake.connector
|
10
|
-
import snowflake.connector.errors
|
9
|
+
import snowflake.connector
|
11
10
|
import sqlglot
|
12
11
|
from duckdb import DuckDBPyConnection
|
13
12
|
from snowflake.connector.cursor import DictCursor, SnowflakeCursor
|
fakesnow/cursor.py
CHANGED
@@ -46,6 +46,12 @@ SQL_DROPPED = Template("SELECT '${name} successfully dropped.' as 'status'")
|
|
46
46
|
SQL_INSERTED_ROWS = Template("SELECT ${count} as 'number of rows inserted'")
|
47
47
|
SQL_UPDATED_ROWS = Template("SELECT ${count} as 'number of rows updated', 0 as 'number of multi-joined rows updated'")
|
48
48
|
SQL_DELETED_ROWS = Template("SELECT ${count} as 'number of rows deleted'")
|
49
|
+
SQL_COPY_ROWS = Template(
|
50
|
+
"SELECT '${file}' as file, 'LOADED' as status, ${count} as rows_parsed, "
|
51
|
+
"${count} as rows_loaded, 1 as error_limit, 0 as errors_seen, "
|
52
|
+
"NULL as first_error, NULL as first_error_line, NULL as first_error_character, "
|
53
|
+
"NULL as first_error_column_name"
|
54
|
+
)
|
49
55
|
|
50
56
|
|
51
57
|
class FakeSnowflakeCursor:
|
@@ -249,6 +255,7 @@ class FakeSnowflakeCursor:
|
|
249
255
|
.transform(lambda e: transforms.show_keys(e, self._conn.database, kind="FOREIGN"))
|
250
256
|
.transform(transforms.show_users)
|
251
257
|
.transform(transforms.create_user)
|
258
|
+
.transform(transforms.copy_into)
|
252
259
|
.transform(transforms.sha256)
|
253
260
|
.transform(transforms.create_clone)
|
254
261
|
.transform(transforms.alias_in_join)
|
@@ -300,6 +307,10 @@ class FakeSnowflakeCursor:
|
|
300
307
|
raise snowflake.connector.errors.DatabaseError(msg=e.args[0], errno=250002, sqlstate="08003") from None
|
301
308
|
except duckdb.ParserException as e:
|
302
309
|
raise snowflake.connector.errors.ProgrammingError(msg=e.args[0], errno=1003, sqlstate="42000") from None
|
310
|
+
except duckdb.HTTPException as e:
|
311
|
+
raise snowflake.connector.errors.ProgrammingError(msg=e.args[0], errno=91016, sqlstate="22000") from None
|
312
|
+
except duckdb.ConversionException as e:
|
313
|
+
raise snowflake.connector.errors.ProgrammingError(msg=e.args[0], errno=100038, sqlstate="22018") from None
|
303
314
|
|
304
315
|
affected_count = None
|
305
316
|
|
@@ -319,6 +330,10 @@ class FakeSnowflakeCursor:
|
|
319
330
|
self._duck_conn.execute(info_schema.per_db_creation_sql(create_db_name))
|
320
331
|
result_sql = SQL_CREATED_DATABASE.substitute(name=create_db_name)
|
321
332
|
|
333
|
+
elif copy_from := transformed.args.get("copy_from"):
|
334
|
+
(affected_count,) = self._duck_conn.fetchall()[0]
|
335
|
+
result_sql = SQL_COPY_ROWS.substitute(count=affected_count, file=copy_from)
|
336
|
+
|
322
337
|
elif cmd == "INSERT":
|
323
338
|
(affected_count,) = self._duck_conn.fetchall()[0]
|
324
339
|
result_sql = SQL_INSERTED_ROWS.substitute(count=affected_count)
|
fakesnow/fixtures.py
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
from collections.abc import Iterator
|
2
|
+
from typing import Any
|
2
3
|
|
3
4
|
import pytest
|
4
5
|
|
@@ -11,6 +12,12 @@ def _fakesnow() -> Iterator[None]:
|
|
11
12
|
yield
|
12
13
|
|
13
14
|
|
15
|
+
@pytest.fixture(scope="session")
|
16
|
+
def fakesnow_server() -> Iterator[dict[str, Any]]:
|
17
|
+
with fakesnow.server() as conn_kwargs:
|
18
|
+
yield conn_kwargs
|
19
|
+
|
20
|
+
|
14
21
|
@pytest.fixture
|
15
22
|
def _fakesnow_no_auto_create() -> Iterator[None]:
|
16
23
|
with fakesnow.patch(create_database_on_connect=False, create_schema_on_connect=False):
|
fakesnow/server.py
CHANGED
@@ -59,7 +59,10 @@ async def login_request(request: Request) -> JSONResponse:
|
|
59
59
|
{
|
60
60
|
"data": {
|
61
61
|
"token": token,
|
62
|
-
"parameters": [
|
62
|
+
"parameters": [
|
63
|
+
{"name": "AUTOCOMMIT", "value": True},
|
64
|
+
{"name": "CLIENT_SESSION_KEEP_ALIVE_HEARTBEAT_FREQUENCY", "value": 3600},
|
65
|
+
],
|
63
66
|
},
|
64
67
|
"success": True,
|
65
68
|
}
|
fakesnow/transforms/__init__.py
CHANGED
@@ -7,6 +7,7 @@ from typing import ClassVar, cast
|
|
7
7
|
import sqlglot
|
8
8
|
from sqlglot import exp
|
9
9
|
|
10
|
+
from fakesnow.transforms.copy_into import copy_into as copy_into
|
10
11
|
from fakesnow.transforms.merge import merge as merge
|
11
12
|
from fakesnow.transforms.show import (
|
12
13
|
show_columns as show_columns,
|
@@ -0,0 +1,163 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from dataclasses import dataclass, field, replace
|
4
|
+
from pathlib import PurePath
|
5
|
+
from typing import Protocol
|
6
|
+
from urllib.parse import urlparse, urlunparse
|
7
|
+
|
8
|
+
import snowflake.connector.errors
|
9
|
+
from sqlglot import exp
|
10
|
+
from typing_extensions import Self
|
11
|
+
|
12
|
+
|
13
|
+
def copy_into(expr: exp.Expression) -> exp.Expression:
|
14
|
+
if not isinstance(expr, exp.Copy):
|
15
|
+
return expr
|
16
|
+
|
17
|
+
schema = expr.this
|
18
|
+
|
19
|
+
columns = [exp.Column(this=exp.Identifier(this=f"column{i}")) for i in range(len(schema.expressions))] or [
|
20
|
+
exp.Column(this=exp.Star())
|
21
|
+
]
|
22
|
+
|
23
|
+
params = expr.args.get("params", [])
|
24
|
+
# TODO: remove columns
|
25
|
+
file_type_handler = _handle_params(params, [c.name for c in columns])
|
26
|
+
|
27
|
+
# the FROM expression
|
28
|
+
source = expr.args["files"][0].this
|
29
|
+
assert isinstance(source, exp.Literal), f"{source.__class__} is not a exp.Literal"
|
30
|
+
|
31
|
+
if len(file_type_handler.files) > 1:
|
32
|
+
raise NotImplementedError("Multiple files not currently supported")
|
33
|
+
file = file_type_handler.files[0]
|
34
|
+
|
35
|
+
scheme, netloc, path, params, query, fragment = urlparse(source.name)
|
36
|
+
if not scheme:
|
37
|
+
raise snowflake.connector.errors.ProgrammingError(
|
38
|
+
msg=f"SQL compilation error:\ninvalid URL prefix found in: '{source.name}'", errno=1011, sqlstate="42601"
|
39
|
+
)
|
40
|
+
path = str(PurePath(path) / file.name)
|
41
|
+
url = urlunparse((scheme, netloc, path, params, query, fragment))
|
42
|
+
|
43
|
+
return exp.Insert(
|
44
|
+
this=schema,
|
45
|
+
expression=exp.Select(expressions=columns).from_(exp.Table(this=file_type_handler.read_expression(url))),
|
46
|
+
copy_from=url,
|
47
|
+
)
|
48
|
+
|
49
|
+
|
50
|
+
def _handle_params(params: list[exp.CopyParameter], columns: list[str]) -> FileTypeHandler:
|
51
|
+
file_type_handler = None
|
52
|
+
force = False
|
53
|
+
files = []
|
54
|
+
for param in params:
|
55
|
+
var = param.this.name
|
56
|
+
if var == "FILE_FORMAT":
|
57
|
+
if file_type_handler:
|
58
|
+
raise ValueError(params)
|
59
|
+
|
60
|
+
var_type = next((e.args["value"].this for e in param.expressions if e.this.this == "TYPE"), None)
|
61
|
+
if not var_type:
|
62
|
+
raise NotImplementedError("FILE_FORMAT without TYPE is not currently implemented")
|
63
|
+
|
64
|
+
if var_type == "CSV":
|
65
|
+
file_type_handler = handle_csv(param.expressions, columns)
|
66
|
+
else:
|
67
|
+
raise NotImplementedError(f"{var_type} FILE_FORMAT is not currently implemented")
|
68
|
+
|
69
|
+
elif var == "FILES":
|
70
|
+
files = param.expression.expressions if isinstance(param.expression, exp.Tuple) else [param.expression.this]
|
71
|
+
elif var == "FORCE":
|
72
|
+
force = True
|
73
|
+
pass
|
74
|
+
else:
|
75
|
+
raise ValueError(f"Unknown copy parameter: {param.this}")
|
76
|
+
|
77
|
+
if not force:
|
78
|
+
raise NotImplementedError("COPY INTO with FORCE=false (default) is not currently implemented")
|
79
|
+
|
80
|
+
if not files:
|
81
|
+
raise NotImplementedError("COPY INTO without FILES is not currently implemented")
|
82
|
+
|
83
|
+
if not file_type_handler:
|
84
|
+
# default to CSV
|
85
|
+
file_type_handler = handle_csv([], columns)
|
86
|
+
|
87
|
+
file_type_handler = file_type_handler.with_files(files)
|
88
|
+
return file_type_handler
|
89
|
+
|
90
|
+
|
91
|
+
def handle_csv(expressions: list[exp.Property], columns: list[str]) -> ReadCSV:
|
92
|
+
skip_header = ReadCSV.skip_header
|
93
|
+
quote = ReadCSV.quote
|
94
|
+
delimiter = ReadCSV.delimiter
|
95
|
+
|
96
|
+
for expression in expressions:
|
97
|
+
exp_type = expression.name
|
98
|
+
if exp_type in {"TYPE"}:
|
99
|
+
continue
|
100
|
+
|
101
|
+
elif exp_type == "SKIP_HEADER":
|
102
|
+
skip_header = True
|
103
|
+
elif exp_type == "FIELD_OPTIONALLY_ENCLOSED_BY":
|
104
|
+
quote = expression.args["value"].this
|
105
|
+
elif exp_type == "FIELD_DELIMITER":
|
106
|
+
delimiter = expression.args["value"].this
|
107
|
+
else:
|
108
|
+
raise NotImplementedError(f"{exp_type} is not currently implemented")
|
109
|
+
|
110
|
+
return ReadCSV(
|
111
|
+
skip_header=skip_header,
|
112
|
+
quote=quote,
|
113
|
+
delimiter=delimiter,
|
114
|
+
columns=columns,
|
115
|
+
)
|
116
|
+
|
117
|
+
|
118
|
+
@dataclass
|
119
|
+
class FileTypeHandler(Protocol):
|
120
|
+
files: list = field(default_factory=list)
|
121
|
+
|
122
|
+
def read_expression(self, url: str) -> exp.Expression: ...
|
123
|
+
|
124
|
+
def with_files(self, files: list) -> Self:
|
125
|
+
return replace(self, files=files)
|
126
|
+
|
127
|
+
@staticmethod
|
128
|
+
def make_eq(name: str, value: list | str | int | bool) -> exp.EQ:
|
129
|
+
if isinstance(value, list):
|
130
|
+
expression = exp.array(*[exp.Literal(this=str(v), is_string=isinstance(v, str)) for v in value])
|
131
|
+
elif isinstance(value, bool):
|
132
|
+
expression = exp.Boolean(this=value)
|
133
|
+
else:
|
134
|
+
expression = exp.Literal(this=str(value), is_string=isinstance(value, str))
|
135
|
+
|
136
|
+
return exp.EQ(this=exp.Literal(this=name, is_string=False), expression=expression)
|
137
|
+
|
138
|
+
|
139
|
+
@dataclass
|
140
|
+
class ReadCSV(FileTypeHandler):
|
141
|
+
skip_header: bool = False
|
142
|
+
quote: str | None = None
|
143
|
+
delimiter: str = ","
|
144
|
+
columns: list[str] = field(default_factory=list)
|
145
|
+
|
146
|
+
def read_expression(self, url: str) -> exp.Expression:
|
147
|
+
args = []
|
148
|
+
|
149
|
+
# don't parse header and use as column names, keep them as column0, column1, etc
|
150
|
+
args.append(self.make_eq("header", False))
|
151
|
+
|
152
|
+
if self.skip_header:
|
153
|
+
args.append(self.make_eq("skip", 1))
|
154
|
+
|
155
|
+
if self.quote:
|
156
|
+
quote = self.quote.replace("'", "''")
|
157
|
+
args.append(self.make_eq("quote", quote))
|
158
|
+
|
159
|
+
if self.delimiter and self.delimiter != ",":
|
160
|
+
delimiter = self.delimiter.replace("'", "''")
|
161
|
+
args.append(self.make_eq("sep", delimiter))
|
162
|
+
|
163
|
+
return exp.func("read_csv", exp.Literal(this=url, is_string=True), *args)
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: fakesnow
|
3
|
-
Version: 0.9.
|
3
|
+
Version: 0.9.36
|
4
4
|
Summary: Fake Snowflake Connector for Python. Run, mock and test Snowflake DB locally.
|
5
5
|
License: Apache License
|
6
6
|
Version 2.0, January 2004
|
@@ -215,6 +215,7 @@ Requires-Dist: pyarrow
|
|
215
215
|
Requires-Dist: snowflake-connector-python
|
216
216
|
Requires-Dist: sqlglot~=26.12.1
|
217
217
|
Provides-Extra: dev
|
218
|
+
Requires-Dist: boto3-stubs[s3,sts]; extra == "dev"
|
218
219
|
Requires-Dist: build~=1.0; extra == "dev"
|
219
220
|
Requires-Dist: dirty-equals; extra == "dev"
|
220
221
|
Requires-Dist: pandas-stubs; extra == "dev"
|
@@ -222,10 +223,11 @@ Requires-Dist: snowflake-connector-python[pandas,secure-local-storage]; extra ==
|
|
222
223
|
Requires-Dist: pre-commit~=4.0; extra == "dev"
|
223
224
|
Requires-Dist: pyarrow-stubs==17.19; extra == "dev"
|
224
225
|
Requires-Dist: pytest~=8.0; extra == "dev"
|
225
|
-
Requires-Dist: pytest-asyncio; extra == "dev"
|
226
226
|
Requires-Dist: ruff~=0.11.0; extra == "dev"
|
227
227
|
Requires-Dist: twine~=6.0; extra == "dev"
|
228
228
|
Requires-Dist: snowflake-sqlalchemy~=1.7.0; extra == "dev"
|
229
|
+
Requires-Dist: boto3; extra == "dev"
|
230
|
+
Requires-Dist: moto[server]>=5; extra == "dev"
|
229
231
|
Provides-Extra: notebook
|
230
232
|
Requires-Dist: duckdb-engine; extra == "notebook"
|
231
233
|
Requires-Dist: ipykernel; extra == "notebook"
|
@@ -242,7 +244,7 @@ Dynamic: license-file
|
|
242
244
|
[](https://pypi.org/project/fakesnow/)
|
243
245
|
[](https://pypi.org/project/fakesnow/)
|
244
246
|
|
245
|
-
|
247
|
+
Run, mock and test fake Snowflake databases locally.
|
246
248
|
|
247
249
|
## Install
|
248
250
|
|
@@ -250,9 +252,21 @@ Fake [Snowflake Connector for Python](https://docs.snowflake.com/en/user-guide/p
|
|
250
252
|
pip install fakesnow
|
251
253
|
```
|
252
254
|
|
255
|
+
Or to install with the server:
|
256
|
+
|
257
|
+
```
|
258
|
+
pip install fakesnow[server]
|
259
|
+
```
|
260
|
+
|
253
261
|
## Usage
|
254
262
|
|
255
|
-
|
263
|
+
fakesnow offers two main approaches for faking Snowflake: in-process patching of the [Snowflake Connector for Python](https://docs.snowflake.com/en/user-guide/python-connector) or a standalone HTTP server.
|
264
|
+
|
265
|
+
Patching only applies to the current Python process. If a subprocess is spawned it won't be patched. For subprocesses, or for non-Python clients, use the server instead.
|
266
|
+
|
267
|
+
### In-process patching
|
268
|
+
|
269
|
+
To run script.py with patching:
|
256
270
|
|
257
271
|
```shell
|
258
272
|
fakesnow script.py
|
@@ -266,9 +280,9 @@ fakesnow -m pytest
|
|
266
280
|
|
267
281
|
`fakesnow` executes `fakesnow.patch` before running the script or module.
|
268
282
|
|
269
|
-
|
283
|
+
#### Use fakesnow.patch in your code
|
270
284
|
|
271
|
-
|
285
|
+
Alternatively, use fakesnow.patch in your code:
|
272
286
|
|
273
287
|
```python
|
274
288
|
import fakesnow
|
@@ -280,12 +294,16 @@ with fakesnow.patch():
|
|
280
294
|
print(conn.cursor().execute("SELECT 'Hello fake world!'").fetchone())
|
281
295
|
```
|
282
296
|
|
283
|
-
|
297
|
+
#### What gets patched
|
298
|
+
|
299
|
+
The following standard imports are automatically patched:
|
284
300
|
|
285
301
|
- `import snowflake.connector.connect`
|
286
302
|
- `import snowflake.connector.pandas_tools.write_pandas`
|
287
303
|
|
288
|
-
|
304
|
+
#### Handling "from ... import" Statements
|
305
|
+
|
306
|
+
To patch modules that use the `from ... import` syntax, you need to manually specify them, eg: if _mymodule.py_ contains:
|
289
307
|
|
290
308
|
```python
|
291
309
|
from snowflake.connector.pandas_tools import write_pandas
|
@@ -298,33 +316,77 @@ with fakesnow.patch("mymodule.write_pandas"):
|
|
298
316
|
...
|
299
317
|
```
|
300
318
|
|
301
|
-
|
319
|
+
#### Database Persistence
|
320
|
+
|
321
|
+
By default, databases are in-memory and will be lost when the process ends. To persist databases between processes, specify a databases path:
|
302
322
|
|
303
323
|
```python
|
304
324
|
with fakesnow.patch(db_path="databases/"):
|
305
325
|
...
|
306
326
|
```
|
307
327
|
|
328
|
+
### Run fakesnow as a server
|
329
|
+
|
330
|
+
For scenarios where patching won't work (like subprocesses or non-Python clients), you can run fakesnow as an HTTP server:
|
331
|
+
|
332
|
+
```python
|
333
|
+
import fakesnow
|
334
|
+
import snowflake.connector
|
335
|
+
|
336
|
+
# Start the fakesnow server in a context manager
|
337
|
+
# This yields connection kwargs (host, port, etc.)
|
338
|
+
with fakesnow.server() as conn_kwargs:
|
339
|
+
# Connect to the fakesnow server using the yielded kwargs
|
340
|
+
with snowflake.connector.connect(**conn_kwargs) as conn:
|
341
|
+
print(conn.cursor().execute("SELECT 'Hello fake server!'").fetchone())
|
342
|
+
|
343
|
+
# The server is automatically stopped when exiting the context manager
|
344
|
+
```
|
345
|
+
|
346
|
+
This starts an HTTP server in its own thread listening for requests on localhost on an available random port.
|
347
|
+
The server accepts any username/password combination.
|
348
|
+
|
349
|
+
#### Server Configuration Options
|
350
|
+
|
351
|
+
By default, the server uses a single in-memory database for its lifetime. To configure database persistence or isolation:
|
352
|
+
|
353
|
+
```python
|
354
|
+
# Databases will be saved to the "databases/" directory
|
355
|
+
with fakesnow.server(session_parameters={"FAKESNOW_DB_PATH": "databases/"}):
|
356
|
+
...
|
357
|
+
|
358
|
+
# Each connection gets its own isolated in-memory database
|
359
|
+
with fakesnow.server(session_parameters={"FAKESNOW_DB_PATH": ":isolated:"}):
|
360
|
+
...
|
361
|
+
```
|
362
|
+
|
363
|
+
To specify a port for the server:
|
364
|
+
|
365
|
+
```python
|
366
|
+
with fakesnow.server(port=12345) as conn_kwargs:
|
367
|
+
...
|
368
|
+
```
|
369
|
+
|
308
370
|
### pytest fixtures
|
309
371
|
|
310
|
-
|
372
|
+
fakesnow provides [fixtures](fakesnow/fixtures.py) for easier test integration. Here's an example _conftest.py_ using them:
|
311
373
|
|
312
374
|
```python
|
313
375
|
from typing import Iterator
|
314
376
|
|
315
|
-
import fakesnow.fixtures
|
316
377
|
import pytest
|
317
378
|
|
318
|
-
pytest_plugins = fakesnow.fixtures
|
379
|
+
pytest_plugins = "fakesnow.fixtures"
|
319
380
|
|
320
381
|
@pytest.fixture(scope="session", autouse=True)
|
321
382
|
def setup(_fakesnow_session: None) -> Iterator[None]:
|
322
383
|
# the standard imports are now patched
|
323
|
-
|
384
|
+
# Add any additional setup here
|
324
385
|
yield
|
386
|
+
# Add any teardown here
|
325
387
|
```
|
326
388
|
|
327
|
-
|
389
|
+
For code that uses `from ... import` statements:
|
328
390
|
|
329
391
|
```python
|
330
392
|
from typing import Iterator
|
@@ -338,34 +400,56 @@ def _fakesnow_session() -> Iterator[None]:
|
|
338
400
|
yield
|
339
401
|
```
|
340
402
|
|
403
|
+
To start a fakesnow server instance, use the `fakesnow_server` session fixture:
|
404
|
+
|
405
|
+
```python
|
406
|
+
import snowflake.connector
|
407
|
+
|
408
|
+
def test_with_server(fakesnow_server: dict):
|
409
|
+
# fakesnow_server contains connection kwargs (host, port, etc.)
|
410
|
+
with snowflake.connector.connect(**fakesnow_server) as conn:
|
411
|
+
conn.cursor().execute("SELECT 1")
|
412
|
+
assert conn.cursor().fetchone() == (1,)
|
413
|
+
```
|
414
|
+
|
341
415
|
## Implementation coverage
|
342
416
|
|
343
|
-
|
344
|
-
- [x] [get_result_batches()](https://docs.snowflake.com/en/user-guide/python-connector-api#get_result_batches)
|
345
|
-
- [x] information schema
|
346
|
-
- [x] multiple databases
|
347
|
-
- [x] [parameter binding](https://docs.snowflake.com/en/user-guide/python-connector-example#binding-data)
|
348
|
-
- [x] table comments
|
349
|
-
- [x] [write_pandas(..)](https://docs.snowflake.com/en/user-guide/python-connector-api#write_pandas)
|
350
|
-
- [ ] [access control](https://docs.snowflake.com/en/user-guide/security-access-control-overview)
|
351
|
-
- [ ] standalone/out of process api/support for faking non-python connectors
|
352
|
-
- [ ] [stored procedures](https://docs.snowflake.com/en/sql-reference/stored-procedures)
|
417
|
+
Fully supported:
|
353
418
|
|
354
|
-
|
419
|
+
- Standard SQL operations and cursors
|
420
|
+
- Information schema queries
|
421
|
+
- Multiple databases
|
422
|
+
- [Parameter binding](https://docs.snowflake.com/en/user-guide/python-connector-example#binding-data) in queries
|
423
|
+
- Table comments
|
424
|
+
- Pandas integration including [write_pandas(..)](https://docs.snowflake.com/en/user-guide/python-connector-api#write_pandas) (not available via the server yet)
|
425
|
+
- Result batch retrieval via [get_result_batches()](https://docs.snowflake.com/en/user-guide/python-connector-api#get_result_batches)
|
426
|
+
- HTTP server for non-Python connectors
|
355
427
|
|
356
|
-
|
357
|
-
- [x] regex functions
|
358
|
-
- [x] semi-structured data
|
359
|
-
- [x] tags
|
360
|
-
- [x] user management (See [tests/test_users.py](tests/test_users.py))
|
428
|
+
Partially supported:
|
361
429
|
|
362
|
-
|
430
|
+
- Date functions
|
431
|
+
- Regular expression functions
|
432
|
+
- Semi-structured data operations
|
433
|
+
- Tags
|
434
|
+
- User management
|
435
|
+
- `COPY INTO` from S3 sources, see [COPY INTO](#copy-into)
|
436
|
+
|
437
|
+
Not yet implemented:
|
438
|
+
|
439
|
+
- [Access control](https://docs.snowflake.com/en/user-guide/security-access-control-overview)
|
440
|
+
- [Stored procedures](https://docs.snowflake.com/en/sql-reference/stored-procedures)
|
441
|
+
|
442
|
+
For more detail see the [test suite](tests/).
|
363
443
|
|
364
444
|
## Caveats
|
365
445
|
|
366
|
-
-
|
367
|
-
-
|
446
|
+
- Row ordering is non-deterministic and may differ from Snowflake unless you fully specify the ORDER BY clause.
|
447
|
+
- fakesnow supports a more liberal SQL dialect than actual Snowflake. This means some queries that work with fakesnow might not work with a real Snowflake instance.
|
448
|
+
|
449
|
+
## COPY INTO
|
450
|
+
|
451
|
+
`COPY INTO` can be used from S3 sources. By default the standard AWS credential chain will be used. If you are getting an HTTP 403 or need to provide alternative S3 credentials you can use the duckdb [CREATE SECRET](https://duckdb.org/docs/stable/extensions/httpfs/s3api) statement. For an example of creating a secret to use a moto S3 endpoint see `s3_client` in [conftest.py](tests/conftest.py#L80)
|
368
452
|
|
369
453
|
## Contributing
|
370
454
|
|
371
|
-
See [CONTRIBUTING.md](CONTRIBUTING.md)
|
455
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for instructions on getting started with development and contributing to this project.
|
@@ -1,29 +1,30 @@
|
|
1
|
-
fakesnow/__init__.py,sha256=
|
1
|
+
fakesnow/__init__.py,sha256=It-8mTZWBaVi4suZjL7UJlJBGFhLWmPnI-THX02XRJU,5108
|
2
2
|
fakesnow/__main__.py,sha256=GDrGyNTvBFuqn_UfDjKs7b3LPtU6gDv1KwosVDrukIM,76
|
3
3
|
fakesnow/arrow.py,sha256=XjTpFyLrD9jULWOtPgpr0RyNMmO6a5yi82y6ivi2CCI,4884
|
4
4
|
fakesnow/checks.py,sha256=be-xo0oMoAUVhlMDCu1_Rkoh_L8p_p8qo9P6reJSHIQ,2874
|
5
5
|
fakesnow/cli.py,sha256=9qfI-Ssr6mo8UmIlXkUAOz2z2YPBgDsrEVaZv9FjGFs,2201
|
6
|
-
fakesnow/conn.py,sha256=
|
6
|
+
fakesnow/conn.py,sha256=diCwcjaCBrlCn9PyjbScfIQTNQjqiPTkQanUTqcvblE,6009
|
7
7
|
fakesnow/converter.py,sha256=xoBFnfBbGWQyUQAVr6zi-RyglU8A7A3GSlwLPkH1dzI,1621
|
8
|
-
fakesnow/cursor.py,sha256=
|
8
|
+
fakesnow/cursor.py,sha256=3JCxSoBJ2g6bndIGQnJnTAWu8Ad7zK_6kwmAY_b0VKE,22949
|
9
9
|
fakesnow/expr.py,sha256=CAxuYIUkwI339DQIBzvFF0F-m1tcVGKEPA5rDTzmH9A,892
|
10
10
|
fakesnow/fakes.py,sha256=JQTiUkkwPeQrJ8FDWhPFPK6pGwd_aR2oiOrNzCWznlM,187
|
11
|
-
fakesnow/fixtures.py,sha256=
|
11
|
+
fakesnow/fixtures.py,sha256=2rj0MTZlaZc4PNWhaqC5IiiLa7E9G0QZT3g45YawsL0,633
|
12
12
|
fakesnow/info_schema.py,sha256=AYmTIHxk5Y6xdMTgttgBL1V0VO8qiM2T1-gKwkLmWDs,8720
|
13
13
|
fakesnow/instance.py,sha256=OKoYXwaI6kL9HQpnHx44yzpON_xNfuIT_F4oJNF_XXQ,2114
|
14
14
|
fakesnow/macros.py,sha256=pX1YJDnQOkFJSHYUjQ6ErEkYIKvFI6Ncz_au0vv1csA,265
|
15
15
|
fakesnow/pandas_tools.py,sha256=wI203UQHC8JvDzxE_VjE1NeV4rThek2P-u52oTg2foo,3481
|
16
16
|
fakesnow/py.typed,sha256=B-DLSjYBi7pkKjwxCSdpVj2J02wgfJr-E7B1wOUyxYU,80
|
17
17
|
fakesnow/rowtype.py,sha256=QUp8EaXD5LT0Xv8BXk5ze4WseEn52xoJ6R05pJjs5mM,2729
|
18
|
-
fakesnow/server.py,sha256=
|
18
|
+
fakesnow/server.py,sha256=RHf7ffKYi5xBH9fh8wZr3tEPmnzFWuvUbziCC8UwTh4,6652
|
19
19
|
fakesnow/variables.py,sha256=C3y_9u7LuVtARkpcim3ihgVWg6KKdz1hSVeW4YI7oL4,3014
|
20
|
-
fakesnow/transforms/__init__.py,sha256=
|
20
|
+
fakesnow/transforms/__init__.py,sha256=RcVGkp95yKByluQ5O6RALJTiRlox8FK4pMl1rt_gJPc,49536
|
21
|
+
fakesnow/transforms/copy_into.py,sha256=QJ1hh3hVi9kPJgyQHlGO3Vi8sP3qnWmvY4JWO--HWl0,5565
|
21
22
|
fakesnow/transforms/merge.py,sha256=Pg7_rwbAT_vr1U4ocBofUSyqaK8_e3qdIz_2SDm2S3s,8320
|
22
23
|
fakesnow/transforms/show.py,sha256=0NjuLQjodrukfUw8mcxcAmtBkV_6r02mA3nuE3ad3rE,17458
|
23
|
-
fakesnow-0.9.
|
24
|
+
fakesnow-0.9.36.dist-info/licenses/LICENSE,sha256=kW-7NWIyaRMQiDpryfSmF2DObDZHGR1cJZ39s6B1Svg,11344
|
24
25
|
tools/decode.py,sha256=kC5kUvLQxdCkMRsnH6BqCajlKxKeN77w6rwCKsY6gqU,1781
|
25
|
-
fakesnow-0.9.
|
26
|
-
fakesnow-0.9.
|
27
|
-
fakesnow-0.9.
|
28
|
-
fakesnow-0.9.
|
29
|
-
fakesnow-0.9.
|
26
|
+
fakesnow-0.9.36.dist-info/METADATA,sha256=Nu1iV0SOnWCNeQCVJXbwC2PVxx7mkdQ6YDfDVkfTmI4,21160
|
27
|
+
fakesnow-0.9.36.dist-info/WHEEL,sha256=lTU6B6eIfYoiQJTZNc-fyaR6BpL6ehTzU3xGYxn2n8k,91
|
28
|
+
fakesnow-0.9.36.dist-info/entry_points.txt,sha256=2riAUgu928ZIHawtO8EsfrMEJhi-EH-z_Vq7Q44xKPM,47
|
29
|
+
fakesnow-0.9.36.dist-info/top_level.txt,sha256=Yos7YveA3f03xVYuURqnBsfMV2DePXfu_yGcsj3pPzI,30
|
30
|
+
fakesnow-0.9.36.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|