snowflake-sql-api 0.1.0__tar.gz → 0.1.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 (40) hide show
  1. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/PKG-INFO +2 -2
  2. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/pyproject.toml +4 -4
  3. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/snowflake_sql_api/_version.py +2 -2
  4. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/snowflake_sql_api/cli.py +1 -1
  5. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/snowflake_sql_api/testing.py +6 -2
  6. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/tests/conftest.py +6 -0
  7. snowflake_sql_api-0.1.1/tests/test_cli.py +143 -0
  8. snowflake_sql_api-0.1.1/tests/test_import_footprint.py +108 -0
  9. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/tests/test_testing.py +2 -0
  10. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/.gitignore +0 -0
  11. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/CONTRIBUTING.md +0 -0
  12. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/LICENSE +0 -0
  13. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/README.md +0 -0
  14. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/snowflake_sql_api/__init__.py +0 -0
  15. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/snowflake_sql_api/aclient.py +0 -0
  16. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/snowflake_sql_api/auth.py +0 -0
  17. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/snowflake_sql_api/bindings.py +0 -0
  18. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/snowflake_sql_api/client.py +0 -0
  19. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/snowflake_sql_api/escaping.py +0 -0
  20. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/snowflake_sql_api/exceptions.py +0 -0
  21. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/snowflake_sql_api/pagination.py +0 -0
  22. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/snowflake_sql_api/py.typed +0 -0
  23. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/snowflake_sql_api/retry.py +0 -0
  24. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/snowflake_sql_api/row_mapping.py +0 -0
  25. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/snowflake_sql_api/transport.py +0 -0
  26. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/snowflake_sql_api/types.py +0 -0
  27. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/tests/__init__.py +0 -0
  28. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/tests/smoke/__init__.py +0 -0
  29. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/tests/smoke/test_smoke.py +0 -0
  30. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/tests/support.py +0 -0
  31. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/tests/test_aclient.py +0 -0
  32. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/tests/test_auth.py +0 -0
  33. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/tests/test_bindings.py +0 -0
  34. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/tests/test_client.py +0 -0
  35. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/tests/test_escaping.py +0 -0
  36. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/tests/test_package.py +0 -0
  37. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/tests/test_regression.py +0 -0
  38. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/tests/test_retry.py +0 -0
  39. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/tests/test_transport.py +0 -0
  40. {snowflake_sql_api-0.1.0 → snowflake_sql_api-0.1.1}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: snowflake-sql-api
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Lightweight, pure-Python client for Snowflake's SQL API v2
5
5
  Project-URL: Homepage, https://github.com/hampsterx/snowflake-sql-api
6
6
  Project-URL: Repository, https://github.com/hampsterx/snowflake-sql-api
@@ -47,7 +47,7 @@ Requires-Dist: cryptography>=3.4
47
47
  Requires-Dist: httpx>=0.24
48
48
  Requires-Dist: pyjwt>=2.4
49
49
  Provides-Extra: dev
50
- Requires-Dist: black<25.10,>=24.0; extra == 'dev'
50
+ Requires-Dist: black<25.12,>=24.0; extra == 'dev'
51
51
  Requires-Dist: build>=1.0; extra == 'dev'
52
52
  Requires-Dist: coverage[toml]>=7.0; extra == 'dev'
53
53
  Requires-Dist: mypy>=1.8; extra == 'dev'
@@ -43,7 +43,7 @@ dev = [
43
43
  "ruff>=0.4",
44
44
  # Capped below 25.10: 25.12.0+ drop Python 3.9, which the package still
45
45
  # supports. Keeps CI and the pinned pre-commit hook on the same black.
46
- "black>=24.0,<25.10",
46
+ "black>=24.0,<25.12",
47
47
  "mypy>=1.8",
48
48
  "build>=1.0",
49
49
  "pre-commit>=3.5",
@@ -134,9 +134,9 @@ markers = [
134
134
  [tool.coverage.run]
135
135
  source = ["snowflake_sql_api"]
136
136
  branch = true
137
- # cli.py is exercised end-to-end by the smoke suite; row_mapping.py is a Phase 8
138
- # stub. Both are out of the Phase 2 core-coverage scope.
139
- omit = ["snowflake_sql_api/cli.py", "snowflake_sql_api/row_mapping.py"]
137
+ # row_mapping.py is a Phase 8 stub, out of the Phase 2 core-coverage scope.
138
+ # cli.py is covered by tests/test_cli.py.
139
+ omit = ["snowflake_sql_api/row_mapping.py"]
140
140
 
141
141
  [tool.coverage.report]
142
142
  show_missing = true
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.1.0'
22
- __version_tuple__ = version_tuple = (0, 1, 0)
21
+ __version__ = version = '0.1.1'
22
+ __version_tuple__ = version_tuple = (0, 1, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -68,7 +68,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
68
68
  return 2
69
69
  if args.command == "query":
70
70
  return _run_query(args.sql)
71
- return 0
71
+ return 0 # pragma: no cover - argparse only yields None or a known subcommand
72
72
 
73
73
 
74
74
  if __name__ == "__main__": # pragma: no cover
@@ -599,7 +599,7 @@ def make_client(
599
599
  from .client import SnowflakeClient
600
600
 
601
601
  _reject_managed_kwargs("make_client", kwargs)
602
- return SnowflakeClient(
602
+ client = SnowflakeClient(
603
603
  account,
604
604
  user,
605
605
  private_key=_throwaway_key(),
@@ -607,6 +607,8 @@ def make_client(
607
607
  poll_interval=poll_interval,
608
608
  **kwargs,
609
609
  )
610
+ client._transport._owns_client = True
611
+ return client
610
612
 
611
613
 
612
614
  def make_async_client(
@@ -621,7 +623,7 @@ def make_async_client(
621
623
  from .aclient import AsyncSnowflakeClient
622
624
 
623
625
  _reject_managed_kwargs("make_async_client", kwargs)
624
- return AsyncSnowflakeClient(
626
+ client = AsyncSnowflakeClient(
625
627
  account,
626
628
  user,
627
629
  private_key=_throwaway_key(),
@@ -629,6 +631,8 @@ def make_async_client(
629
631
  poll_interval=poll_interval,
630
632
  **kwargs,
631
633
  )
634
+ client._transport._owns_client = True
635
+ return client
632
636
 
633
637
 
634
638
  # ---------------------------------------------------------------------------
@@ -25,6 +25,12 @@ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
25
25
  PASSPHRASE = b"correct horse battery staple"
26
26
 
27
27
 
28
+ def pytest_configure(config: pytest.Config) -> None:
29
+ """Load shipped testing fixtures when pytest11 entry points are unavailable."""
30
+ if not config.pluginmanager.hasplugin("snowflake_sql_api"):
31
+ config.pluginmanager.import_plugin("snowflake_sql_api.testing")
32
+
33
+
28
34
  @pytest.fixture
29
35
  def fake_account() -> str:
30
36
  """A region-suffixed account locator for auth-normalization tests."""
@@ -0,0 +1,143 @@
1
+ """Unit tests for the ``snowflake-sql-api`` CLI (``cli.py``).
2
+
3
+ Drive ``build_parser()`` / ``main()`` with ``argv`` injection and a stubbed
4
+ client (``from_env`` monkeypatched to a :class:`FakeSnowflake`-backed client), so
5
+ no network and no real account. Covers the exit-code contract documented in
6
+ ``docs/cli.md`` plus the ``_json_default`` serializer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from datetime import date, datetime, time, timezone
13
+ from decimal import Decimal
14
+
15
+ import pytest
16
+
17
+ from snowflake_sql_api import cli
18
+ from snowflake_sql_api.client import SnowflakeClient
19
+ from snowflake_sql_api.testing import FakeSnowflake, make_client
20
+
21
+
22
+ @pytest.fixture
23
+ def stub_from_env(monkeypatch: pytest.MonkeyPatch) -> FakeSnowflake:
24
+ """Point ``SnowflakeClient.from_env`` at a fresh FakeSnowflake-backed client.
25
+
26
+ Returns the fake so a test can register results before invoking the CLI.
27
+ """
28
+ fake = FakeSnowflake()
29
+
30
+ def _from_env() -> SnowflakeClient:
31
+ return make_client(fake)
32
+
33
+ monkeypatch.setattr(SnowflakeClient, "from_env", staticmethod(_from_env))
34
+ return fake
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # build_parser / argv plumbing
39
+ # ---------------------------------------------------------------------------
40
+
41
+
42
+ def test_build_parser_parses_query() -> None:
43
+ args = cli.build_parser().parse_args(["query", "SELECT 1"])
44
+ assert args.command == "query"
45
+ assert args.sql == "SELECT 1"
46
+
47
+
48
+ def test_version(capsys: pytest.CaptureFixture[str]) -> None:
49
+ # argparse's `version` action prints then raises SystemExit(0).
50
+ from snowflake_sql_api import __version__
51
+
52
+ with pytest.raises(SystemExit) as excinfo:
53
+ cli.main(["--version"])
54
+ assert excinfo.value.code == 0
55
+ assert __version__ in capsys.readouterr().out
56
+
57
+
58
+ def test_no_subcommand_prints_help_and_exits_2(
59
+ capsys: pytest.CaptureFixture[str],
60
+ ) -> None:
61
+ assert cli.main([]) == 2
62
+ out = capsys.readouterr().out
63
+ assert "usage:" in out
64
+ assert "query" in out
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # query command (stubbed client)
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ def test_query_happy_path_prints_json(
73
+ stub_from_env: FakeSnowflake, capsys: pytest.CaptureFixture[str]
74
+ ) -> None:
75
+ stub_from_env.register(
76
+ "SELECT id, name FROM users",
77
+ [{"ID": 1, "NAME": "alice"}, {"ID": 2, "NAME": "bob"}],
78
+ )
79
+ assert cli.main(["query", "SELECT id, name FROM users"]) == 0
80
+ out = capsys.readouterr().out
81
+ assert json.loads(out) == [
82
+ {"ID": 1, "NAME": "alice"},
83
+ {"ID": 2, "NAME": "bob"},
84
+ ]
85
+
86
+
87
+ def test_query_serializes_non_json_native_values(
88
+ stub_from_env: FakeSnowflake, capsys: pytest.CaptureFixture[str]
89
+ ) -> None:
90
+ # Decimal/timestamp/binary travel through _json_default on the way to stdout.
91
+ stub_from_env.register(
92
+ "SELECT amount, ts, blob",
93
+ [
94
+ {
95
+ "AMOUNT": Decimal("10.50"),
96
+ "TS": datetime(2024, 1, 2, 3, 4, 5, tzinfo=timezone.utc),
97
+ "BLOB": b"\x00\xff",
98
+ }
99
+ ],
100
+ )
101
+ assert cli.main(["query", "SELECT amount, ts, blob"]) == 0
102
+ row = json.loads(capsys.readouterr().out)[0]
103
+ assert row["AMOUNT"] == "10.50"
104
+ assert row["TS"].startswith("2024-01-02T03:04:05")
105
+ assert row["BLOB"] == "00ff"
106
+
107
+
108
+ def test_query_snowflake_error_exits_1(
109
+ stub_from_env: FakeSnowflake, capsys: pytest.CaptureFixture[str]
110
+ ) -> None:
111
+ stub_from_env.register_error(
112
+ "SELECT * FROM nope", "Object 'NOPE' does not exist", code="002003"
113
+ )
114
+ assert cli.main(["query", "SELECT * FROM nope"]) == 1
115
+ captured = capsys.readouterr()
116
+ assert captured.out == ""
117
+ assert captured.err.startswith("error: ")
118
+ assert "does not exist" in captured.err
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # _json_default serializer (direct unit coverage, incl. the TypeError branch)
123
+ # ---------------------------------------------------------------------------
124
+
125
+
126
+ @pytest.mark.parametrize(
127
+ ("value", "expected"),
128
+ [
129
+ (Decimal("1.25"), "1.25"),
130
+ (datetime(2024, 6, 1, 12, 30, 0), "2024-06-01T12:30:00"),
131
+ (date(2024, 6, 1), "2024-06-01"),
132
+ (time(9, 15, 30), "09:15:30"),
133
+ (b"\xde\xad", "dead"),
134
+ (bytearray(b"\xbe\xef"), "beef"),
135
+ ],
136
+ )
137
+ def test_json_default_serializes(value: object, expected: str) -> None:
138
+ assert cli._json_default(value) == expected
139
+
140
+
141
+ def test_json_default_rejects_unsupported() -> None:
142
+ with pytest.raises(TypeError, match="cannot serialize"):
143
+ cli._json_default(object())
@@ -0,0 +1,108 @@
1
+ """Guard the install footprint: a bare ``import snowflake_sql_api`` stays light.
2
+
3
+ The package's whole value proposition is a small install and a fast cold start
4
+ (serverless / Lambda). Two regressions would silently erode that:
5
+
6
+ 1. A heavy optional dependency (pandas / pyarrow) getting imported at module top
7
+ instead of behind its extra. The ``[pandas]`` feature must stay opt-in.
8
+ 2. Import time ballooning for any reason.
9
+
10
+ Both checks run in a **fresh subprocess** so the parent pytest process's
11
+ already-imported modules can't mask a regression (pandas may well be imported by
12
+ the time these tests run). The leak check installs a recording meta-path finder
13
+ that stubs the forbidden names, so it fires deterministically whether or not the
14
+ real packages are installed: a module-top ``import pandas`` is caught even when
15
+ it is guarded by ``try/except ImportError`` and even in an env without pandas.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import subprocess
21
+ import sys
22
+
23
+ # Heavy optional deps that a bare import must never pull in. pandas is the
24
+ # ``[pandas]`` extra; pyarrow/numpy ride in with it. If any is imported at module
25
+ # top, something bypassed the optional-import guard.
26
+ FORBIDDEN_ON_BARE_IMPORT = ("pandas", "pyarrow", "numpy")
27
+
28
+ # Generous ceiling: the real cold import (httpx + PyJWT + cryptography, all pure
29
+ # Python or fast C imports) is well under a second. This is a coarse backstop
30
+ # against an order-of-magnitude regression, not a precise benchmark, so the bound
31
+ # is deliberately loose and we assert on the best of several samples to stay
32
+ # non-flaky on shared CI runners.
33
+ IMPORT_BUDGET_SECONDS = 2.5
34
+ TIMING_SAMPLES = 3
35
+
36
+ # Subprocess: record any *attempt* to import a forbidden module during a bare
37
+ # `import snowflake_sql_api`. The recorder returns a stub module for forbidden
38
+ # names, so the import succeeds (and is recorded) regardless of whether the real
39
+ # dependency is installed -- making the guard deterministic across environments.
40
+ _LEAK_SCRIPT = """
41
+ import importlib.abc
42
+ import importlib.machinery
43
+ import sys
44
+ import types
45
+
46
+ FORBIDDEN = {forbidden!r}
47
+ attempted = []
48
+
49
+
50
+ class _Recorder(importlib.abc.MetaPathFinder, importlib.abc.Loader):
51
+ def find_spec(self, name, path, target=None):
52
+ if name.split(".")[0] in FORBIDDEN:
53
+ attempted.append(name.split(".")[0])
54
+ return importlib.machinery.ModuleSpec(name, self)
55
+ return None
56
+
57
+ def create_module(self, spec):
58
+ return types.ModuleType(spec.name)
59
+
60
+ def exec_module(self, module):
61
+ pass
62
+
63
+
64
+ sys.meta_path.insert(0, _Recorder())
65
+ import snowflake_sql_api # noqa: F401
66
+ print(",".join(sorted(set(attempted))))
67
+ """.format(
68
+ forbidden=FORBIDDEN_ON_BARE_IMPORT
69
+ )
70
+
71
+ _TIMING_SCRIPT = """
72
+ import time
73
+ start = time.perf_counter()
74
+ import snowflake_sql_api # noqa: F401
75
+ print(time.perf_counter() - start)
76
+ """
77
+
78
+
79
+ def _run(script: str) -> subprocess.CompletedProcess:
80
+ return subprocess.run(
81
+ [sys.executable, "-c", script], capture_output=True, text=True
82
+ )
83
+
84
+
85
+ def test_bare_import_pulls_no_heavy_deps() -> None:
86
+ result = _run(_LEAK_SCRIPT)
87
+ assert (
88
+ result.returncode == 0
89
+ ), f"`import snowflake_sql_api` failed in a clean subprocess:\n{result.stderr}"
90
+ attempted = [name for name in result.stdout.strip().split(",") if name]
91
+ assert not attempted, (
92
+ f"bare `import snowflake_sql_api` tried to import heavy deps: {attempted}. "
93
+ "Keep optional dependencies behind their extra (lazy import)."
94
+ )
95
+
96
+
97
+ def test_bare_import_is_fast() -> None:
98
+ samples = []
99
+ for _ in range(TIMING_SAMPLES):
100
+ result = _run(_TIMING_SCRIPT)
101
+ assert result.returncode == 0, result.stderr
102
+ samples.append(float(result.stdout.strip()))
103
+ best = min(samples)
104
+ assert best < IMPORT_BUDGET_SECONDS, (
105
+ f"fastest of {TIMING_SAMPLES} imports was {best:.3f}s, over the "
106
+ f"{IMPORT_BUDGET_SECONDS}s budget. A heavy import likely crept into the "
107
+ "module-load path."
108
+ )
@@ -46,6 +46,7 @@ def test_make_client_returns_real_client() -> None:
46
46
  assert isinstance(client, SnowflakeClient)
47
47
  assert client.query_scalar("SELECT 1") == 1
48
48
  client.close()
49
+ assert client._transport._client.is_closed
49
50
 
50
51
 
51
52
  def test_make_client_rejects_managed_kwargs() -> None:
@@ -304,6 +305,7 @@ async def test_async_query() -> None:
304
305
  assert isinstance(client, AsyncSnowflakeClient)
305
306
  assert await client.query("SELECT id FROM t") == [{"ID": 1}, {"ID": 2}]
306
307
  await client.aclose()
308
+ assert client._transport._client.is_closed
307
309
 
308
310
 
309
311
  async def test_async_submit_and_result() -> None: