iceaxe 0.2.3.dev3__tar.gz → 0.2.3.dev4__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.
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/PKG-INFO +1 -1
- iceaxe-0.2.3.dev4/iceaxe/__tests__/helpers.py +263 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/test_comparison.py +35 -0
- iceaxe-0.2.3.dev4/iceaxe/__tests__/test_helpers.py +9 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/comparison.py +25 -2
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/field.py +6 -1
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/typing.py +5 -2
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/pyproject.toml +1 -1
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/setup.py +1 -1
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/LICENSE +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/README.md +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/build.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/.DS_Store +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__init__.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/__init__.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/benchmarks/__init__.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/benchmarks/test_select.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/conf_models.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/conftest.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/migrations/__init__.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/migrations/conftest.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/migrations/test_action_sorter.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/migrations/test_generator.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/migrations/test_generics.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/mountaineer/__init__.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/mountaineer/dependencies/__init__.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/mountaineer/dependencies/test_core.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/schemas/__init__.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/schemas/test_actions.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/schemas/test_cli.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/schemas/test_db_memory_serializer.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/schemas/test_db_serializer.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/schemas/test_db_stubs.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/test_base.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/test_field.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/test_queries.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/test_session.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/base.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/functions.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/generics.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/io.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/logging.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/migrations/__init__.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/migrations/action_sorter.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/migrations/cli.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/migrations/client_io.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/migrations/generator.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/migrations/migration.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/migrations/migrator.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/mountaineer/__init__.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/mountaineer/cli.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/mountaineer/config.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/mountaineer/dependencies/__init__.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/mountaineer/dependencies/core.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/postgres.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/py.typed +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/queries.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/queries_str.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/schemas/__init__.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/schemas/actions.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/schemas/cli.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/schemas/db_memory_serializer.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/schemas/db_serializer.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/schemas/db_stubs.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/session.py +0 -0
- {iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/session_optimized.pyx +0 -0
|
@@ -0,0 +1,263 @@
|
|
|
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
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def run_pyright(file_path: str) -> list[PyrightDiagnostic]:
|
|
151
|
+
"""
|
|
152
|
+
Run pyright on a file and return the diagnostics
|
|
153
|
+
|
|
154
|
+
"""
|
|
155
|
+
try:
|
|
156
|
+
with TemporaryDirectory() as temp_dir:
|
|
157
|
+
# Create pyright config
|
|
158
|
+
config_path = os.path.join(temp_dir, "pyrightconfig.json")
|
|
159
|
+
with open(config_path, "w") as f:
|
|
160
|
+
json_dump(create_pyright_config(), f)
|
|
161
|
+
|
|
162
|
+
# Copy the file to analyze into the project directory
|
|
163
|
+
test_file = os.path.join(temp_dir, "test.py")
|
|
164
|
+
with open(file_path, "r") as src, open(test_file, "w") as dst:
|
|
165
|
+
dst.write(src.read())
|
|
166
|
+
|
|
167
|
+
# Run pyright with the config
|
|
168
|
+
result = run(
|
|
169
|
+
"--project",
|
|
170
|
+
temp_dir,
|
|
171
|
+
"--outputjson",
|
|
172
|
+
test_file,
|
|
173
|
+
capture_output=True,
|
|
174
|
+
text=True,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
output = json_loads(result.stdout)
|
|
179
|
+
except JSONDecodeError:
|
|
180
|
+
print(f"Failed to parse pyright output: {result.stdout}") # noqa: T201
|
|
181
|
+
print(f"Stderr: {result.stderr}") # noqa: T201
|
|
182
|
+
raise
|
|
183
|
+
|
|
184
|
+
if "generalDiagnostics" not in output:
|
|
185
|
+
raise RuntimeError(
|
|
186
|
+
f"Unknown pyright output, missing generalDiagnostics: {output}"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
diagnostics: list[PyrightDiagnostic] = []
|
|
190
|
+
for diag in output["generalDiagnostics"]:
|
|
191
|
+
diagnostics.append(
|
|
192
|
+
PyrightDiagnostic(
|
|
193
|
+
file=diag["file"],
|
|
194
|
+
severity=diag["severity"],
|
|
195
|
+
message=diag["message"],
|
|
196
|
+
rule=diag.get("rule"),
|
|
197
|
+
line=diag["range"]["start"]["line"] + 1, # Convert to 1-based
|
|
198
|
+
column=(
|
|
199
|
+
diag["range"]["start"]["character"]
|
|
200
|
+
+ 1 # Convert to 1-based
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
return diagnostics
|
|
206
|
+
|
|
207
|
+
except Exception as e:
|
|
208
|
+
raise RuntimeError(f"Failed to run pyright: {str(e)}")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@contextmanager
|
|
212
|
+
def pyright_raises(
|
|
213
|
+
expected_rule: str,
|
|
214
|
+
expected_line: int | None = None,
|
|
215
|
+
matches: Pattern | None = None,
|
|
216
|
+
):
|
|
217
|
+
"""
|
|
218
|
+
Context manager that verifies code produces a specific Pyright error.
|
|
219
|
+
|
|
220
|
+
:params expected_rule: The Pyright rule that should be violated
|
|
221
|
+
:params expected_line: Optional line number where the error should occur
|
|
222
|
+
|
|
223
|
+
:raises ExpectedPyrightError: If Pyright doesn't produce the expected error
|
|
224
|
+
|
|
225
|
+
"""
|
|
226
|
+
# Create a temporary file to store the code
|
|
227
|
+
with NamedTemporaryFile(mode="w", suffix=".py") as temp_file:
|
|
228
|
+
temp_path = temp_file.name
|
|
229
|
+
|
|
230
|
+
# Extract the source code of the calling function
|
|
231
|
+
source_code = extract_current_function_code()
|
|
232
|
+
print(f"Running Pyright on:\n{source_code}") # noqa: T201
|
|
233
|
+
|
|
234
|
+
# Write the source code to the temporary file
|
|
235
|
+
temp_file.write(source_code)
|
|
236
|
+
temp_file.flush()
|
|
237
|
+
|
|
238
|
+
# At runtime, our actual code is probably a no-op but we still let it run
|
|
239
|
+
# inside the scope of the contextmanager
|
|
240
|
+
yield
|
|
241
|
+
|
|
242
|
+
# Run Pyright on the temporary file
|
|
243
|
+
diagnostics = run_pyright(temp_path)
|
|
244
|
+
|
|
245
|
+
# Check if any of the diagnostics match our expected error
|
|
246
|
+
for diagnostic in diagnostics:
|
|
247
|
+
if diagnostic.rule == expected_rule:
|
|
248
|
+
if expected_line is not None and diagnostic.line != expected_line:
|
|
249
|
+
continue
|
|
250
|
+
if matches and not matches.search(diagnostic.message):
|
|
251
|
+
continue
|
|
252
|
+
# Found matching error
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
# If we get here, we didn't find the expected error
|
|
256
|
+
actual_errors = [
|
|
257
|
+
f"{d.rule or 'unknown'} on line {d.line}: {d.message}" for d in diagnostics
|
|
258
|
+
]
|
|
259
|
+
raise ExpectedPyrightError(
|
|
260
|
+
f"Expected Pyright error {expected_rule}"
|
|
261
|
+
f"{f' on line {expected_line}' if expected_line else ''}"
|
|
262
|
+
f" but got: {', '.join(actual_errors) if actual_errors else 'no errors'}"
|
|
263
|
+
)
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
from re import compile as re_compile
|
|
1
2
|
from typing import Any
|
|
2
3
|
|
|
3
4
|
import pytest
|
|
5
|
+
from typing_extensions import assert_type
|
|
4
6
|
|
|
7
|
+
from iceaxe.__tests__.helpers import pyright_raises
|
|
5
8
|
from iceaxe.base import TableBase
|
|
6
9
|
from iceaxe.comparison import ComparisonType, FieldComparison
|
|
7
10
|
from iceaxe.field import DBFieldClassDefinition, DBFieldInfo
|
|
11
|
+
from iceaxe.typing import column
|
|
8
12
|
|
|
9
13
|
|
|
10
14
|
def test_comparison_type_enum():
|
|
@@ -17,6 +21,9 @@ def test_comparison_type_enum():
|
|
|
17
21
|
assert ComparisonType.IN == "IN"
|
|
18
22
|
assert ComparisonType.NOT_IN == "NOT IN"
|
|
19
23
|
assert ComparisonType.LIKE == "LIKE"
|
|
24
|
+
assert ComparisonType.NOT_LIKE == "NOT LIKE"
|
|
25
|
+
assert ComparisonType.ILIKE == "ILIKE"
|
|
26
|
+
assert ComparisonType.NOT_ILIKE == "NOT ILIKE"
|
|
20
27
|
assert ComparisonType.IS == "IS"
|
|
21
28
|
assert ComparisonType.IS_NOT == "IS NOT"
|
|
22
29
|
|
|
@@ -158,3 +165,31 @@ def test_comparison_with_different_types(db_field: DBFieldClassDefinition, value
|
|
|
158
165
|
assert result.left == db_field
|
|
159
166
|
assert isinstance(result.comparison, ComparisonType)
|
|
160
167
|
assert result.right == value
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
#
|
|
171
|
+
# Typehinting
|
|
172
|
+
# These checks are run as part of the static typechecking we do
|
|
173
|
+
# for our codebase, not as part of the pytest runtime.
|
|
174
|
+
#
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_typehint_ilike():
|
|
178
|
+
class UserDemo(TableBase):
|
|
179
|
+
id: int
|
|
180
|
+
value_str: str
|
|
181
|
+
value_int: int
|
|
182
|
+
|
|
183
|
+
str_col = column(UserDemo.value_str)
|
|
184
|
+
int_col = column(UserDemo.value_int)
|
|
185
|
+
|
|
186
|
+
assert_type(str_col, DBFieldClassDefinition[str])
|
|
187
|
+
assert_type(int_col, DBFieldClassDefinition[int])
|
|
188
|
+
|
|
189
|
+
assert_type(str_col.ilike("test"), bool)
|
|
190
|
+
|
|
191
|
+
with pyright_raises(
|
|
192
|
+
"reportAttributeAccessIssue",
|
|
193
|
+
matches=re_compile('Cannot access attribute "ilike"'),
|
|
194
|
+
):
|
|
195
|
+
int_col.ilike(5) # type: ignore
|
|
@@ -7,6 +7,7 @@ from iceaxe.queries_str import QueryElementBase, QueryLiteral
|
|
|
7
7
|
from iceaxe.typing import is_column, is_comparison, is_comparison_group
|
|
8
8
|
|
|
9
9
|
T = TypeVar("T", bound="ComparisonBase")
|
|
10
|
+
J = TypeVar("J")
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class ComparisonType(StrEnum):
|
|
@@ -18,7 +19,12 @@ class ComparisonType(StrEnum):
|
|
|
18
19
|
GE = ">="
|
|
19
20
|
IN = "IN"
|
|
20
21
|
NOT_IN = "NOT IN"
|
|
22
|
+
|
|
21
23
|
LIKE = "LIKE"
|
|
24
|
+
NOT_LIKE = "NOT LIKE"
|
|
25
|
+
ILIKE = "ILIKE"
|
|
26
|
+
NOT_ILIKE = "NOT ILIKE"
|
|
27
|
+
|
|
22
28
|
IS = "IS"
|
|
23
29
|
IS_NOT = "IS NOT"
|
|
24
30
|
|
|
@@ -95,7 +101,7 @@ class FieldComparisonGroup:
|
|
|
95
101
|
return QueryLiteral(queries), all_variables
|
|
96
102
|
|
|
97
103
|
|
|
98
|
-
class ComparisonBase(ABC):
|
|
104
|
+
class ComparisonBase(ABC, Generic[J]):
|
|
99
105
|
def __eq__(self, other): # type: ignore
|
|
100
106
|
if other is None:
|
|
101
107
|
return self._compare(ComparisonType.IS, None)
|
|
@@ -124,9 +130,26 @@ class ComparisonBase(ABC):
|
|
|
124
130
|
def not_in(self, other) -> bool:
|
|
125
131
|
return self._compare(ComparisonType.NOT_IN, other) # type: ignore
|
|
126
132
|
|
|
127
|
-
def like(
|
|
133
|
+
def like(
|
|
134
|
+
self: "ComparisonBase[str] | ComparisonBase[str | None]", other: str
|
|
135
|
+
) -> bool:
|
|
128
136
|
return self._compare(ComparisonType.LIKE, other) # type: ignore
|
|
129
137
|
|
|
138
|
+
def not_like(
|
|
139
|
+
self: "ComparisonBase[str] | ComparisonBase[str | None]", other: str
|
|
140
|
+
) -> bool:
|
|
141
|
+
return self._compare(ComparisonType.NOT_LIKE, other) # type: ignore
|
|
142
|
+
|
|
143
|
+
def ilike(
|
|
144
|
+
self: "ComparisonBase[str] | ComparisonBase[str | None]", other: str
|
|
145
|
+
) -> bool:
|
|
146
|
+
return self._compare(ComparisonType.ILIKE, other) # type: ignore
|
|
147
|
+
|
|
148
|
+
def not_ilike(
|
|
149
|
+
self: "ComparisonBase[str] | ComparisonBase[str | None]", other: str
|
|
150
|
+
) -> bool:
|
|
151
|
+
return self._compare(ComparisonType.NOT_ILIKE, other) # type: ignore
|
|
152
|
+
|
|
130
153
|
def _compare(self, comparison: ComparisonType, other: Any) -> FieldComparison[Self]:
|
|
131
154
|
return FieldComparison(left=self, comparison=comparison, right=other)
|
|
132
155
|
|
|
@@ -4,8 +4,10 @@ from typing import (
|
|
|
4
4
|
Any,
|
|
5
5
|
Callable,
|
|
6
6
|
Concatenate,
|
|
7
|
+
Generic,
|
|
7
8
|
ParamSpec,
|
|
8
9
|
Type,
|
|
10
|
+
TypeVar,
|
|
9
11
|
Unpack,
|
|
10
12
|
cast,
|
|
11
13
|
)
|
|
@@ -139,7 +141,10 @@ def __get_db_field(_: Callable[Concatenate[Any, P], Any] = PydanticField): # ty
|
|
|
139
141
|
return func
|
|
140
142
|
|
|
141
143
|
|
|
142
|
-
|
|
144
|
+
T = TypeVar("T")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class DBFieldClassDefinition(Generic[T], ComparisonBase[T]):
|
|
143
148
|
root_model: Type["TableBase"]
|
|
144
149
|
key: str
|
|
145
150
|
field_definition: DBFieldInfo
|
|
@@ -8,6 +8,7 @@ from typing import (
|
|
|
8
8
|
Any,
|
|
9
9
|
Type,
|
|
10
10
|
TypeGuard,
|
|
11
|
+
TypeVar,
|
|
11
12
|
)
|
|
12
13
|
from uuid import UUID
|
|
13
14
|
|
|
@@ -27,6 +28,8 @@ PRIMITIVE_WRAPPER_TYPES = list[PRIMITIVE_TYPES] | PRIMITIVE_TYPES
|
|
|
27
28
|
DATE_TYPES = datetime | date | time | timedelta
|
|
28
29
|
JSON_WRAPPER_FALLBACK = list[Any] | dict[Any, Any]
|
|
29
30
|
|
|
31
|
+
T = TypeVar("T")
|
|
32
|
+
|
|
30
33
|
|
|
31
34
|
def is_base_table(obj: Any) -> TypeGuard[type[TableBase]]:
|
|
32
35
|
from iceaxe.base import TableBase
|
|
@@ -34,7 +37,7 @@ def is_base_table(obj: Any) -> TypeGuard[type[TableBase]]:
|
|
|
34
37
|
return isclass(obj) and issubclass(obj, TableBase)
|
|
35
38
|
|
|
36
39
|
|
|
37
|
-
def is_column(obj:
|
|
40
|
+
def is_column(obj: T) -> TypeGuard[DBFieldClassDefinition[T]]:
|
|
38
41
|
from iceaxe.base import DBFieldClassDefinition
|
|
39
42
|
|
|
40
43
|
return isinstance(obj, DBFieldClassDefinition)
|
|
@@ -64,7 +67,7 @@ def is_function_metadata(obj: Any) -> TypeGuard[FunctionMetadata]:
|
|
|
64
67
|
return isinstance(obj, FunctionMetadata)
|
|
65
68
|
|
|
66
69
|
|
|
67
|
-
def column(obj:
|
|
70
|
+
def column(obj: T) -> DBFieldClassDefinition[T]:
|
|
68
71
|
if not is_column(obj):
|
|
69
72
|
raise ValueError(f"Invalid column: {obj}")
|
|
70
73
|
return obj
|
|
@@ -22,7 +22,7 @@ install_requires = \
|
|
|
22
22
|
|
|
23
23
|
setup_kwargs = {
|
|
24
24
|
'name': 'iceaxe',
|
|
25
|
-
'version': '0.2.3.
|
|
25
|
+
'version': '0.2.3.dev4',
|
|
26
26
|
'description': 'A modern, fast ORM for Python.',
|
|
27
27
|
'long_description': '# iceaxe\n\nA modern, fast ORM for Python. We have the following goals:\n\n- 🏎️ **Performance**: We want to exceed or match the fastest ORMs in Python. We want our ORM\nto be as close as possible to raw-[asyncpg](https://github.com/MagicStack/asyncpg) speeds. See the "Benchmarks" section for more.\n- 📝 **Typehinting**: Everything should be typehinted with expected types. Declare your data as you\nexpect in Python and it should bidirectionally sync to the database.\n- 🐘 **Postgres only**: Leverage native Postgres features and simplify the implementation.\n- ⚡ **Common things are easy, rare things are possible**: 99% of the SQL queries we write are\nvanilla SELECT/INSERT/UPDATEs. These should be natively supported by your ORM. If you\'re writing _really_\ncomplex queries, these are better done by hand so you can see exactly what SQL will be run.\n\nIceaxe is in early alpha. It\'s also an independent project. It\'s compatible with the [Mountaineer](https://github.com/piercefreeman/mountaineer) ecosystem, but you can use it in whatever\nproject and web framework you\'re using.\n\n## Installation\n\nIf you\'re using poetry to manage your dependencies:\n\n```bash\npoetry add iceaxe\n```\n\nOtherwise install with pip:\n\n```bash\npip install iceaxe\n```\n\n## Usage\n\nDefine your models as a `TableBase` subclass:\n\n```python\nfrom iceaxe import TableBase\n\nclass Person(TableBase):\n id: int\n name: str\n age: int\n```\n\nTableBase is a subclass of Pydantic\'s `BaseModel`, so you get all of the validation and Field customization\nout of the box. We provide our own `Field` constructor that adds database-specific configuration. For instance, to make the\n`id` field a primary key / auto-incrementing you can do:\n\n```python\nfrom iceaxe import Field\n\nclass Person(TableBase):\n id: int = Field(primary_key=True)\n name: str\n age: int\n```\n\nOkay now you have a model. How do you interact with it?\n\nDatabases are based on a few core primitives to insert data, update it, and fetch it out again.\nTo do so you\'ll need a _database connection_, which is a connection over the network from your code\nto your Postgres database. The `DBConnection` is the core class for all ORM actions against the database.\n\n```python\nfrom iceaxe import DBConnection\nimport asyncpg\n\nconn = DBConnection(\n await asyncpg.connect(\n host="localhost",\n port=5432,\n user="db_user",\n password="yoursecretpassword",\n database="your_db",\n )\n)\n```\n\nThe Person class currently just lives in memory. To back it with a full\ndatabase table, we can run raw SQL or run a migration to add it:\n\n```python\nawait conn.conn.execute(\n """\n CREATE TABLE IF NOT EXISTS person (\n id SERIAL PRIMARY KEY,\n name TEXT NOT NULL,\n age INT NOT NULL\n )\n """\n)\n```\n\n### Inserting Data\n\nInstantiate object classes as you normally do:\n\n```python\npeople = [\n Person(name="Alice", age=30),\n Person(name="Bob", age=40),\n Person(name="Charlie", age=50),\n]\nawait conn.insert(people)\n\nprint(people[0].id) # 1\nprint(people[1].id) # 2\n```\n\nBecause we\'re using an auto-incrementing primary key, the `id` field will be populated after the insert.\nIceaxe will automatically update the object in place with the newly assigned value.\n\n### Updating data\n\nNow that we have these lovely people, let\'s modify them.\n\n```python\nperson = people[0]\nperson.name = "Blice"\n```\n\nRight now, we have a Python object that\'s out of state with the database. But that\'s often okay. We can inspect it\nand further write logic - it\'s fully decoupled from the database.\n\n```python\ndef ensure_b_letter(person: Person):\n if person.name[0].lower() != "b":\n raise ValueError("Name must start with \'B\'")\n\nensure_b_letter(person)\n```\n\nTo sync the values back to the database, we can call `update`:\n\n```python\nawait conn.update([person])\n```\n\nIf we were to query the database directly, we see that the name has been updated:\n\n```\nid | name | age\n----+-------+-----\n 1 | Blice | 31\n 2 | Bob | 40\n 3 | Charlie | 50\n```\n\nBut no other fields have been touched. This lets a potentially concurrent process\nmodify `Alice`\'s record - say, updating the age to 31. By the time we update the data, we\'ll\nchange the name but nothing else. Under the hood we do this by tracking the fields that\nhave been modified in-memory and creating a targeted UPDATE to modify only those values.\n\n### Selecting data\n\nTo select data, we can use a `QueryBuilder`. For a shortcut to `select` query functions,\nyou can also just import select directly. This method takes the desired value parameters\nand returns a list of the desired objects.\n\n```python\nfrom iceaxe import select\n\nquery = select(Person).where(Person.name == "Blice", Person.age > 25)\nresults = await conn.exec(query)\n```\n\nIf we inspect the typing of `results`, we see that it\'s a `list[Person]` objects. This matches\nthe typehint of the `select` function. You can also target columns directly:\n\n```python\nquery = select((Person.id, Person.name)).where(Person.age > 25)\nresults = await conn.exec(query)\n```\n\nThis will return a list of tuples, where each tuple is the id and name of the person: `list[tuple[int, str]]`.\n\nWe support most of the common SQL operations. Just like the results, these are typehinted\nto their proper types as well. Static typecheckers and your IDE will throw an error if you try to compare\na string column to an integer, for instance. A more complex example of a query:\n\n```python\nquery = select((\n Person.id,\n FavoriteColor,\n)).join(\n FavoriteColor,\n Person.id == FavoriteColor.person_id,\n).where(\n Person.age > 25,\n Person.name == "Blice",\n).order_by(\n Person.age.desc(),\n).limit(10)\nresults = await conn.exec(query)\n```\n\nAs expected this will deliver results - and typehint - as a `list[tuple[int, FavoriteColor]]`\n\n## Production\n\n> [!IMPORTANT]\n> Iceaxe is in early alpha. We\'re using it internally and showly rolling out to our production\napplications, but we\'re not yet ready to recommend it for general use. The API and larger\nstability is subject to change.\n\nNote that underlying Postgres connection wrapped by `conn` will be alive for as long as your object is in memory. This uses up one\nof the allowable connections to your database. Your overall limit depends on your Postgres configuration\nor hosting provider, but most managed solutions top out around 150-300. If you need more concurrent clients\nconnected (and even if you don\'t - connection creation at the Postgres level is expensive), you can adopt\na load balancer like `pgbouncer` to better scale to traffic. More deployment notes to come.\n\nIt\'s also worth noting the absence of request pooling in this initialization. This is a feature of many ORMs that lets you limit\nthe overall connections you make to Postgres, and re-use these over time. We specifically don\'t offer request\npooling as part of Iceaxe, despite being supported by our underlying engine `asyncpg`. This is a bit more\naligned to how things should be structured in production. Python apps are always bound to one process thanks to\nthe GIL. So no matter what your connection pool will always be tied to the current Python process / runtime. When you\'re deploying onto a server with multiple cores, the pool will be duplicated across CPUs and largely defeats the purpose of capping\nnetwork connections in the first place.\n\n## Benchmarking\n\nWe have basic benchmarking tests in the `__tests__/benchmarks` directory. To run them, you\'ll need to execute the pytest suite:\n\n```bash\npoetry run pytest -m integration_tests\n```\n\nCurrent benchmarking as of October 11 2024 is:\n\n| | raw asyncpg | iceaxe | external overhead | |\n|-------------------|-------------|--------|-----------------------------------------------|---|\n| TableBase columns | 0.098s | 0.093s | | |\n| TableBase full | 0.164s | 1.345s | 10%: dict construction | 90%: pydantic overhead | |\n\n## Development\n\nIf you update your Cython implementation during development, you\'ll need to re-compile the Cython code. This can be done with\na simple poetry install. Poetry is set up to create a dynamic `setup.py` based on our `build.py` definition.\n\n```bash\npoetry install\n```\n\n## TODOs\n\n- [ ] Additional documentation with usage examples.\n',
|
|
28
28
|
'author': 'Pierce Freeman',
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/mountaineer/dependencies/__init__.py
RENAMED
|
File without changes
|
{iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/mountaineer/dependencies/test_core.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{iceaxe-0.2.3.dev3 → iceaxe-0.2.3.dev4}/iceaxe/__tests__/schemas/test_db_memory_serializer.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|