sql_querystring_builder 0.3.0__tar.gz → 0.4.0__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.
- {sql_querystring_builder-0.3.0/src/sql_querystring_builder.egg-info → sql_querystring_builder-0.4.0}/PKG-INFO +33 -2
- {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/README.md +32 -1
- {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/pyproject.toml +1 -1
- {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder/__init__.py +2 -0
- {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder/clauses.py +95 -3
- {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder/query_builder.py +26 -4
- {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0/src/sql_querystring_builder.egg-info}/PKG-INFO +33 -2
- {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder.egg-info/SOURCES.txt +1 -0
- {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/tests/test_characterization.py +2 -0
- sql_querystring_builder-0.4.0/tests/test_insert_delete.py +227 -0
- {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/tests/test_sql_syntax.py +23 -2
- {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/LICENSE +0 -0
- {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/setup.cfg +0 -0
- {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder/abstract_clause.py +0 -0
- {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder/conditions.py +0 -0
- {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder/exceptions.py +0 -0
- {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder/py.typed +0 -0
- {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder/tables.py +0 -0
- {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder/values.py +0 -0
- {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder.egg-info/dependency_links.txt +0 -0
- {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder.egg-info/top_level.txt +0 -0
- {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/tests/test_clauses.py +0 -0
- {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/tests/test_conditions.py +0 -0
- {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/tests/test_queries.py +0 -0
- {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/tests/test_readme.py +0 -0
- {sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/tests/test_tables.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sql_querystring_builder
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Wraps string building of MS SQL queries
|
|
5
5
|
Author-email: Wael GRIBAA <g.wael@outlook.fr>
|
|
6
6
|
License-Expression: GPL-3.0-only
|
|
@@ -35,6 +35,8 @@ It provides first level validation.
|
|
|
35
35
|
2. Using:
|
|
36
36
|
- [x] Select (with DISTINCT and TOP)
|
|
37
37
|
- [x] Update (with typed values)
|
|
38
|
+
- [x] Insert (multi-row VALUES)
|
|
39
|
+
- [x] Delete (with joins; refused without a WHERE unless asked)
|
|
38
40
|
- [x] From
|
|
39
41
|
- [x] InnerJoin, LeftJoin, RightJoin, FullJoin (on two fields or on any condition), CrossJoin
|
|
40
42
|
- [x] Where (with conditions)
|
|
@@ -47,7 +49,6 @@ It provides first level validation.
|
|
|
47
49
|
|
|
48
50
|
## Not implemented (yet)
|
|
49
51
|
In planned order:
|
|
50
|
-
- [ ] Insert and Delete
|
|
51
52
|
- [ ] Functions (aggregates and others), GROUP BY, HAVING, CASE
|
|
52
53
|
- [ ] Compound queries (UNION, INTERSECT, EXCEPT) and sub-queries
|
|
53
54
|
|
|
@@ -134,6 +135,23 @@ print(params)
|
|
|
134
135
|
# > (20000, 'A1C%')
|
|
135
136
|
# With pyodbc: cursor.execute(sql, params). For pymssql, build_with_params("format") writes %s instead.
|
|
136
137
|
```
|
|
138
|
+
6. Insert and delete rows:
|
|
139
|
+
```
|
|
140
|
+
# Inserts rows: one tuple per row, with the values in column order
|
|
141
|
+
insert = Insert(customers_table.fields.customerId, customers_table.fields.mainPhone).values(
|
|
142
|
+
("A1C42", "555-0100"), ("B7", None)
|
|
143
|
+
)
|
|
144
|
+
print(QueryBuilder(insert).build())
|
|
145
|
+
# > INSERT INTO Customers (customerId, mainPhone)
|
|
146
|
+
# > VALUES ('A1C42', '555-0100'), ('B7', NULL)
|
|
147
|
+
|
|
148
|
+
# Deletes the accounts of customer B7; a DELETE needs a WHERE, unless built with where_required=False
|
|
149
|
+
delete = QueryBuilder(Delete(accounts_table), From(accounts_table), Where(accounts_table.fields.customerId.eq("B7")))
|
|
150
|
+
print(delete.build())
|
|
151
|
+
# > DELETE acc
|
|
152
|
+
# > FROM Accounts acc
|
|
153
|
+
# > WHERE acc.customerId = 'B7'
|
|
154
|
+
```
|
|
137
155
|
|
|
138
156
|
(Find it directly in the module example.py)
|
|
139
157
|
|
|
@@ -144,6 +162,19 @@ Python 3.10 and above.
|
|
|
144
162
|
(None)
|
|
145
163
|
|
|
146
164
|
## Changelog
|
|
165
|
+
### 0.4.0
|
|
166
|
+
Queries built with 0.3.0 produce the same SQL.
|
|
167
|
+
- `Insert(*columns).values(*rows)` renders `INSERT INTO Customers (id, name)` and
|
|
168
|
+
`VALUES (1, 'Ann'), (2, 'O''Brien')`. The columns are fields of one table, or a whole table. Each row is a
|
|
169
|
+
tuple of values in column order, with at most 1000 rows per INSERT, as in T-SQL. Values are escaped as in
|
|
170
|
+
conditions, plain strings included, and `Raw("DEFAULT")` writes SQL. An Insert stands alone in its query.
|
|
171
|
+
- `Delete(table)` renders `DELETE` and the table's alias, or its name, and works with `From`, joins and
|
|
172
|
+
`Where`: `DELETE acc FROM Accounts acc INNER JOIN ...`. A query with a DELETE but no WHERE raises
|
|
173
|
+
`IncompatibleClausesException`, unless written `Delete(table, where_required=False)`; so does a DELETE with
|
|
174
|
+
ORDER BY.
|
|
175
|
+
- Both work with `build_with_params()`. SQL Server accepts about 2,100 parameters per query, so split big
|
|
176
|
+
parameterized inserts.
|
|
177
|
+
|
|
147
178
|
### 0.3.0
|
|
148
179
|
Queries built with 0.2.0 produce the same SQL, except for the fixed `Update` values below.
|
|
149
180
|
- Conditions are methods of fields, functions on fields and strings: `eq`, `ne`, `lt`, `le`, `gt`, `ge`,
|
|
@@ -14,6 +14,8 @@ It provides first level validation.
|
|
|
14
14
|
2. Using:
|
|
15
15
|
- [x] Select (with DISTINCT and TOP)
|
|
16
16
|
- [x] Update (with typed values)
|
|
17
|
+
- [x] Insert (multi-row VALUES)
|
|
18
|
+
- [x] Delete (with joins; refused without a WHERE unless asked)
|
|
17
19
|
- [x] From
|
|
18
20
|
- [x] InnerJoin, LeftJoin, RightJoin, FullJoin (on two fields or on any condition), CrossJoin
|
|
19
21
|
- [x] Where (with conditions)
|
|
@@ -26,7 +28,6 @@ It provides first level validation.
|
|
|
26
28
|
|
|
27
29
|
## Not implemented (yet)
|
|
28
30
|
In planned order:
|
|
29
|
-
- [ ] Insert and Delete
|
|
30
31
|
- [ ] Functions (aggregates and others), GROUP BY, HAVING, CASE
|
|
31
32
|
- [ ] Compound queries (UNION, INTERSECT, EXCEPT) and sub-queries
|
|
32
33
|
|
|
@@ -113,6 +114,23 @@ print(params)
|
|
|
113
114
|
# > (20000, 'A1C%')
|
|
114
115
|
# With pyodbc: cursor.execute(sql, params). For pymssql, build_with_params("format") writes %s instead.
|
|
115
116
|
```
|
|
117
|
+
6. Insert and delete rows:
|
|
118
|
+
```
|
|
119
|
+
# Inserts rows: one tuple per row, with the values in column order
|
|
120
|
+
insert = Insert(customers_table.fields.customerId, customers_table.fields.mainPhone).values(
|
|
121
|
+
("A1C42", "555-0100"), ("B7", None)
|
|
122
|
+
)
|
|
123
|
+
print(QueryBuilder(insert).build())
|
|
124
|
+
# > INSERT INTO Customers (customerId, mainPhone)
|
|
125
|
+
# > VALUES ('A1C42', '555-0100'), ('B7', NULL)
|
|
126
|
+
|
|
127
|
+
# Deletes the accounts of customer B7; a DELETE needs a WHERE, unless built with where_required=False
|
|
128
|
+
delete = QueryBuilder(Delete(accounts_table), From(accounts_table), Where(accounts_table.fields.customerId.eq("B7")))
|
|
129
|
+
print(delete.build())
|
|
130
|
+
# > DELETE acc
|
|
131
|
+
# > FROM Accounts acc
|
|
132
|
+
# > WHERE acc.customerId = 'B7'
|
|
133
|
+
```
|
|
116
134
|
|
|
117
135
|
(Find it directly in the module example.py)
|
|
118
136
|
|
|
@@ -123,6 +141,19 @@ Python 3.10 and above.
|
|
|
123
141
|
(None)
|
|
124
142
|
|
|
125
143
|
## Changelog
|
|
144
|
+
### 0.4.0
|
|
145
|
+
Queries built with 0.3.0 produce the same SQL.
|
|
146
|
+
- `Insert(*columns).values(*rows)` renders `INSERT INTO Customers (id, name)` and
|
|
147
|
+
`VALUES (1, 'Ann'), (2, 'O''Brien')`. The columns are fields of one table, or a whole table. Each row is a
|
|
148
|
+
tuple of values in column order, with at most 1000 rows per INSERT, as in T-SQL. Values are escaped as in
|
|
149
|
+
conditions, plain strings included, and `Raw("DEFAULT")` writes SQL. An Insert stands alone in its query.
|
|
150
|
+
- `Delete(table)` renders `DELETE` and the table's alias, or its name, and works with `From`, joins and
|
|
151
|
+
`Where`: `DELETE acc FROM Accounts acc INNER JOIN ...`. A query with a DELETE but no WHERE raises
|
|
152
|
+
`IncompatibleClausesException`, unless written `Delete(table, where_required=False)`; so does a DELETE with
|
|
153
|
+
ORDER BY.
|
|
154
|
+
- Both work with `build_with_params()`. SQL Server accepts about 2,100 parameters per query, so split big
|
|
155
|
+
parameterized inserts.
|
|
156
|
+
|
|
126
157
|
### 0.3.0
|
|
127
158
|
Queries built with 0.2.0 produce the same SQL, except for the fixed `Update` values below.
|
|
128
159
|
- Conditions are methods of fields, functions on fields and strings: `eq`, `ne`, `lt`, `le`, `gt`, `ge`,
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from collections.abc import Mapping
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
4
|
|
|
5
5
|
from .abstract_clause import Clause
|
|
6
|
-
from .conditions import Condition, Junction, check_operand, render_operand
|
|
6
|
+
from .conditions import Comparable, Condition, Junction, check_operand, render_operand
|
|
7
7
|
from .exceptions import SqlQueryBuilderError
|
|
8
8
|
from .tables import Aliasable, Field, Fields, Table, TempTable
|
|
9
|
+
from .values import render_value
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
class IncorrectUpdateException(SqlQueryBuilderError):
|
|
12
13
|
pass
|
|
13
14
|
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
def _insert_value(value: object) -> object:
|
|
17
|
+
# VALUES cannot read columns: unlike in a condition, a field or expression is no value here.
|
|
18
|
+
if isinstance(value, Comparable):
|
|
19
|
+
raise TypeError(f"Unsupported value type: {type(value).__name__}.")
|
|
20
|
+
return check_operand(value)
|
|
16
21
|
|
|
17
22
|
|
|
18
23
|
def _row_count(keyword: str, value: int, minimum: int) -> int:
|
|
@@ -228,6 +233,93 @@ class Update(Clause):
|
|
|
228
233
|
return f"{self.__class__.__name__}(updates={self.updates})"
|
|
229
234
|
|
|
230
235
|
|
|
236
|
+
class Insert(Clause):
|
|
237
|
+
"""`INSERT INTO table (columns)` and `VALUES (…), (…)`, with the rows added by `values()`.
|
|
238
|
+
|
|
239
|
+
The columns are fields of one table, or whole tables, as `Select` takes them. Unlike in `Update`, a plain
|
|
240
|
+
str is a value here. An Insert stands alone in its query (see `QueryBuilder.build()`).
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
def __init__(self, *columns: Field | Table) -> None:
|
|
244
|
+
tables: list[Table] = []
|
|
245
|
+
self.columns: list[Field] = []
|
|
246
|
+
for column in columns:
|
|
247
|
+
if isinstance(column, Table):
|
|
248
|
+
tables.append(column)
|
|
249
|
+
self.columns.extend(column.fields.as_list())
|
|
250
|
+
elif isinstance(column, Field):
|
|
251
|
+
tables.append(column.table)
|
|
252
|
+
self.columns.append(column)
|
|
253
|
+
else:
|
|
254
|
+
raise TypeError(f"Insert takes fields or tables, got {type(column).__name__}.")
|
|
255
|
+
if not self.columns:
|
|
256
|
+
raise ValueError("Insert needs at least one column.")
|
|
257
|
+
for table in tables[1:]:
|
|
258
|
+
if table.name != tables[0].name:
|
|
259
|
+
raise ValueError(f"Insert columns must come from one table, got '{tables[0].name}' and '{table.name}'.")
|
|
260
|
+
names: set[str] = set()
|
|
261
|
+
for column in self.columns:
|
|
262
|
+
if column.name in names:
|
|
263
|
+
raise ValueError(f"Insert column '{column.name}' appears twice.")
|
|
264
|
+
names.add(column.name)
|
|
265
|
+
self.table: Table = tables[0]
|
|
266
|
+
self.rows: list[tuple[object, ...]] = []
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def place(self) -> int:
|
|
270
|
+
return 0
|
|
271
|
+
|
|
272
|
+
def values(self, *rows: Sequence[object]) -> Insert:
|
|
273
|
+
"""Adds rows, each a tuple or list of values in column order, and returns the Insert."""
|
|
274
|
+
if len(self.rows) + len(rows) > 1000:
|
|
275
|
+
raise ValueError(f"An INSERT takes at most 1000 rows, got {len(self.rows) + len(rows)}.")
|
|
276
|
+
checked: list[tuple[object, ...]] = []
|
|
277
|
+
expected: str = f"{len(self.columns)} value{'s' if len(self.columns) > 1 else ''}"
|
|
278
|
+
for number, row in enumerate(rows, start=len(self.rows) + 1):
|
|
279
|
+
if isinstance(row, (str, bytes)) or not isinstance(row, Sequence):
|
|
280
|
+
raise TypeError(f"An Insert row must be a tuple or list of values, got {type(row).__name__}.")
|
|
281
|
+
if len(row) != len(self.columns):
|
|
282
|
+
raise ValueError(f"Insert row {number} needs {expected}, got {len(row)}.")
|
|
283
|
+
checked.append(tuple(_insert_value(value) for value in row))
|
|
284
|
+
self.rows.extend(checked)
|
|
285
|
+
return self
|
|
286
|
+
|
|
287
|
+
def build(self) -> str:
|
|
288
|
+
if not self.rows:
|
|
289
|
+
raise ValueError("The Insert has no rows: add them with values().")
|
|
290
|
+
# T-SQL refuses an alias after INSERT INTO, and qualified column names.
|
|
291
|
+
columns: str = ", ".join(column.name for column in self.columns)
|
|
292
|
+
rows: str = ", ".join(f"({', '.join(render_value(value) for value in row)})" for row in self.rows)
|
|
293
|
+
return f"INSERT INTO {self.table.name} ({columns})\nVALUES {rows}"
|
|
294
|
+
|
|
295
|
+
def __repr__(self) -> str:
|
|
296
|
+
return f"{self.__class__.__name__}(columns={self.columns}, rows={len(self.rows)})"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class Delete(Clause):
|
|
300
|
+
"""`DELETE` and the table's alias, or its name; T-SQL reads an alias from `From(table)`, as for `Update`.
|
|
301
|
+
|
|
302
|
+
A query with a Delete needs a Where unless `where_required=False`: an empty filter would delete every row.
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
def __init__(self, table: Table, *, where_required: bool = True) -> None:
|
|
306
|
+
if not isinstance(table, Table):
|
|
307
|
+
raise TypeError(f"Delete takes a Table, got {type(table).__name__}.")
|
|
308
|
+
self.table: Table = table
|
|
309
|
+
self.where_required: bool = where_required
|
|
310
|
+
|
|
311
|
+
@property
|
|
312
|
+
def place(self) -> int:
|
|
313
|
+
return 0
|
|
314
|
+
|
|
315
|
+
def build(self) -> str:
|
|
316
|
+
return f"DELETE {self.table.build(use_name=False, use_alias=True)}"
|
|
317
|
+
|
|
318
|
+
def __repr__(self) -> str:
|
|
319
|
+
option: str = "" if self.where_required else ", where_required=False"
|
|
320
|
+
return f"{self.__class__.__name__}(table={self.table}{option})"
|
|
321
|
+
|
|
322
|
+
|
|
231
323
|
class OrderBy(Clause):
|
|
232
324
|
def __init__(self, field: Aliasable, descending: bool = False) -> None:
|
|
233
325
|
self.ordering: list[tuple[Aliasable, bool]] = [(field, descending)]
|
|
@@ -4,7 +4,7 @@ from collections.abc import Iterable
|
|
|
4
4
|
from typing import Literal
|
|
5
5
|
|
|
6
6
|
from .abstract_clause import Clause
|
|
7
|
-
from .clauses import OrderBy, Select
|
|
7
|
+
from .clauses import Delete, Insert, OrderBy, Select, Where
|
|
8
8
|
from .exceptions import SqlQueryBuilderError
|
|
9
9
|
from .values import parameterize
|
|
10
10
|
|
|
@@ -17,6 +17,14 @@ class IncompatibleClausesException(SqlQueryBuilderError):
|
|
|
17
17
|
pass
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
def _describe(clause: Clause) -> str:
|
|
21
|
+
"""A clause's SQL for an error message, or its repr while it cannot be built (an Insert without rows)."""
|
|
22
|
+
try:
|
|
23
|
+
return clause.build()
|
|
24
|
+
except ValueError:
|
|
25
|
+
return repr(clause)
|
|
26
|
+
|
|
27
|
+
|
|
20
28
|
class QueryBuilder:
|
|
21
29
|
def __init__(self, *initial_clauses: Clause) -> None:
|
|
22
30
|
self.places: dict[int, list[Clause]] = {}
|
|
@@ -27,7 +35,7 @@ class QueryBuilder:
|
|
|
27
35
|
old_clause: Clause | None = self._place_is_unavailable(clause)
|
|
28
36
|
if old_clause is not None:
|
|
29
37
|
raise UnavailablePlaceException(
|
|
30
|
-
f"Cannot add a new clause with the same exclusive place in the query. New clause='{clause
|
|
38
|
+
f"Cannot add a new clause with the same exclusive place in the query. New clause='{_describe(clause)}'. Old clause='{_describe(old_clause)}'"
|
|
31
39
|
)
|
|
32
40
|
self.places[clause.place] = self.places.get(clause.place, []) + [clause]
|
|
33
41
|
return self
|
|
@@ -39,12 +47,26 @@ class QueryBuilder:
|
|
|
39
47
|
|
|
40
48
|
def build(self) -> str:
|
|
41
49
|
clauses: list[Clause] = self._sorted_clauses()
|
|
42
|
-
|
|
50
|
+
self._check_combination(clauses)
|
|
51
|
+
return "\n".join(clause.build() for clause in clauses)
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def _check_combination(clauses: list[Clause]) -> None:
|
|
55
|
+
"""Rules across clauses, checked at build time: clauses stay mutable after being added."""
|
|
43
56
|
has_top: bool = any(isinstance(clause, Select) and clause.top is not None for clause in clauses)
|
|
44
57
|
has_paging: bool = any(isinstance(clause, OrderBy) and clause.has_paging for clause in clauses)
|
|
45
58
|
if has_top and has_paging:
|
|
46
59
|
raise IncompatibleClausesException("TOP cannot be combined with OFFSET/FETCH in the same query.")
|
|
47
|
-
|
|
60
|
+
if len(clauses) > 1 and any(isinstance(clause, Insert) for clause in clauses):
|
|
61
|
+
raise IncompatibleClausesException("INSERT cannot be combined with other clauses in the same query.")
|
|
62
|
+
deletes: list[Delete] = [clause for clause in clauses if isinstance(clause, Delete)]
|
|
63
|
+
if deletes:
|
|
64
|
+
if any(isinstance(clause, OrderBy) for clause in clauses):
|
|
65
|
+
raise IncompatibleClausesException("ORDER BY cannot be combined with DELETE in the same query.")
|
|
66
|
+
if deletes[0].where_required and not any(isinstance(clause, Where) for clause in clauses):
|
|
67
|
+
raise IncompatibleClausesException(
|
|
68
|
+
"A DELETE needs a WHERE, unless built with Delete(table, where_required=False)."
|
|
69
|
+
)
|
|
48
70
|
|
|
49
71
|
def build_with_params(self, style: Literal["qmark", "format"] = "qmark") -> tuple[str, tuple[object, ...]]:
|
|
50
72
|
"""Builds the query with a placeholder for each value, and returns it with the values, in order.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sql_querystring_builder
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Wraps string building of MS SQL queries
|
|
5
5
|
Author-email: Wael GRIBAA <g.wael@outlook.fr>
|
|
6
6
|
License-Expression: GPL-3.0-only
|
|
@@ -35,6 +35,8 @@ It provides first level validation.
|
|
|
35
35
|
2. Using:
|
|
36
36
|
- [x] Select (with DISTINCT and TOP)
|
|
37
37
|
- [x] Update (with typed values)
|
|
38
|
+
- [x] Insert (multi-row VALUES)
|
|
39
|
+
- [x] Delete (with joins; refused without a WHERE unless asked)
|
|
38
40
|
- [x] From
|
|
39
41
|
- [x] InnerJoin, LeftJoin, RightJoin, FullJoin (on two fields or on any condition), CrossJoin
|
|
40
42
|
- [x] Where (with conditions)
|
|
@@ -47,7 +49,6 @@ It provides first level validation.
|
|
|
47
49
|
|
|
48
50
|
## Not implemented (yet)
|
|
49
51
|
In planned order:
|
|
50
|
-
- [ ] Insert and Delete
|
|
51
52
|
- [ ] Functions (aggregates and others), GROUP BY, HAVING, CASE
|
|
52
53
|
- [ ] Compound queries (UNION, INTERSECT, EXCEPT) and sub-queries
|
|
53
54
|
|
|
@@ -134,6 +135,23 @@ print(params)
|
|
|
134
135
|
# > (20000, 'A1C%')
|
|
135
136
|
# With pyodbc: cursor.execute(sql, params). For pymssql, build_with_params("format") writes %s instead.
|
|
136
137
|
```
|
|
138
|
+
6. Insert and delete rows:
|
|
139
|
+
```
|
|
140
|
+
# Inserts rows: one tuple per row, with the values in column order
|
|
141
|
+
insert = Insert(customers_table.fields.customerId, customers_table.fields.mainPhone).values(
|
|
142
|
+
("A1C42", "555-0100"), ("B7", None)
|
|
143
|
+
)
|
|
144
|
+
print(QueryBuilder(insert).build())
|
|
145
|
+
# > INSERT INTO Customers (customerId, mainPhone)
|
|
146
|
+
# > VALUES ('A1C42', '555-0100'), ('B7', NULL)
|
|
147
|
+
|
|
148
|
+
# Deletes the accounts of customer B7; a DELETE needs a WHERE, unless built with where_required=False
|
|
149
|
+
delete = QueryBuilder(Delete(accounts_table), From(accounts_table), Where(accounts_table.fields.customerId.eq("B7")))
|
|
150
|
+
print(delete.build())
|
|
151
|
+
# > DELETE acc
|
|
152
|
+
# > FROM Accounts acc
|
|
153
|
+
# > WHERE acc.customerId = 'B7'
|
|
154
|
+
```
|
|
137
155
|
|
|
138
156
|
(Find it directly in the module example.py)
|
|
139
157
|
|
|
@@ -144,6 +162,19 @@ Python 3.10 and above.
|
|
|
144
162
|
(None)
|
|
145
163
|
|
|
146
164
|
## Changelog
|
|
165
|
+
### 0.4.0
|
|
166
|
+
Queries built with 0.3.0 produce the same SQL.
|
|
167
|
+
- `Insert(*columns).values(*rows)` renders `INSERT INTO Customers (id, name)` and
|
|
168
|
+
`VALUES (1, 'Ann'), (2, 'O''Brien')`. The columns are fields of one table, or a whole table. Each row is a
|
|
169
|
+
tuple of values in column order, with at most 1000 rows per INSERT, as in T-SQL. Values are escaped as in
|
|
170
|
+
conditions, plain strings included, and `Raw("DEFAULT")` writes SQL. An Insert stands alone in its query.
|
|
171
|
+
- `Delete(table)` renders `DELETE` and the table's alias, or its name, and works with `From`, joins and
|
|
172
|
+
`Where`: `DELETE acc FROM Accounts acc INNER JOIN ...`. A query with a DELETE but no WHERE raises
|
|
173
|
+
`IncompatibleClausesException`, unless written `Delete(table, where_required=False)`; so does a DELETE with
|
|
174
|
+
ORDER BY.
|
|
175
|
+
- Both work with `build_with_params()`. SQL Server accepts about 2,100 parameters per query, so split big
|
|
176
|
+
parameterized inserts.
|
|
177
|
+
|
|
147
178
|
### 0.3.0
|
|
148
179
|
Queries built with 0.2.0 produce the same SQL, except for the fixed `Update` values below.
|
|
149
180
|
- Conditions are methods of fields, functions on fields and strings: `eq`, `ne`, `lt`, `le`, `gt`, `ge`,
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from datetime import date
|
|
3
|
+
from unittest import TestCase
|
|
4
|
+
|
|
5
|
+
from sql_querystring_builder.clauses import Delete, From, InnerJoin, Insert, OrderBy, Select, Update, Where
|
|
6
|
+
from sql_querystring_builder.conditions import Condition
|
|
7
|
+
from sql_querystring_builder.query_builder import (
|
|
8
|
+
IncompatibleClausesException,
|
|
9
|
+
QueryBuilder,
|
|
10
|
+
UnavailablePlaceException,
|
|
11
|
+
)
|
|
12
|
+
from sql_querystring_builder.tables import Table, TempTable
|
|
13
|
+
from sql_querystring_builder.values import NStr, Raw, Str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestInsert(TestCase):
|
|
17
|
+
def setUp(self) -> None:
|
|
18
|
+
self.maxDiff = None
|
|
19
|
+
self.customers: Table = Table("Customers", "c", field_names=["id", "name", "phone"])
|
|
20
|
+
|
|
21
|
+
def test_rows(self):
|
|
22
|
+
c = self.customers.fields
|
|
23
|
+
insert = Insert(c.id, c.name).values((1, "Ann"), [2, "O'Brien"]).values((3, None))
|
|
24
|
+
expected_result: str = "INSERT INTO Customers (id, name)\nVALUES (1, 'Ann'), (2, 'O''Brien'), (3, NULL)"
|
|
25
|
+
self.assertEqual(insert.build(), expected_result)
|
|
26
|
+
|
|
27
|
+
def test_whole_table_without_aliases(self):
|
|
28
|
+
# T-SQL refuses an alias after INSERT INTO, and qualified column names: neither is written.
|
|
29
|
+
self.customers.fields.name.set_alias("customerName")
|
|
30
|
+
actual_result: str = Insert(self.customers).values((1, "Ann", "555-0100")).build()
|
|
31
|
+
self.assertEqual(actual_result, "INSERT INTO Customers (id, name, phone)\nVALUES (1, 'Ann', '555-0100')")
|
|
32
|
+
|
|
33
|
+
def test_temporary_and_bracketed_tables(self):
|
|
34
|
+
temp_table = TempTable("t", "a")
|
|
35
|
+
bracketed = Table("dbo.[My Table]", field_names=["x"])
|
|
36
|
+
self.assertEqual(Insert(temp_table).values((1,)).build(), "INSERT INTO #t (a)\nVALUES (1)")
|
|
37
|
+
self.assertEqual(Insert(bracketed).values((1,)).build(), "INSERT INTO dbo.[My Table] (x)\nVALUES (1)")
|
|
38
|
+
|
|
39
|
+
def test_values(self):
|
|
40
|
+
c = self.customers.fields
|
|
41
|
+
insert = Insert(c.id, c.name, c.phone).values(
|
|
42
|
+
(Raw("DEFAULT"), NStr("Zoé"), date(2026, 10, 11)), (True, "", 1.5)
|
|
43
|
+
)
|
|
44
|
+
expected_result: str = (
|
|
45
|
+
"INSERT INTO Customers (id, name, phone)\nVALUES (DEFAULT, N'Zoé', '20261011'), (1, '', 1.5)"
|
|
46
|
+
)
|
|
47
|
+
self.assertEqual(insert.build(), expected_result)
|
|
48
|
+
|
|
49
|
+
def test_columns(self):
|
|
50
|
+
c = self.customers.fields
|
|
51
|
+
accounts = Table("Accounts", field_names=["id"])
|
|
52
|
+
expected_err_msgs: dict[str, tuple[type, Callable[[], Insert]]] = {
|
|
53
|
+
"Insert needs at least one column.": (ValueError, lambda: Insert()),
|
|
54
|
+
"Insert takes fields or tables, got str.": (TypeError, lambda: Insert("id")),
|
|
55
|
+
"Insert columns must come from one table, got 'Customers' and 'Accounts'.": (
|
|
56
|
+
ValueError,
|
|
57
|
+
lambda: Insert(c.id, accounts.fields.id),
|
|
58
|
+
),
|
|
59
|
+
"Insert column 'id' appears twice.": (ValueError, lambda: Insert(c.id, self.customers)),
|
|
60
|
+
}
|
|
61
|
+
for expected_err_msg, (error, make_insert) in expected_err_msgs.items():
|
|
62
|
+
with self.subTest(expected_err_msg=expected_err_msg):
|
|
63
|
+
with self.assertRaises(error) as exc:
|
|
64
|
+
make_insert()
|
|
65
|
+
self.assertEqual(str(exc.exception), expected_err_msg)
|
|
66
|
+
with self.assertRaises(ValueError) as exc:
|
|
67
|
+
Insert(Table("Empty"))
|
|
68
|
+
self.assertEqual(str(exc.exception), "Insert needs at least one column.")
|
|
69
|
+
|
|
70
|
+
def test_rows_are_checked_when_added(self):
|
|
71
|
+
c = self.customers.fields
|
|
72
|
+
insert = Insert(c.id, c.name).values((1, "Ann"))
|
|
73
|
+
expected_err_msgs: dict[str, tuple[type, tuple[object, ...]]] = {
|
|
74
|
+
"Insert row 2 needs 2 values, got 1.": (ValueError, ((2,),)),
|
|
75
|
+
"Insert row 3 needs 2 values, got 3.": (ValueError, ((2, "Bob"), (3, "Cy", "x"))),
|
|
76
|
+
"An Insert row must be a tuple or list of values, got str.": (TypeError, ("ab",)),
|
|
77
|
+
"An Insert row must be a tuple or list of values, got dict.": (TypeError, ({c.id: 1, c.name: "x"},)),
|
|
78
|
+
"Unsupported value type: list.": (TypeError, ((2, ["Bob"]),)),
|
|
79
|
+
"Unsupported value type: Field.": (TypeError, ((2, c.phone),)), # VALUES cannot read columns
|
|
80
|
+
"Unsupported value type: Condition.": (TypeError, ((2, c.id.eq(1)),)),
|
|
81
|
+
"nan is not a valid T-SQL number.": (ValueError, ((float("nan"), "Bob"),)),
|
|
82
|
+
}
|
|
83
|
+
for expected_err_msg, (error, rows) in expected_err_msgs.items():
|
|
84
|
+
with self.subTest(expected_err_msg=expected_err_msg):
|
|
85
|
+
with self.assertRaises(error) as exc:
|
|
86
|
+
insert.values(*rows)
|
|
87
|
+
self.assertEqual(str(exc.exception), expected_err_msg)
|
|
88
|
+
self.assertEqual(len(insert.rows), 1) # a refused call adds none of its rows
|
|
89
|
+
one_column = Insert(c.id)
|
|
90
|
+
with self.assertRaises(ValueError) as exc:
|
|
91
|
+
one_column.values((1, 2))
|
|
92
|
+
self.assertEqual(str(exc.exception), "Insert row 1 needs 1 value, got 2.")
|
|
93
|
+
|
|
94
|
+
def test_at_most_1000_rows(self):
|
|
95
|
+
insert = Insert(self.customers.fields.id).values(*[(n,) for n in range(999)])
|
|
96
|
+
with self.assertRaises(ValueError) as exc:
|
|
97
|
+
insert.values((999,), (1000,)) # the first row would fit, but a call adds its rows all or none
|
|
98
|
+
self.assertEqual(str(exc.exception), "An INSERT takes at most 1000 rows, got 1001.")
|
|
99
|
+
self.assertEqual(len(insert.rows), 999)
|
|
100
|
+
insert.values((999,))
|
|
101
|
+
with self.assertRaises(ValueError) as exc:
|
|
102
|
+
insert.values((1000,))
|
|
103
|
+
self.assertEqual(str(exc.exception), "An INSERT takes at most 1000 rows, got 1001.")
|
|
104
|
+
self.assertEqual(len(insert.rows), 1000)
|
|
105
|
+
|
|
106
|
+
def test_no_rows(self):
|
|
107
|
+
with self.assertRaises(ValueError) as exc:
|
|
108
|
+
Insert(self.customers).build()
|
|
109
|
+
self.assertEqual(str(exc.exception), "The Insert has no rows: add them with values().")
|
|
110
|
+
|
|
111
|
+
def test_repr(self):
|
|
112
|
+
insert = Insert(self.customers.fields.id).values((1,), (2,))
|
|
113
|
+
self.assertEqual(repr(insert), "Insert(columns=[Field(name='id', alias='None', table='Customers')], rows=2)")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TestInsertQueries(TestCase):
|
|
117
|
+
def setUp(self) -> None:
|
|
118
|
+
self.customers: Table = Table("Customers", "c", field_names=["id", "name"])
|
|
119
|
+
c = self.customers.fields
|
|
120
|
+
self.insert: Insert = Insert(c.id, c.name).values((1, "O'Brien"), (2, None), (Raw("DEFAULT"), "Bob"))
|
|
121
|
+
|
|
122
|
+
def test_insert_alone(self):
|
|
123
|
+
expected_result: str = "INSERT INTO Customers (id, name)\nVALUES (1, 'O''Brien'), (2, NULL), (DEFAULT, 'Bob')"
|
|
124
|
+
self.assertEqual(QueryBuilder(self.insert).build(), expected_result)
|
|
125
|
+
|
|
126
|
+
def test_parameters(self):
|
|
127
|
+
expected_result: tuple[str, tuple[object, ...]] = (
|
|
128
|
+
"INSERT INTO Customers (id, name)\nVALUES (?, ?), (?, NULL), (DEFAULT, ?)",
|
|
129
|
+
(1, "O'Brien", 2, "Bob"),
|
|
130
|
+
)
|
|
131
|
+
self.assertEqual(QueryBuilder(self.insert).build_with_params(), expected_result)
|
|
132
|
+
|
|
133
|
+
def test_insert_stands_alone(self):
|
|
134
|
+
query_builder = QueryBuilder(self.insert, Where(self.customers.fields.id.eq(1)))
|
|
135
|
+
with self.assertRaises(IncompatibleClausesException) as exc:
|
|
136
|
+
query_builder.build()
|
|
137
|
+
self.assertEqual(str(exc.exception), "INSERT cannot be combined with other clauses in the same query.")
|
|
138
|
+
with self.assertRaises(UnavailablePlaceException):
|
|
139
|
+
QueryBuilder(self.insert, Select(self.customers))
|
|
140
|
+
|
|
141
|
+
def test_clash_with_an_insert_without_rows(self):
|
|
142
|
+
# An Insert without rows cannot be built, so the message shows its repr instead of hiding the clash.
|
|
143
|
+
with self.assertRaises(UnavailablePlaceException) as exc:
|
|
144
|
+
QueryBuilder(Select(self.customers.fields.name), Insert(self.customers.fields.id))
|
|
145
|
+
self.assertEqual(
|
|
146
|
+
str(exc.exception),
|
|
147
|
+
"Cannot add a new clause with the same exclusive place in the query. "
|
|
148
|
+
"New clause='Insert(columns=[Field(name='id', alias='None', table='Customers')], rows=0)'. "
|
|
149
|
+
"Old clause='SELECT c.name'",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class TestDelete(TestCase):
|
|
154
|
+
def setUp(self) -> None:
|
|
155
|
+
self.maxDiff = None
|
|
156
|
+
self.customers: Table = Table("Customers", "c", field_names=["id", "name"])
|
|
157
|
+
self.accounts: Table = Table("Accounts", "acc", field_names=["id", "customerId", "closed"])
|
|
158
|
+
self.logs: Table = Table("Logs", field_names=["id", "level"])
|
|
159
|
+
|
|
160
|
+
def test_delete_with_a_join(self):
|
|
161
|
+
accounts, customers = self.accounts, self.customers
|
|
162
|
+
query_builder = QueryBuilder(
|
|
163
|
+
Delete(accounts),
|
|
164
|
+
From(accounts),
|
|
165
|
+
InnerJoin(customers, on=customers.fields.id.eq(accounts.fields.customerId)),
|
|
166
|
+
Where(customers.fields.name.eq("O'Brien") & accounts.fields.closed.eq(True)),
|
|
167
|
+
)
|
|
168
|
+
expected_result: str = """DELETE acc
|
|
169
|
+
FROM Accounts acc
|
|
170
|
+
INNER JOIN Customers c on c.id = acc.customerId
|
|
171
|
+
WHERE c.name = 'O''Brien' AND acc.closed = 1"""
|
|
172
|
+
self.assertEqual(query_builder.build(), expected_result)
|
|
173
|
+
|
|
174
|
+
def test_delete_without_alias(self):
|
|
175
|
+
query_builder = QueryBuilder(Delete(self.logs), Where(self.logs.fields.id.eq(1)))
|
|
176
|
+
self.assertEqual(query_builder.build(), "DELETE Logs\nWHERE Logs.id = 1")
|
|
177
|
+
|
|
178
|
+
def test_parameters(self):
|
|
179
|
+
query_builder = QueryBuilder(Delete(self.logs), Where(self.logs.fields.level.in_(["debug", "trace"])))
|
|
180
|
+
expected_result: tuple[str, tuple[object, ...]] = (
|
|
181
|
+
"DELETE Logs\nWHERE Logs.level IN (?, ?)",
|
|
182
|
+
("debug", "trace"),
|
|
183
|
+
)
|
|
184
|
+
self.assertEqual(query_builder.build_with_params(), expected_result)
|
|
185
|
+
|
|
186
|
+
def test_a_where_is_required(self):
|
|
187
|
+
expected_err_msg: str = "A DELETE needs a WHERE, unless built with Delete(table, where_required=False)."
|
|
188
|
+
on: Condition = self.customers.fields.id.eq(self.accounts.fields.customerId)
|
|
189
|
+
query_builders: dict[str, QueryBuilder] = {
|
|
190
|
+
"alone": QueryBuilder(Delete(self.logs)),
|
|
191
|
+
"a join is no WHERE": QueryBuilder(
|
|
192
|
+
Delete(self.accounts), From(self.accounts), InnerJoin(self.customers, on=on)
|
|
193
|
+
),
|
|
194
|
+
}
|
|
195
|
+
for name, query_builder in query_builders.items():
|
|
196
|
+
with self.subTest(name=name):
|
|
197
|
+
with self.assertRaises(IncompatibleClausesException) as exc:
|
|
198
|
+
query_builder.build()
|
|
199
|
+
self.assertEqual(str(exc.exception), expected_err_msg)
|
|
200
|
+
self.assertEqual(QueryBuilder(Delete(self.logs, where_required=False)).build(), "DELETE Logs")
|
|
201
|
+
|
|
202
|
+
def test_the_where_is_checked_when_building(self):
|
|
203
|
+
query_builder = QueryBuilder(Delete(self.logs))
|
|
204
|
+
query_builder.add(Where(self.logs.fields.level.eq("debug")))
|
|
205
|
+
self.assertEqual(query_builder.build(), "DELETE Logs\nWHERE Logs.level = 'debug'")
|
|
206
|
+
|
|
207
|
+
def test_order_by_is_refused(self):
|
|
208
|
+
logs = self.logs.fields
|
|
209
|
+
query_builder = QueryBuilder(Delete(self.logs), Where(logs.id.gt(1)), OrderBy(logs.id))
|
|
210
|
+
with self.assertRaises(IncompatibleClausesException) as exc:
|
|
211
|
+
query_builder.build()
|
|
212
|
+
self.assertEqual(str(exc.exception), "ORDER BY cannot be combined with DELETE in the same query.")
|
|
213
|
+
|
|
214
|
+
def test_one_statement_per_query(self):
|
|
215
|
+
logs = self.logs.fields
|
|
216
|
+
for other in (Select(self.logs), Update({logs.level: Str("x")}), Insert(self.logs).values((1, "x"))):
|
|
217
|
+
with self.subTest(other=type(other).__name__), self.assertRaises(UnavailablePlaceException):
|
|
218
|
+
QueryBuilder(Delete(self.logs), other)
|
|
219
|
+
|
|
220
|
+
def test_delete_takes_a_table(self):
|
|
221
|
+
with self.assertRaises(TypeError) as exc:
|
|
222
|
+
Delete("Logs")
|
|
223
|
+
self.assertEqual(str(exc.exception), "Delete takes a Table, got str.")
|
|
224
|
+
|
|
225
|
+
def test_repr(self):
|
|
226
|
+
self.assertTrue(repr(Delete(self.logs)).startswith("Delete(table=Table(name='Logs'"))
|
|
227
|
+
self.assertTrue(repr(Delete(self.logs, where_required=False)).endswith(")), where_required=False)"))
|
|
@@ -15,10 +15,12 @@ from sqlglot.errors import SqlglotError
|
|
|
15
15
|
from sql_querystring_builder import (
|
|
16
16
|
AliasableString,
|
|
17
17
|
CrossJoin,
|
|
18
|
+
Delete,
|
|
18
19
|
From,
|
|
19
20
|
FullJoin,
|
|
20
21
|
FunctionOnField,
|
|
21
22
|
InnerJoin,
|
|
23
|
+
Insert,
|
|
22
24
|
LeftJoin,
|
|
23
25
|
NStr,
|
|
24
26
|
OrderBy,
|
|
@@ -134,15 +136,34 @@ class TestSqlSyntax(TestCase):
|
|
|
134
136
|
From(accounts),
|
|
135
137
|
Where(accounts.fields.id.eq(1)),
|
|
136
138
|
).build_with_params()[0],
|
|
139
|
+
# Accounts has an alias, which must not reach the INSERT.
|
|
140
|
+
"insert": QueryBuilder(
|
|
141
|
+
Insert(accounts.fields.id, accounts.fields.customerId).values(
|
|
142
|
+
(1, "O'Brien"), (Raw("DEFAULT"), NStr("é")), (3, None)
|
|
143
|
+
)
|
|
144
|
+
).build(),
|
|
145
|
+
"parameterized insert": QueryBuilder(Insert(accounts).values((1, "x", 2, b"\x01"))).build_with_params()[0],
|
|
146
|
+
"delete with a join": QueryBuilder(
|
|
147
|
+
Delete(accounts),
|
|
148
|
+
From(accounts),
|
|
149
|
+
InnerJoin(customers, on=customers.fields.id.eq(accounts.fields.customerId)),
|
|
150
|
+
Where(customers.fields.mainPhone.like("555%") & accounts.fields.card.eq(None)),
|
|
151
|
+
).build(),
|
|
152
|
+
"delete without alias": QueryBuilder(Delete(customers), Where(customers.fields.id.eq(1))).build(),
|
|
153
|
+
"parameterized delete": QueryBuilder(
|
|
154
|
+
Delete(customers), Where(customers.fields.id.in_([1, 2]))
|
|
155
|
+
).build_with_params()[0],
|
|
137
156
|
}
|
|
138
157
|
|
|
139
158
|
def test_known_bad_output_is_rejected(self):
|
|
140
|
-
# Outputs the library produced before 0.1.0,
|
|
141
|
-
# would prove nothing.
|
|
159
|
+
# Outputs the library produced before 0.1.0, an unescaped quote, and the two INSERT forms T-SQL refuses
|
|
160
|
+
# (an alias after INSERT INTO, a qualified column): if these parsed, the test below would prove nothing.
|
|
142
161
|
for sql in (
|
|
143
162
|
"UPDATE acc\nSET acc.a AS A=1, acc.b=2",
|
|
144
163
|
"SELECT #tt.id, #tt.sum of card\nFROM #tt",
|
|
145
164
|
"SELECT c.id\nFROM Customers c\nWHERE c.name = 'O'Brien'",
|
|
165
|
+
"INSERT INTO Accounts acc (id)\nVALUES (1)",
|
|
166
|
+
"INSERT INTO Accounts (acc.id)\nVALUES (1)",
|
|
146
167
|
):
|
|
147
168
|
with self.subTest(sql=sql), self.assertRaises(SqlglotError):
|
|
148
169
|
sqlglot.parse_one(sql, read="tsql")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sql_querystring_builder-0.3.0 → sql_querystring_builder-0.4.0}/src/sql_querystring_builder/py.typed
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
|