iceaxe 0.7.1__cp313-cp313-macosx_11_0_arm64.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.

Potentially problematic release.


This version of iceaxe might be problematic. Click here for more details.

Files changed (75) hide show
  1. iceaxe/__init__.py +20 -0
  2. iceaxe/__tests__/__init__.py +0 -0
  3. iceaxe/__tests__/benchmarks/__init__.py +0 -0
  4. iceaxe/__tests__/benchmarks/test_bulk_insert.py +45 -0
  5. iceaxe/__tests__/benchmarks/test_select.py +114 -0
  6. iceaxe/__tests__/conf_models.py +133 -0
  7. iceaxe/__tests__/conftest.py +204 -0
  8. iceaxe/__tests__/docker_helpers.py +208 -0
  9. iceaxe/__tests__/helpers.py +268 -0
  10. iceaxe/__tests__/migrations/__init__.py +0 -0
  11. iceaxe/__tests__/migrations/conftest.py +36 -0
  12. iceaxe/__tests__/migrations/test_action_sorter.py +237 -0
  13. iceaxe/__tests__/migrations/test_generator.py +140 -0
  14. iceaxe/__tests__/migrations/test_generics.py +91 -0
  15. iceaxe/__tests__/mountaineer/__init__.py +0 -0
  16. iceaxe/__tests__/mountaineer/dependencies/__init__.py +0 -0
  17. iceaxe/__tests__/mountaineer/dependencies/test_core.py +76 -0
  18. iceaxe/__tests__/schemas/__init__.py +0 -0
  19. iceaxe/__tests__/schemas/test_actions.py +1264 -0
  20. iceaxe/__tests__/schemas/test_cli.py +25 -0
  21. iceaxe/__tests__/schemas/test_db_memory_serializer.py +1525 -0
  22. iceaxe/__tests__/schemas/test_db_serializer.py +398 -0
  23. iceaxe/__tests__/schemas/test_db_stubs.py +190 -0
  24. iceaxe/__tests__/test_alias.py +83 -0
  25. iceaxe/__tests__/test_base.py +52 -0
  26. iceaxe/__tests__/test_comparison.py +383 -0
  27. iceaxe/__tests__/test_field.py +11 -0
  28. iceaxe/__tests__/test_helpers.py +9 -0
  29. iceaxe/__tests__/test_modifications.py +151 -0
  30. iceaxe/__tests__/test_queries.py +605 -0
  31. iceaxe/__tests__/test_queries_str.py +173 -0
  32. iceaxe/__tests__/test_session.py +1511 -0
  33. iceaxe/__tests__/test_text_search.py +287 -0
  34. iceaxe/alias_values.py +67 -0
  35. iceaxe/base.py +350 -0
  36. iceaxe/comparison.py +560 -0
  37. iceaxe/field.py +250 -0
  38. iceaxe/functions.py +906 -0
  39. iceaxe/generics.py +140 -0
  40. iceaxe/io.py +107 -0
  41. iceaxe/logging.py +91 -0
  42. iceaxe/migrations/__init__.py +5 -0
  43. iceaxe/migrations/action_sorter.py +98 -0
  44. iceaxe/migrations/cli.py +228 -0
  45. iceaxe/migrations/client_io.py +62 -0
  46. iceaxe/migrations/generator.py +404 -0
  47. iceaxe/migrations/migration.py +86 -0
  48. iceaxe/migrations/migrator.py +101 -0
  49. iceaxe/modifications.py +176 -0
  50. iceaxe/mountaineer/__init__.py +10 -0
  51. iceaxe/mountaineer/cli.py +74 -0
  52. iceaxe/mountaineer/config.py +46 -0
  53. iceaxe/mountaineer/dependencies/__init__.py +6 -0
  54. iceaxe/mountaineer/dependencies/core.py +67 -0
  55. iceaxe/postgres.py +133 -0
  56. iceaxe/py.typed +0 -0
  57. iceaxe/queries.py +1455 -0
  58. iceaxe/queries_str.py +294 -0
  59. iceaxe/schemas/__init__.py +0 -0
  60. iceaxe/schemas/actions.py +864 -0
  61. iceaxe/schemas/cli.py +30 -0
  62. iceaxe/schemas/db_memory_serializer.py +705 -0
  63. iceaxe/schemas/db_serializer.py +346 -0
  64. iceaxe/schemas/db_stubs.py +525 -0
  65. iceaxe/session.py +860 -0
  66. iceaxe/session_optimized.c +12035 -0
  67. iceaxe/session_optimized.cpython-313-darwin.so +0 -0
  68. iceaxe/session_optimized.pyx +212 -0
  69. iceaxe/sql_types.py +148 -0
  70. iceaxe/typing.py +73 -0
  71. iceaxe-0.7.1.dist-info/METADATA +261 -0
  72. iceaxe-0.7.1.dist-info/RECORD +75 -0
  73. iceaxe-0.7.1.dist-info/WHEEL +6 -0
  74. iceaxe-0.7.1.dist-info/licenses/LICENSE +21 -0
  75. iceaxe-0.7.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,208 @@
1
+ """
2
+ Docker helper utilities for testing.
3
+
4
+ This module provides classes and functions to manage Docker containers for testing,
5
+ particularly focusing on PostgreSQL database containers.
6
+ """
7
+
8
+ import logging
9
+ import socket
10
+ import time
11
+ import uuid
12
+ from typing import Any, Dict, Optional, cast
13
+
14
+ import docker
15
+ from docker.errors import APIError
16
+
17
+ # Configure logging
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def get_free_port() -> int:
22
+ """Find a free port on the host machine."""
23
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
24
+ s.bind(("", 0))
25
+ return s.getsockname()[1]
26
+
27
+
28
+ class PostgresContainer:
29
+ """
30
+ A class that manages a PostgreSQL Docker container for testing.
31
+
32
+ This class handles the lifecycle of a PostgreSQL container, including:
33
+ - Starting the container with appropriate configuration
34
+ - Finding available ports
35
+ - Waiting for the container to be ready
36
+ - Providing connection information
37
+ - Cleaning up after tests
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ pg_user: str = "iceaxe",
43
+ pg_password: str = "mysecretpassword",
44
+ pg_db: str = "iceaxe_test_db",
45
+ postgres_version: str = "16",
46
+ ):
47
+ self.pg_user = pg_user
48
+ self.pg_password = pg_password
49
+ self.pg_db = pg_db
50
+ self.postgres_version = postgres_version
51
+ self.port = get_free_port()
52
+ self.container: Optional[Any] = None
53
+ self.client = docker.from_env()
54
+ self.container_name = f"iceaxe-postgres-test-{uuid.uuid4().hex[:8]}"
55
+
56
+ def start(self) -> Dict[str, Any]:
57
+ """
58
+ Start the PostgreSQL container.
59
+
60
+ Returns:
61
+ Dict[str, Any]: Connection information for the PostgreSQL container
62
+
63
+ Raises:
64
+ RuntimeError: If the container fails to start or become ready
65
+ """
66
+ logger.info(f"Starting PostgreSQL container on port {self.port}")
67
+
68
+ max_attempts = 3
69
+ attempt = 0
70
+
71
+ while attempt < max_attempts:
72
+ attempt += 1
73
+ try:
74
+ self.container = self._run_container(self.port)
75
+ break
76
+ except APIError as e:
77
+ if "port is already allocated" in str(e) and attempt < max_attempts:
78
+ logger.warning(
79
+ f"Port {self.port} is still in use. Trying with a new port (attempt {attempt}/{max_attempts})."
80
+ )
81
+ self.port = get_free_port()
82
+ else:
83
+ raise RuntimeError(f"Failed to start PostgreSQL container: {e}")
84
+
85
+ # Wait for PostgreSQL to be ready
86
+ if not self._wait_for_container_ready():
87
+ self.stop()
88
+ raise RuntimeError("Failed to connect to PostgreSQL container")
89
+
90
+ return self.get_connection_info()
91
+
92
+ def _run_container(
93
+ self, port: int
94
+ ) -> Any: # Type as Any since docker.models.containers.Container isn't imported
95
+ """
96
+ Run the Docker container with the specified port.
97
+
98
+ Args:
99
+ port: The port to map PostgreSQL to on the host
100
+
101
+ Returns:
102
+ The Docker container object
103
+ """
104
+ return self.client.containers.run(
105
+ f"postgres:{self.postgres_version}",
106
+ name=self.container_name,
107
+ detach=True,
108
+ environment={
109
+ "POSTGRES_USER": self.pg_user,
110
+ "POSTGRES_PASSWORD": self.pg_password,
111
+ "POSTGRES_DB": self.pg_db,
112
+ # Additional settings for faster startup in testing
113
+ "POSTGRES_HOST_AUTH_METHOD": "trust",
114
+ },
115
+ ports={"5432/tcp": port},
116
+ remove=True, # Auto-remove container when stopped
117
+ )
118
+
119
+ def _wait_for_container_ready(self) -> bool:
120
+ """
121
+ Wait for the PostgreSQL container to be ready.
122
+
123
+ Returns:
124
+ bool: True if the container is ready, False otherwise
125
+ """
126
+ max_retries = 30
127
+ retry_interval = 1
128
+
129
+ for i in range(max_retries):
130
+ try:
131
+ if self.container is None:
132
+ logger.warning("Container is None, cannot proceed")
133
+ return False
134
+
135
+ # We've already checked that self.container is not None
136
+ container = cast(Any, self.container)
137
+ container.reload() # Refresh container status
138
+ if container.status != "running":
139
+ logger.warning(f"Container status: {container.status}")
140
+ return False
141
+
142
+ # Try to connect to PostgreSQL
143
+ conn = socket.create_connection(("localhost", self.port), timeout=1)
144
+ conn.close()
145
+ # Wait a bit more to ensure PostgreSQL is fully initialized
146
+ time.sleep(2)
147
+ logger.info(f"PostgreSQL container is ready after {i + 1} attempt(s)")
148
+ return True
149
+ except (socket.error, ConnectionRefusedError) as e:
150
+ if i == max_retries - 1:
151
+ logger.warning(
152
+ f"Failed to connect after {max_retries} attempts: {e}"
153
+ )
154
+ return False
155
+ time.sleep(retry_interval)
156
+ except Exception as e:
157
+ logger.warning(f"Unexpected error checking container readiness: {e}")
158
+ if i == max_retries - 1:
159
+ return False
160
+ time.sleep(retry_interval)
161
+
162
+ return False
163
+
164
+ def stop(self) -> None:
165
+ """
166
+ Stop the PostgreSQL container.
167
+
168
+ This method ensures the container is properly stopped and removed.
169
+ """
170
+ if self.container is not None:
171
+ try:
172
+ logger.info(f"Stopping PostgreSQL container {self.container_name}")
173
+ # We've already checked that self.container is not None
174
+ container = cast(Any, self.container)
175
+ container.stop(timeout=10) # Allow 10 seconds for graceful shutdown
176
+ except Exception as e:
177
+ logger.warning(f"Failed to stop container: {e}")
178
+ try:
179
+ # Force remove as a fallback
180
+ if self.container is not None:
181
+ self.container.remove(force=True)
182
+ logger.info("Forced container removal")
183
+ except Exception as e2:
184
+ logger.warning(f"Failed to force remove container: {e2}")
185
+
186
+ def get_connection_info(self) -> Dict[str, Any]:
187
+ """
188
+ Get the connection information for the PostgreSQL container.
189
+
190
+ Returns:
191
+ Dict[str, Any]: A dictionary containing connection parameters
192
+ """
193
+ return {
194
+ "host": "localhost",
195
+ "port": self.port,
196
+ "user": self.pg_user,
197
+ "password": self.pg_password,
198
+ "database": self.pg_db,
199
+ }
200
+
201
+ def get_connection_string(self) -> str:
202
+ """
203
+ Get a PostgreSQL connection string.
204
+
205
+ Returns:
206
+ str: A connection string in the format 'postgresql://user:password@host:port/database'
207
+ """
208
+ return f"postgresql://{self.pg_user}:{self.pg_password}@localhost:{self.port}/{self.pg_db}"
@@ -0,0 +1,268 @@
1
+ import ast
2
+ import inspect
3
+ import os
4
+ from contextlib import contextmanager
5
+ from dataclasses import dataclass
6
+ from json import JSONDecodeError, dump as json_dump, loads as json_loads
7
+ from re import Pattern
8
+ from tempfile import NamedTemporaryFile, TemporaryDirectory
9
+ from textwrap import dedent
10
+
11
+ from pyright import run
12
+
13
+
14
+ @dataclass
15
+ class PyrightDiagnostic:
16
+ file: str
17
+ severity: str
18
+ message: str
19
+ rule: str | None
20
+ line: int
21
+ column: int
22
+
23
+
24
+ class ExpectedPyrightError(Exception):
25
+ """
26
+ Exception raised when Pyright doesn't produce the expected error
27
+
28
+ """
29
+
30
+ pass
31
+
32
+
33
+ def get_imports_from_module(module_source: str) -> set[str]:
34
+ """
35
+ Extract all import statements from module source
36
+
37
+ """
38
+ tree = ast.parse(module_source)
39
+ imports: set[str] = set()
40
+
41
+ for node in ast.walk(tree):
42
+ if isinstance(node, ast.Import):
43
+ for name in node.names:
44
+ imports.add(f"import {name.name}")
45
+ elif isinstance(node, ast.ImportFrom):
46
+ names = ", ".join(name.name for name in node.names)
47
+ if node.module is None:
48
+ # Handle "from . import x" case
49
+ imports.add(f"from . import {names}")
50
+ else:
51
+ imports.add(f"from {node.module} import {names}")
52
+
53
+ return imports
54
+
55
+
56
+ def strip_type_ignore(line: str) -> str:
57
+ """
58
+ Strip type: ignore comments from a line while preserving the line content
59
+
60
+ """
61
+ if "#" not in line:
62
+ return line
63
+
64
+ # Split only on the first #
65
+ code_part, *comment_parts = line.split("#", 1)
66
+ if not comment_parts:
67
+ return line
68
+
69
+ comment = comment_parts[0]
70
+ # If this is a type: ignore comment, return just the code
71
+ if "type:" in comment and "ignore" in comment:
72
+ return code_part.rstrip()
73
+
74
+ # Otherwise return the full line
75
+ return line
76
+
77
+
78
+ def extract_current_function_code():
79
+ """
80
+ Extracts the source code of the function calling this utility,
81
+ along with any necessary imports at the module level. This only works for
82
+ functions in a pytest testing context that are prefixed with `test_`.
83
+
84
+ """
85
+ # Get the frame of the calling function
86
+ frame = inspect.currentframe()
87
+
88
+ try:
89
+ # Go up until we find the test function; workaround to not
90
+ # knowing the entrypoint of our contextmanager at runtime
91
+ while frame is not None:
92
+ func_name = frame.f_code.co_name
93
+ if func_name.startswith("test_"):
94
+ test_frame = frame
95
+ break
96
+ frame = frame.f_back
97
+ else:
98
+ raise RuntimeError("Could not find test function frame")
99
+
100
+ # Source code of the function
101
+ func_source = inspect.getsource(test_frame.f_code)
102
+
103
+ # Source code of the larger test file, which contains the test function
104
+ # All the imports used by the test function should be within this file
105
+ module = inspect.getmodule(test_frame)
106
+ if not module:
107
+ raise RuntimeError("Could not find module for test function")
108
+
109
+ module_source = inspect.getsource(module)
110
+
111
+ # Postprocess the source code to build into a valid new module
112
+ imports = get_imports_from_module(module_source)
113
+ filtered_lines = [strip_type_ignore(line) for line in func_source.split("\n")]
114
+ return "\n".join(sorted(imports)) + "\n\n" + dedent("\n".join(filtered_lines))
115
+
116
+ finally:
117
+ del frame # Avoid reference cycles
118
+
119
+
120
+ def create_pyright_config():
121
+ """
122
+ Creates a new pyright configuration that ignores unused imports or other
123
+ issues that are not related to context-manager wrapped type checking.
124
+
125
+ """
126
+ return {
127
+ "include": ["."],
128
+ "exclude": [],
129
+ "ignore": [],
130
+ "strict": [],
131
+ "typeCheckingMode": "strict",
132
+ "reportUnusedImport": False,
133
+ "reportUnusedVariable": False,
134
+ # Focus only on type checking
135
+ "reportOptionalMemberAccess": True,
136
+ "reportGeneralTypeIssues": True,
137
+ "reportPropertyTypeMismatch": True,
138
+ "reportFunctionMemberAccess": True,
139
+ "reportTypeCommentUsage": True,
140
+ "reportMissingTypeStubs": False,
141
+ # Only typehint intentional typehints, not inferred values
142
+ "reportUnknownParameterType": False,
143
+ "reportUnknownVariableType": False,
144
+ "reportUnknownMemberType": False,
145
+ "reportUnknownArgumentType": False,
146
+ "reportMissingParameterType": False,
147
+ "extraPaths": [
148
+ os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))
149
+ ],
150
+ "reportAttributeAccessIssue": True,
151
+ "reportArgumentType": True,
152
+ }
153
+
154
+
155
+ def run_pyright(file_path: str) -> list[PyrightDiagnostic]:
156
+ """
157
+ Run pyright on a file and return the diagnostics
158
+
159
+ """
160
+ try:
161
+ with TemporaryDirectory() as temp_dir:
162
+ # Create pyright config
163
+ config_path = os.path.join(temp_dir, "pyrightconfig.json")
164
+ with open(config_path, "w") as f:
165
+ json_dump(create_pyright_config(), f)
166
+
167
+ # Copy the file to analyze into the project directory
168
+ test_file = os.path.join(temp_dir, "test.py")
169
+ with open(file_path, "r") as src, open(test_file, "w") as dst:
170
+ dst.write(src.read())
171
+
172
+ # Run pyright with the config
173
+ result = run(
174
+ "--project",
175
+ temp_dir,
176
+ "--outputjson",
177
+ test_file,
178
+ capture_output=True,
179
+ text=True,
180
+ )
181
+
182
+ try:
183
+ output = json_loads(result.stdout)
184
+ except JSONDecodeError:
185
+ print(f"Failed to parse pyright output: {result.stdout}") # noqa: T201
186
+ print(f"Stderr: {result.stderr}") # noqa: T201
187
+ raise
188
+
189
+ if "generalDiagnostics" not in output:
190
+ raise RuntimeError(
191
+ f"Unknown pyright output, missing generalDiagnostics: {output}"
192
+ )
193
+
194
+ diagnostics: list[PyrightDiagnostic] = []
195
+ for diag in output["generalDiagnostics"]:
196
+ diagnostics.append(
197
+ PyrightDiagnostic(
198
+ file=diag["file"],
199
+ severity=diag["severity"],
200
+ message=diag["message"],
201
+ rule=diag.get("rule"),
202
+ line=diag["range"]["start"]["line"] + 1, # Convert to 1-based
203
+ column=(
204
+ diag["range"]["start"]["character"]
205
+ + 1 # Convert to 1-based
206
+ ),
207
+ )
208
+ )
209
+
210
+ return diagnostics
211
+
212
+ except Exception as e:
213
+ raise RuntimeError(f"Failed to run pyright: {str(e)}")
214
+
215
+
216
+ @contextmanager
217
+ def pyright_raises(
218
+ expected_rule: str,
219
+ expected_line: int | None = None,
220
+ matches: Pattern | None = None,
221
+ ):
222
+ """
223
+ Context manager that verifies code produces a specific Pyright error.
224
+
225
+ :params expected_rule: The Pyright rule that should be violated
226
+ :params expected_line: Optional line number where the error should occur
227
+
228
+ :raises ExpectedPyrightError: If Pyright doesn't produce the expected error
229
+
230
+ """
231
+ # Create a temporary file to store the code
232
+ with NamedTemporaryFile(mode="w", suffix=".py") as temp_file:
233
+ temp_path = temp_file.name
234
+
235
+ # Extract the source code of the calling function
236
+ source_code = extract_current_function_code()
237
+ print(f"Running Pyright on:\n{source_code}") # noqa: T201
238
+
239
+ # Write the source code to the temporary file
240
+ temp_file.write(source_code)
241
+ temp_file.flush()
242
+
243
+ # At runtime, our actual code is probably a no-op but we still let it run
244
+ # inside the scope of the contextmanager
245
+ yield
246
+
247
+ # Run Pyright on the temporary file
248
+ diagnostics = run_pyright(temp_path)
249
+
250
+ # Check if any of the diagnostics match our expected error
251
+ for diagnostic in diagnostics:
252
+ if diagnostic.rule == expected_rule:
253
+ if expected_line is not None and diagnostic.line != expected_line:
254
+ continue
255
+ if matches and not matches.search(diagnostic.message):
256
+ continue
257
+ # Found matching error
258
+ return
259
+
260
+ # If we get here, we didn't find the expected error
261
+ actual_errors = [
262
+ f"{d.rule or 'unknown'} on line {d.line}: {d.message}" for d in diagnostics
263
+ ]
264
+ raise ExpectedPyrightError(
265
+ f"Expected Pyright error {expected_rule}"
266
+ f"{f' on line {expected_line}' if expected_line else ''}"
267
+ f" but got: {', '.join(actual_errors) if actual_errors else 'no errors'}"
268
+ )
File without changes
@@ -0,0 +1,36 @@
1
+ import pytest_asyncio
2
+
3
+ from iceaxe.session import DBConnection
4
+
5
+
6
+ @pytest_asyncio.fixture
7
+ async def clear_all_database_objects(db_connection: DBConnection):
8
+ """
9
+ Clear all database objects.
10
+
11
+ """
12
+ # Step 1: Drop all tables in the public schema
13
+ await db_connection.conn.execute(
14
+ """
15
+ DO $$ DECLARE
16
+ r RECORD;
17
+ BEGIN
18
+ FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP
19
+ EXECUTE 'DROP TABLE IF EXISTS ' || quote_ident(r.tablename) || ' CASCADE';
20
+ END LOOP;
21
+ END $$;
22
+ """
23
+ )
24
+
25
+ # Step 2: Drop all custom types in the public schema
26
+ await db_connection.conn.execute(
27
+ """
28
+ DO $$ DECLARE
29
+ r RECORD;
30
+ BEGIN
31
+ FOR r IN (SELECT typname FROM pg_type WHERE typtype = 'e' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')) LOOP
32
+ EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE';
33
+ END LOOP;
34
+ END $$;
35
+ """
36
+ )