SARepo 0.1.6__py3-none-any.whl → 0.1.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- SARepo/repo.py +97 -38
- SARepo/sa_repo.py +285 -104
- sarepo-0.1.8.dist-info/METADATA +157 -0
- sarepo-0.1.8.dist-info/RECORD +12 -0
- sarepo-0.1.6.dist-info/METADATA +0 -90
- sarepo-0.1.6.dist-info/RECORD +0 -12
- {sarepo-0.1.6.dist-info → sarepo-0.1.8.dist-info}/WHEEL +0 -0
- {sarepo-0.1.6.dist-info → sarepo-0.1.8.dist-info}/licenses/LICENSE +0 -0
- {sarepo-0.1.6.dist-info → sarepo-0.1.8.dist-info}/top_level.txt +0 -0
SARepo/repo.py
CHANGED
@@ -1,50 +1,109 @@
|
|
1
|
+
from __future__ import annotations
|
1
2
|
|
2
|
-
from typing import Generic, List,
|
3
|
+
from typing import Any, Generic, List, Optional, Protocol, Type, TypeVar, runtime_checkable
|
3
4
|
|
4
5
|
from SARepo.sa_repo import Spec
|
5
6
|
from .base import Page, PageRequest
|
6
7
|
|
7
8
|
T = TypeVar("T")
|
8
9
|
|
10
|
+
|
11
|
+
@runtime_checkable
|
9
12
|
class CrudRepository(Protocol, Generic[T]):
|
13
|
+
"""
|
14
|
+
Контракт CRUD-репозитория для SQLAlchemy-подобной реализации.
|
15
|
+
Реализация должна соответствовать сигнатурам ниже.
|
16
|
+
"""
|
17
|
+
|
10
18
|
model: Type[T]
|
11
|
-
|
12
|
-
|
13
|
-
def
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
+
|
20
|
+
|
21
|
+
def getAll(
|
22
|
+
self,
|
23
|
+
limit: Optional[int] = None,
|
24
|
+
*,
|
25
|
+
include_deleted: bool = False,
|
26
|
+
order_by=None,
|
27
|
+
**filters: Any,
|
28
|
+
) -> List[T]:
|
29
|
+
...
|
30
|
+
|
31
|
+
def get(self, id_: Any = None, *, include_deleted: bool = False, **filters: Any) -> T:
|
32
|
+
"""
|
33
|
+
Должен вернуть объект T или бросить исключение (например, NotFoundError),
|
34
|
+
если объект не найден. Если хочешь Optional — измени сигнатуру.
|
35
|
+
"""
|
36
|
+
...
|
37
|
+
|
38
|
+
def try_get(self, id_: Any = None, *, include_deleted: bool = False, **filters: Any) -> Optional[T]:
|
39
|
+
...
|
40
|
+
|
41
|
+
def add(self, entity: T) -> T:
|
42
|
+
...
|
43
|
+
|
44
|
+
def update(self, entity: T) -> T:
|
45
|
+
...
|
46
|
+
|
47
|
+
def remove(self, entity: Optional[T] = None, id: Optional[Any] = None) -> bool:
|
48
|
+
...
|
49
|
+
|
50
|
+
def delete_by_id(self, id_: Any) -> bool:
|
51
|
+
...
|
52
|
+
|
53
|
+
def page(
|
54
|
+
self,
|
55
|
+
page: PageRequest,
|
56
|
+
spec: Optional[Spec] = None,
|
57
|
+
order_by=None,
|
58
|
+
*,
|
59
|
+
include_deleted: bool = False,
|
60
|
+
) -> Page[T]:
|
61
|
+
...
|
62
|
+
|
19
63
|
def get_all_by_column(
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
) -> list[T]:
|
64
|
+
self,
|
65
|
+
column_name: str,
|
66
|
+
value: Any,
|
67
|
+
*,
|
68
|
+
limit: Optional[int] = None,
|
69
|
+
order_by=None,
|
70
|
+
include_deleted: bool = False,
|
71
|
+
**extra_filters: Any,
|
72
|
+
) -> list[T]:
|
73
|
+
...
|
74
|
+
|
29
75
|
def find_all_by_column(
|
30
|
-
self,
|
31
|
-
column_name: str,
|
32
|
-
value: Any,
|
33
|
-
*,
|
34
|
-
limit: Optional[int] = None,
|
35
|
-
order_by=None,
|
36
|
-
include_deleted: bool = False,
|
37
|
-
**extra_filters
|
38
|
-
) -> list[T]: ...
|
39
|
-
def get_or_create(
|
40
76
|
self,
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
77
|
+
column_name: str,
|
78
|
+
value: Any,
|
79
|
+
*,
|
80
|
+
limit: Optional[int] = None,
|
81
|
+
order_by=None,
|
82
|
+
include_deleted: bool = False,
|
83
|
+
**extra_filters: Any,
|
84
|
+
) -> list[T]:
|
85
|
+
...
|
86
|
+
|
87
|
+
def get_or_create(self, defaults: Optional[dict] = None, **unique_filters: Any) -> tuple[T, bool]:
|
88
|
+
...
|
89
|
+
|
90
|
+
def raw_query(self, sql: str, params: Optional[dict] = None) -> list[dict]:
|
91
|
+
...
|
92
|
+
|
93
|
+
def aggregate_avg(self, column_name: str, **filters: Any) -> Optional[float]:
|
94
|
+
...
|
95
|
+
|
96
|
+
def aggregate_min(self, column_name: str, **filters: Any):
|
97
|
+
...
|
98
|
+
|
99
|
+
def aggregate_max(self, column_name: str, **filters: Any):
|
100
|
+
...
|
101
|
+
|
102
|
+
def aggregate_sum(self, column_name: str, **filters: Any):
|
103
|
+
...
|
104
|
+
|
105
|
+
def count(self, **filters: Any) -> int:
|
106
|
+
...
|
107
|
+
|
108
|
+
def restore(self, id_: Any) -> bool:
|
109
|
+
...
|
SARepo/sa_repo.py
CHANGED
@@ -1,19 +1,22 @@
|
|
1
|
-
|
2
|
-
from typing import List, Type, Generic, TypeVar, Optional, Sequence, Any, Callable
|
1
|
+
from typing import List, Type, Generic, TypeVar, Optional, Sequence, Any, Callable, Tuple
|
3
2
|
from sqlalchemy.orm import Session
|
4
3
|
from sqlalchemy.ext.asyncio import AsyncSession
|
5
|
-
from sqlalchemy import inspect, select, func, text
|
4
|
+
from sqlalchemy import inspect, select, func, text, insert, update, and_
|
6
5
|
from .base import PageRequest, Page, NotFoundError
|
7
6
|
|
8
7
|
T = TypeVar("T")
|
9
8
|
Spec = Callable
|
10
9
|
|
10
|
+
|
11
11
|
class SARepository(Generic[T]):
|
12
|
-
"""
|
12
|
+
"""Sync repository implementation for SQLAlchemy 2.x (ORM Session)."""
|
13
|
+
|
13
14
|
def __init__(self, model: Type[T], session: Session):
|
14
15
|
self.model = model
|
15
16
|
self.session = session
|
16
17
|
|
18
|
+
# ---- helpers
|
19
|
+
|
17
20
|
def _resolve_column(self, column_name: str):
|
18
21
|
try:
|
19
22
|
return getattr(self.model, column_name)
|
@@ -25,59 +28,144 @@ class SARepository(Generic[T]):
|
|
25
28
|
stmt = stmt.filter_by(**filters)
|
26
29
|
return stmt
|
27
30
|
|
31
|
+
def _to_dto(self, obj: Any) -> Any:
|
32
|
+
if hasattr(obj, "to_read_model"):
|
33
|
+
return obj.to_read_model()
|
34
|
+
return obj
|
35
|
+
|
28
36
|
def _select(self):
|
29
37
|
return select(self.model)
|
30
38
|
|
31
39
|
def _has_soft_delete(self) -> bool:
|
32
40
|
return hasattr(self.model, "is_deleted")
|
33
41
|
|
34
|
-
def _apply_alive_filter(self, stmt, include_deleted: bool)
|
42
|
+
def _apply_alive_filter(self, stmt, include_deleted: bool):
|
35
43
|
if self._has_soft_delete() and not include_deleted:
|
36
44
|
stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
|
37
45
|
return stmt
|
38
|
-
|
39
|
-
|
46
|
+
|
47
|
+
# ---- CRUD/queries
|
48
|
+
|
49
|
+
def getAll(
|
50
|
+
self,
|
51
|
+
limit: Optional[int] = None,
|
52
|
+
*,
|
53
|
+
include_deleted: bool = False,
|
54
|
+
order_by=None,
|
55
|
+
**filters,
|
56
|
+
) -> List[T]:
|
57
|
+
"""
|
58
|
+
Получить все записи с возможностью фильтрации (username='foo', age__gt=20, и т.д.)
|
59
|
+
"""
|
40
60
|
stmt = select(self.model)
|
61
|
+
stmt = self._apply_filters(stmt, **filters)
|
41
62
|
stmt = self._apply_alive_filter(stmt, include_deleted)
|
63
|
+
if order_by is not None:
|
64
|
+
stmt = stmt.order_by(order_by)
|
42
65
|
if limit is not None:
|
43
66
|
stmt = stmt.limit(limit)
|
44
67
|
result = self.session.execute(stmt)
|
45
|
-
return result.scalars().all()
|
46
|
-
|
47
|
-
def get(self, id_: Any, *, include_deleted: bool = False) -> T:
|
48
|
-
|
49
|
-
|
50
|
-
|
68
|
+
return result.scalars().all()
|
69
|
+
|
70
|
+
def get(self, id_: Any = None, *, include_deleted: bool = False, **filters) -> Optional[T]:
|
71
|
+
"""
|
72
|
+
Получить один объект по id или по произвольным фильтрам.
|
73
|
+
repo.get(id_=1)
|
74
|
+
repo.get(username='ibrahim')
|
75
|
+
repo.get(username__ilike='%rah%') # если у тебя реализована интерпретация таких фильтров выше по стеку
|
76
|
+
"""
|
77
|
+
if id_ is not None:
|
78
|
+
obj = self.session.get(self.model, id_)
|
79
|
+
if not obj:
|
80
|
+
return None
|
81
|
+
else:
|
82
|
+
stmt = select(self.model)
|
83
|
+
stmt = self._apply_filters(stmt, **filters)
|
84
|
+
stmt = self._apply_alive_filter(stmt, include_deleted)
|
85
|
+
res = self.session.execute(stmt)
|
86
|
+
obj = res.scalars().first()
|
87
|
+
if not obj:
|
88
|
+
return None
|
89
|
+
|
51
90
|
if self._has_soft_delete() and not include_deleted and getattr(obj, "is_deleted", False):
|
52
|
-
raise NotFoundError(f"{self.model.__name__}({
|
91
|
+
raise NotFoundError(f"{self.model.__name__}({getattr(obj, 'id', '?')}) deleted")
|
53
92
|
return obj
|
54
93
|
|
55
|
-
def try_get(self, id_: Any, *, include_deleted: bool = False) -> Optional[T]:
|
56
|
-
|
57
|
-
|
58
|
-
return
|
59
|
-
|
94
|
+
def try_get(self, id_: Any = None, *, include_deleted: bool = False, **filters) -> Optional[T]:
|
95
|
+
"""Как get(), но не выбрасывает исключение при soft-deleted."""
|
96
|
+
try:
|
97
|
+
return self.get(id_=id_, include_deleted=include_deleted, **filters)
|
98
|
+
except NotFoundError:
|
60
99
|
return None
|
61
|
-
return obj
|
62
|
-
|
63
|
-
def add(self, entity: T) -> T:
|
64
|
-
self.session.add(entity)
|
65
|
-
self.session.flush()
|
66
|
-
self.session.refresh(entity)
|
67
|
-
return entity
|
68
100
|
|
69
|
-
def
|
70
|
-
|
71
|
-
|
72
|
-
|
101
|
+
def add(self, data: dict) -> T:
|
102
|
+
"""
|
103
|
+
Вставка через Core insert(...).returning(model.id) — как в async-версии.
|
104
|
+
Возвращает dict с добавленным id (повторяю твою семантику).
|
105
|
+
"""
|
106
|
+
stmt = insert(self.model).values(**data).returning(self.model.id)
|
107
|
+
res = self.session.execute(stmt)
|
108
|
+
new_id = res.scalar_one()
|
109
|
+
self.session.commit()
|
110
|
+
data["id"] = new_id
|
111
|
+
return data # так же, как у тебя: возвращаем dict, а не ORM-объект
|
112
|
+
|
113
|
+
def update(
|
114
|
+
self,
|
115
|
+
data: dict,
|
116
|
+
*,
|
117
|
+
include_deleted: bool = False,
|
118
|
+
expect_one: bool = False,
|
119
|
+
**filters,
|
120
|
+
) -> Optional[T]:
|
121
|
+
"""
|
122
|
+
Обновляет запись(и) по произвольным фильтрам.
|
123
|
+
Возвращает DTO одной обновлённой строки (если ровно одна) или None.
|
124
|
+
"""
|
125
|
+
if not data:
|
126
|
+
raise ValueError("`data` не может быть пустым.")
|
127
|
+
if not filters:
|
128
|
+
raise ValueError("Нужен хотя бы один фильтр (например, id=1 или username='foo').")
|
129
|
+
|
130
|
+
values = {self._resolve_column(k).key: v for k, v in data.items()}
|
131
|
+
conditions = [self._resolve_column(k) == v for k, v in filters.items()]
|
132
|
+
if self._has_soft_delete() and not include_deleted:
|
133
|
+
conditions.append(self.model.is_deleted == False) # noqa: E712
|
134
|
+
|
135
|
+
stmt = (
|
136
|
+
update(self.model)
|
137
|
+
.where(and_(*conditions))
|
138
|
+
.values(**values)
|
139
|
+
.returning(self.model)
|
140
|
+
)
|
141
|
+
|
142
|
+
res = self.session.execute(stmt)
|
143
|
+
updated_obj = res.scalar_one_or_none()
|
144
|
+
|
145
|
+
if expect_one:
|
146
|
+
rowcount = res.rowcount
|
147
|
+
if rowcount != 1:
|
148
|
+
self.session.rollback()
|
149
|
+
raise ValueError(f"Ожидалась ровно 1 строка, затронуто: {rowcount}")
|
150
|
+
|
151
|
+
if updated_obj is not None:
|
152
|
+
self.session.commit()
|
153
|
+
return self._to_dto(updated_obj)
|
154
|
+
|
155
|
+
self.session.rollback()
|
156
|
+
return None
|
73
157
|
|
74
158
|
def remove(self, entity: Optional[T] = None, id: Optional[Any] = None) -> bool:
|
159
|
+
"""
|
160
|
+
Поведение идентично твоему async-коду: soft-delete, иначе физическое удаление.
|
161
|
+
Коммиты намеренно не делаю, чтобы не менять твою транзакционную модель.
|
162
|
+
"""
|
75
163
|
if entity is None and id is None:
|
76
164
|
raise ValueError("remove() requires either entity or id")
|
77
|
-
|
165
|
+
|
78
166
|
if id is not None:
|
79
167
|
return self._delete_by_id(id)
|
80
|
-
|
168
|
+
|
81
169
|
insp = inspect(entity, raiseerr=False)
|
82
170
|
if not (insp and (insp.persistent or insp.pending)):
|
83
171
|
pk = getattr(entity, "id", None)
|
@@ -86,24 +174,36 @@ class SARepository(Generic[T]):
|
|
86
174
|
entity = self.session.get(self.model, pk)
|
87
175
|
if entity is None:
|
88
176
|
return False
|
177
|
+
|
89
178
|
if hasattr(entity, "is_deleted"):
|
90
179
|
setattr(entity, "is_deleted", True)
|
180
|
+
self.session.flush()
|
91
181
|
else:
|
92
182
|
self.session.delete(entity)
|
93
|
-
|
183
|
+
|
94
184
|
return True
|
95
185
|
|
96
186
|
def _delete_by_id(self, id_: Any) -> bool:
|
97
187
|
obj = self.session.get(self.model, id_)
|
98
188
|
if not obj:
|
99
189
|
return False
|
100
|
-
self.remove(obj)
|
101
|
-
return True
|
190
|
+
return self.remove(obj)
|
102
191
|
|
103
|
-
def page(
|
192
|
+
def page(
|
193
|
+
self,
|
194
|
+
page: PageRequest,
|
195
|
+
spec: Optional[Spec] = None,
|
196
|
+
order_by=None,
|
197
|
+
*,
|
198
|
+
include_deleted: bool = False,
|
199
|
+
) -> Page[T]: # type: ignore
|
200
|
+
"""
|
201
|
+
Точно повторяю логику подсчёта total через подзапрос.
|
202
|
+
Ожидается, что Page(items, total, page.page, page.size) уже есть в твоём коде.
|
203
|
+
"""
|
104
204
|
base = self._select()
|
105
205
|
if spec:
|
106
|
-
base = spec(base)
|
206
|
+
base = spec(base) # type: ignore
|
107
207
|
base = self._apply_alive_filter(base, include_deleted)
|
108
208
|
if order_by is not None:
|
109
209
|
base = base.order_by(order_by)
|
@@ -112,11 +212,12 @@ class SARepository(Generic[T]):
|
|
112
212
|
select(func.count()).select_from(base.subquery())
|
113
213
|
).scalar_one()
|
114
214
|
|
115
|
-
|
215
|
+
res = self.session.execute(
|
116
216
|
base.offset(page.page * page.size).limit(page.size)
|
117
|
-
)
|
118
|
-
|
119
|
-
|
217
|
+
)
|
218
|
+
items = res.scalars().all()
|
219
|
+
return Page(items, total, page.page, page.size) # type: ignore
|
220
|
+
|
120
221
|
def get_all_by_column(
|
121
222
|
self,
|
122
223
|
column_name: str,
|
@@ -125,8 +226,8 @@ class SARepository(Generic[T]):
|
|
125
226
|
limit: Optional[int] = None,
|
126
227
|
order_by=None,
|
127
228
|
include_deleted: bool = False,
|
128
|
-
**extra_filters
|
129
|
-
) ->
|
229
|
+
**extra_filters,
|
230
|
+
) -> List[T]:
|
130
231
|
col = self._resolve_column(column_name)
|
131
232
|
stmt = select(self.model).where(col == value)
|
132
233
|
stmt = self._apply_alive_filter(stmt, include_deleted)
|
@@ -137,26 +238,23 @@ class SARepository(Generic[T]):
|
|
137
238
|
stmt = stmt.limit(limit)
|
138
239
|
res = self.session.execute(stmt)
|
139
240
|
return res.scalars().all()
|
140
|
-
|
141
|
-
# Alias
|
241
|
+
|
242
|
+
# Alias — как у тебя
|
142
243
|
def find_all_by_column(self, *args, **kwargs):
|
143
244
|
return self.get_all_by_column(*args, **kwargs)
|
144
|
-
|
245
|
+
|
145
246
|
def get_or_create(
|
146
247
|
self,
|
147
248
|
defaults: Optional[dict] = None,
|
148
|
-
**unique_filters
|
149
|
-
) ->
|
150
|
-
"""
|
151
|
-
Возвращает (obj, created). unique_filters определяют уникальность.
|
152
|
-
defaults дополняют поля при создании.
|
153
|
-
"""
|
249
|
+
**unique_filters,
|
250
|
+
) -> Tuple[T, bool]:
|
154
251
|
stmt = select(self.model).filter_by(**unique_filters)
|
155
252
|
if hasattr(self.model, "is_deleted"):
|
156
253
|
stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
|
157
254
|
obj = self.session.execute(stmt).scalar_one_or_none()
|
158
255
|
if obj:
|
159
256
|
return obj, False
|
257
|
+
|
160
258
|
payload = {**unique_filters, **(defaults or {})}
|
161
259
|
obj = self.model(**payload) # type: ignore[call-arg]
|
162
260
|
self.session.add(obj)
|
@@ -165,12 +263,8 @@ class SARepository(Generic[T]):
|
|
165
263
|
return obj, True
|
166
264
|
|
167
265
|
def raw_query(self, sql: str, params: Optional[dict] = None) -> list[dict]:
|
168
|
-
"""
|
169
|
-
Безопасно выполняет сырой SQL (используй плейсхолдеры :name).
|
170
|
-
Возвращает список dict (строки).
|
171
|
-
"""
|
172
266
|
res = self.session.execute(text(sql), params or {})
|
173
|
-
#
|
267
|
+
# В SQLA 2.x для маппингов:
|
174
268
|
return [dict(row) for row in res.mappings().all()]
|
175
269
|
|
176
270
|
def aggregate_avg(self, column_name: str, **filters) -> Optional[float]:
|
@@ -206,17 +300,15 @@ class SARepository(Generic[T]):
|
|
206
300
|
return self.session.execute(stmt).scalar()
|
207
301
|
|
208
302
|
def count(self, **filters) -> int:
|
303
|
+
include_deleted = bool(filters.pop("include_deleted", False))
|
209
304
|
stmt = select(func.count()).select_from(self.model)
|
210
|
-
if hasattr(self.model, "is_deleted") and not
|
305
|
+
if hasattr(self.model, "is_deleted") and not include_deleted:
|
211
306
|
stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
|
212
307
|
if filters:
|
213
308
|
stmt = stmt.filter_by(**filters)
|
214
309
|
return int(self.session.execute(stmt).scalar_one())
|
215
310
|
|
216
311
|
def restore(self, id_: Any) -> bool:
|
217
|
-
"""
|
218
|
-
Для soft-delete: is_deleted=False. Возвращает True, если восстановили.
|
219
|
-
"""
|
220
312
|
if not hasattr(self.model, "is_deleted"):
|
221
313
|
raise RuntimeError(f"{self.model.__name__} has no 'is_deleted' field")
|
222
314
|
obj = self.session.get(self.model, id_)
|
@@ -228,12 +320,14 @@ class SARepository(Generic[T]):
|
|
228
320
|
return True
|
229
321
|
return False
|
230
322
|
|
323
|
+
|
231
324
|
class SAAsyncRepository(Generic[T]):
|
232
325
|
"""Async repository implementation for SQLAlchemy 2.x."""
|
326
|
+
|
233
327
|
def __init__(self, model: Type[T], session: AsyncSession):
|
234
328
|
self.model = model
|
235
329
|
self.session = session
|
236
|
-
|
330
|
+
|
237
331
|
def _resolve_column(self, column_name: str):
|
238
332
|
try:
|
239
333
|
return getattr(self.model, column_name)
|
@@ -244,60 +338,147 @@ class SAAsyncRepository(Generic[T]):
|
|
244
338
|
if filters:
|
245
339
|
stmt = stmt.filter_by(**filters)
|
246
340
|
return stmt
|
247
|
-
|
341
|
+
|
342
|
+
def _to_dto(self, obj: Any) -> Any:
|
343
|
+
if hasattr(obj, "to_read_model"):
|
344
|
+
return obj.to_read_model()
|
345
|
+
return obj
|
346
|
+
|
248
347
|
def _select(self):
|
249
348
|
return select(self.model)
|
250
|
-
|
349
|
+
|
251
350
|
def _has_soft_delete(self) -> bool:
|
252
351
|
return hasattr(self.model, "is_deleted")
|
253
352
|
|
254
|
-
def _apply_alive_filter(self, stmt, include_deleted: bool)
|
353
|
+
def _apply_alive_filter(self, stmt, include_deleted: bool):
|
255
354
|
if self._has_soft_delete() and not include_deleted:
|
256
355
|
stmt = stmt.where(self.model.is_deleted == False) # noqa: E712
|
257
356
|
return stmt
|
258
357
|
|
259
|
-
async def getAll(
|
358
|
+
async def getAll(
|
359
|
+
self,
|
360
|
+
limit: Optional[int] = None,
|
361
|
+
*,
|
362
|
+
include_deleted: bool = False,
|
363
|
+
order_by=None,
|
364
|
+
**filters
|
365
|
+
) -> List[T]:
|
366
|
+
"""
|
367
|
+
Получить все записи с возможностью фильтрации (username='foo', age__gt=20, и т.д.)
|
368
|
+
"""
|
260
369
|
stmt = select(self.model)
|
370
|
+
stmt = self._apply_filters(stmt, **filters)
|
261
371
|
stmt = self._apply_alive_filter(stmt, include_deleted)
|
372
|
+
if order_by is not None:
|
373
|
+
stmt = stmt.order_by(order_by)
|
262
374
|
if limit is not None:
|
263
375
|
stmt = stmt.limit(limit)
|
264
376
|
result = await self.session.execute(stmt)
|
265
377
|
return result.scalars().all()
|
266
378
|
|
267
|
-
async def get(self, id_: Any, *, include_deleted: bool = False) -> T:
|
268
|
-
|
269
|
-
|
270
|
-
|
379
|
+
async def get(self, id_: Any = None, *, include_deleted: bool = False, **filters) -> Optional[T]:
|
380
|
+
"""
|
381
|
+
Получить один объект по id или по произвольным фильтрам.
|
382
|
+
Пример:
|
383
|
+
await repo.get(id_=1)
|
384
|
+
await repo.get(username='ibrahim')
|
385
|
+
await repo.get(username__ilike='%rah%')
|
386
|
+
"""
|
387
|
+
if id_ is not None:
|
388
|
+
obj = await self.session.get(self.model, id_)
|
389
|
+
if not obj:
|
390
|
+
return None
|
391
|
+
else:
|
392
|
+
stmt = select(self.model)
|
393
|
+
stmt = self._apply_filters(stmt, **filters)
|
394
|
+
stmt = self._apply_alive_filter(stmt, include_deleted)
|
395
|
+
res = await self.session.execute(stmt)
|
396
|
+
obj = res.scalars().first()
|
397
|
+
if not obj:
|
398
|
+
return None
|
271
399
|
if self._has_soft_delete() and not include_deleted and getattr(obj, "is_deleted", False):
|
272
|
-
raise NotFoundError(f"{self.model.__name__}({
|
400
|
+
raise NotFoundError(f"{self.model.__name__}({getattr(obj, 'id', '?')}) deleted")
|
273
401
|
return obj
|
274
402
|
|
275
|
-
async def try_get(self, id_: Any, *, include_deleted: bool = False) -> Optional[T]:
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
403
|
+
async def try_get(self, id_: Any = None, *, include_deleted: bool = False, **filters) -> Optional[T]:
|
404
|
+
"""
|
405
|
+
Как get(), но не выбрасывает исключение при отсутствии объекта.
|
406
|
+
"""
|
407
|
+
try:
|
408
|
+
return await self.get(id_=id_, include_deleted=include_deleted, **filters)
|
409
|
+
except NotFoundError:
|
280
410
|
return None
|
281
|
-
return obj
|
282
411
|
|
283
|
-
async def add(self,
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
412
|
+
async def add(self, data: dict) -> T:
|
413
|
+
stmt = (
|
414
|
+
insert(self.model)
|
415
|
+
.values(**data)
|
416
|
+
.returning(self.model.id)
|
417
|
+
)
|
418
|
+
res = await self.session.execute(stmt)
|
419
|
+
new_id = res.scalar_one()
|
420
|
+
await self.session.commit()
|
421
|
+
data["id"] = new_id
|
422
|
+
return data
|
423
|
+
|
424
|
+
async def update(
|
425
|
+
self,
|
426
|
+
data: dict,
|
427
|
+
*,
|
428
|
+
include_deleted: bool = False,
|
429
|
+
expect_one: bool = False,
|
430
|
+
**filters,
|
431
|
+
) -> Optional[T]:
|
432
|
+
"""
|
433
|
+
Обновляет запись(и) по произвольным фильтрам.
|
434
|
+
- data: словарь обновляемых полей -> значений
|
435
|
+
- include_deleted: если модель имеет is_deleted, включать ли удалённые
|
436
|
+
- expect_one: если True — бросит ValueError, если затронуто != 1 строк
|
437
|
+
- **filters: произвольные фильтры вида field=value
|
438
|
+
Возвращает DTO одной обновлённой строки (если ровно одна) или None.
|
439
|
+
"""
|
440
|
+
if not data:
|
441
|
+
raise ValueError("`data` не может быть пустым.")
|
442
|
+
if not filters:
|
443
|
+
raise ValueError("Нужен хотя бы один фильтр (например, id=1 или username='foo').")
|
288
444
|
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
445
|
+
values = {self._resolve_column(k).key: v for k, v in data.items()}
|
446
|
+
|
447
|
+
conditions = [self._resolve_column(k) == v for k, v in filters.items()]
|
448
|
+
|
449
|
+
if self._has_soft_delete() and not include_deleted:
|
450
|
+
conditions.append(self.model.is_deleted == False)
|
451
|
+
|
452
|
+
stmt = (
|
453
|
+
update(self.model)
|
454
|
+
.where(and_(*conditions))
|
455
|
+
.values(**values)
|
456
|
+
.returning(self.model)
|
457
|
+
)
|
458
|
+
|
459
|
+
res = await self.session.execute(stmt)
|
460
|
+
updated_obj = res.scalar_one_or_none()
|
461
|
+
|
462
|
+
if expect_one:
|
463
|
+
rowcount = res.rowcount
|
464
|
+
if rowcount != 1:
|
465
|
+
await self.session.rollback()
|
466
|
+
raise ValueError(f"Ожидалась ровно 1 строка, затронуто: {rowcount}")
|
467
|
+
|
468
|
+
if updated_obj is not None:
|
469
|
+
await self.session.commit()
|
470
|
+
return self._to_dto(updated_obj)
|
471
|
+
|
472
|
+
await self.session.rollback()
|
473
|
+
return None
|
293
474
|
|
294
475
|
async def remove(self, entity: Optional[T] = None, id: Optional[Any] = None) -> bool:
|
295
476
|
if entity is None and id is None:
|
296
477
|
raise ValueError("remove() requires either entity or id")
|
297
|
-
|
478
|
+
|
298
479
|
if id is not None:
|
299
480
|
return await self._delete_by_id(id)
|
300
|
-
|
481
|
+
|
301
482
|
insp = inspect(entity, raiseerr=False)
|
302
483
|
if not (insp and (insp.persistent or insp.pending)):
|
303
484
|
pk = getattr(entity, "id", None)
|
@@ -310,10 +491,9 @@ class SAAsyncRepository(Generic[T]):
|
|
310
491
|
setattr(entity, "is_deleted", True)
|
311
492
|
else:
|
312
493
|
await self.session.delete(entity)
|
313
|
-
|
494
|
+
|
314
495
|
return True
|
315
496
|
|
316
|
-
|
317
497
|
async def _delete_by_id(self, id_: Any) -> bool:
|
318
498
|
obj = await self.session.get(self.model, id_)
|
319
499
|
if not obj:
|
@@ -321,7 +501,8 @@ class SAAsyncRepository(Generic[T]):
|
|
321
501
|
await self.remove(obj)
|
322
502
|
return True
|
323
503
|
|
324
|
-
async def page(self, page: PageRequest, spec: Optional[Spec] = None, order_by=None, *,
|
504
|
+
async def page(self, page: PageRequest, spec: Optional[Spec] = None, order_by=None, *,
|
505
|
+
include_deleted: bool = False) -> Page[T]: # type: ignore
|
325
506
|
base = self._select()
|
326
507
|
if spec:
|
327
508
|
base = spec(base)
|
@@ -340,14 +521,14 @@ class SAAsyncRepository(Generic[T]):
|
|
340
521
|
return Page(items, total, page.page, page.size)
|
341
522
|
|
342
523
|
async def get_all_by_column(
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
524
|
+
self,
|
525
|
+
column_name: str,
|
526
|
+
value: Any,
|
527
|
+
*,
|
528
|
+
limit: Optional[int] = None,
|
529
|
+
order_by=None,
|
530
|
+
include_deleted: bool = False,
|
531
|
+
**extra_filters
|
351
532
|
) -> list[T]:
|
352
533
|
col = self._resolve_column(column_name)
|
353
534
|
stmt = select(self.model).where(col == value)
|
@@ -363,11 +544,11 @@ class SAAsyncRepository(Generic[T]):
|
|
363
544
|
# Alias
|
364
545
|
async def find_all_by_column(self, *args, **kwargs):
|
365
546
|
return await self.get_all_by_column(*args, **kwargs)
|
366
|
-
|
547
|
+
|
367
548
|
async def get_or_create(
|
368
|
-
|
369
|
-
|
370
|
-
|
549
|
+
self,
|
550
|
+
defaults: Optional[dict] = None,
|
551
|
+
**unique_filters
|
371
552
|
) -> tuple[T, bool]:
|
372
553
|
stmt = select(self.model).filter_by(**unique_filters)
|
373
554
|
if hasattr(self.model, "is_deleted"):
|
@@ -437,4 +618,4 @@ class SAAsyncRepository(Generic[T]):
|
|
437
618
|
setattr(obj, "is_deleted", False)
|
438
619
|
await self.session.flush()
|
439
620
|
return True
|
440
|
-
return False
|
621
|
+
return False
|
@@ -0,0 +1,157 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: SARepo
|
3
|
+
Version: 0.1.8
|
4
|
+
Summary: Minimal, explicit Repository & Unit-of-Work layer over SQLAlchemy 2.x
|
5
|
+
Author: nurbergenovv
|
6
|
+
License: MIT
|
7
|
+
Requires-Python: >=3.11
|
8
|
+
Description-Content-Type: text/markdown
|
9
|
+
License-File: LICENSE
|
10
|
+
Requires-Dist: SQLAlchemy>=2.0
|
11
|
+
Dynamic: license-file
|
12
|
+
|
13
|
+
# SARepo
|
14
|
+
|
15
|
+
**Minimal, explicit Repository + Unit of Work layer on top of SQLAlchemy 2.x (sync & async).**
|
16
|
+
No magic method names — just clean typed APIs, composable specs, pagination, and optional soft-delete.
|
17
|
+
|
18
|
+
---
|
19
|
+
|
20
|
+
## 🚀 Quick Start (Sync)
|
21
|
+
|
22
|
+
```python
|
23
|
+
from sqlalchemy import create_engine, String
|
24
|
+
from sqlalchemy.orm import sessionmaker, DeclarativeBase, Mapped, mapped_column
|
25
|
+
|
26
|
+
from SARepo.sa_repo import SARepository
|
27
|
+
from SARepo.base import PageRequest
|
28
|
+
from SARepo.specs import and_specs, ilike
|
29
|
+
|
30
|
+
|
31
|
+
class Base(DeclarativeBase):
|
32
|
+
pass
|
33
|
+
|
34
|
+
|
35
|
+
class Request(Base):
|
36
|
+
__tablename__ = "requests"
|
37
|
+
|
38
|
+
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
39
|
+
title: Mapped[str] = mapped_column(String(255))
|
40
|
+
status: Mapped[str] = mapped_column(String(50))
|
41
|
+
|
42
|
+
|
43
|
+
# Setup database
|
44
|
+
engine = create_engine("sqlite+pysqlite:///:memory:", echo=False)
|
45
|
+
Base.metadata.create_all(engine)
|
46
|
+
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
|
47
|
+
|
48
|
+
# Usage
|
49
|
+
with SessionLocal() as session:
|
50
|
+
repo = SARepository(Request, session)
|
51
|
+
|
52
|
+
# Create
|
53
|
+
r = repo.add(Request(title="Hello", status="NEW"))
|
54
|
+
session.commit()
|
55
|
+
|
56
|
+
# Query + Paginate
|
57
|
+
page = repo.page(PageRequest(0, 10), spec=ilike(Request.title, "%He%"))
|
58
|
+
print(page.total, [i.title for i in page.items])
|
59
|
+
```
|
60
|
+
|
61
|
+
---
|
62
|
+
|
63
|
+
## ⚙️ Async Quick Start
|
64
|
+
|
65
|
+
```python
|
66
|
+
import asyncio
|
67
|
+
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
68
|
+
|
69
|
+
from SARepo.sa_repo import SAAsyncRepository
|
70
|
+
from SARepo.base import PageRequest
|
71
|
+
|
72
|
+
|
73
|
+
async def main():
|
74
|
+
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
75
|
+
async with engine.begin() as conn:
|
76
|
+
await conn.run_sync(Base.metadata.create_all)
|
77
|
+
|
78
|
+
SessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False)
|
79
|
+
|
80
|
+
async with SessionLocal() as session:
|
81
|
+
repo = SAAsyncRepository(Request, session)
|
82
|
+
|
83
|
+
await repo.add(Request(title="Hello", status="NEW"))
|
84
|
+
await session.commit()
|
85
|
+
|
86
|
+
page = await repo.page(PageRequest(0, 10))
|
87
|
+
print(page.total)
|
88
|
+
|
89
|
+
|
90
|
+
asyncio.run(main())
|
91
|
+
```
|
92
|
+
|
93
|
+
---
|
94
|
+
|
95
|
+
## 🧠 Features
|
96
|
+
|
97
|
+
✅ **SARepository / SAAsyncRepository**
|
98
|
+
- Clean CRUD operations (`get`, `add`, `update`, `remove`, etc.)
|
99
|
+
- `page()` method with total count
|
100
|
+
|
101
|
+
✅ **Composable Specs**
|
102
|
+
- Combine filters easily (`eq`, `ilike`, `and_specs`, `not_deleted`)
|
103
|
+
|
104
|
+
✅ **Soft Delete**
|
105
|
+
- Automatically filters out rows with `is_deleted=True` if the model has this column
|
106
|
+
|
107
|
+
✅ **Typed Protocol Interface**
|
108
|
+
- `CrudRepository` protocol for structural typing & IDE autocompletion
|
109
|
+
|
110
|
+
✅ **Unit of Work**
|
111
|
+
- Minimal sync & async helpers for transactional operations
|
112
|
+
|
113
|
+
---
|
114
|
+
|
115
|
+
## 🧩 Example: Spec Composition
|
116
|
+
|
117
|
+
```python
|
118
|
+
from SARepo.specs import eq, ilike, and_specs
|
119
|
+
|
120
|
+
spec = and_specs(
|
121
|
+
eq(Request.status, "NEW"),
|
122
|
+
ilike(Request.title, "%Hello%")
|
123
|
+
)
|
124
|
+
|
125
|
+
page = repo.page(PageRequest(0, 10), spec=spec)
|
126
|
+
```
|
127
|
+
|
128
|
+
---
|
129
|
+
|
130
|
+
## 📜 License
|
131
|
+
|
132
|
+
**MIT License**
|
133
|
+
Copyright (c) 2025
|
134
|
+
|
135
|
+
---
|
136
|
+
|
137
|
+
## 🧭 Project Structure (Example)
|
138
|
+
|
139
|
+
```
|
140
|
+
SARepo/
|
141
|
+
│
|
142
|
+
├── sa_repo.py # Core Repository classes (sync & async)
|
143
|
+
├── base.py # Pagination, PageRequest, DTO helpers
|
144
|
+
├── specs.py # Composable query specs
|
145
|
+
├── uow.py # Optional Unit of Work layer
|
146
|
+
├── __init__.py
|
147
|
+
```
|
148
|
+
|
149
|
+
---
|
150
|
+
|
151
|
+
### ✅ Philosophy
|
152
|
+
|
153
|
+
- No ORM “magic” — only explicit SQLAlchemy 2.0 APIs
|
154
|
+
- One repository → one model
|
155
|
+
- Predictable transaction scope
|
156
|
+
- Works with Pydantic DTOs or dataclasses
|
157
|
+
- 100 % type-safe
|
@@ -0,0 +1,12 @@
|
|
1
|
+
SARepo/__init__.py,sha256=tNYbuUDloC1qVnXq7uomS3jRcaPUvy0VEZiMdYR6x1M,183
|
2
|
+
SARepo/base.py,sha256=UbAdZ9WYh_o93mrCVL3D8Q0tY_8mvm0HspO_L5m0GTQ,874
|
3
|
+
SARepo/models.py,sha256=ypSmbKAijvNH4WjSeJBgyNT8mKa_e_7-I5kiNisjGAI,570
|
4
|
+
SARepo/repo.py,sha256=bjxK5gzSrMZcYHnj688UTL2KVbg_9t8bNT2hLfWWAa8,2821
|
5
|
+
SARepo/sa_repo.py,sha256=MY8RkXfrtahb9J96VXroq_OueSrixE02noxVzbc9UCc,24007
|
6
|
+
SARepo/specs.py,sha256=e-X1cCkva3e71M37hcfbjNDMezZAaOkkRaPToUzhEeU,687
|
7
|
+
SARepo/uow.py,sha256=yPlvi7PoH9x2pLNt6hEin9zT5qG9axUKn_TkZcIuz1s,859
|
8
|
+
sarepo-0.1.8.dist-info/licenses/LICENSE,sha256=px90NOQCrnZ77qoFT8IvAiNS1CXQ2OU27uVBKbki9DM,131
|
9
|
+
sarepo-0.1.8.dist-info/METADATA,sha256=CM2j9zHkVD4TaWFFDG3DypvuxlA3aOonNSNHxrJ-Z0Y,3768
|
10
|
+
sarepo-0.1.8.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
11
|
+
sarepo-0.1.8.dist-info/top_level.txt,sha256=0k952UYVZLIIv3kZzFlxI0yzBMusZo3-XCQtxms_kS0,7
|
12
|
+
sarepo-0.1.8.dist-info/RECORD,,
|
sarepo-0.1.6.dist-info/METADATA
DELETED
@@ -1,90 +0,0 @@
|
|
1
|
-
Metadata-Version: 2.4
|
2
|
-
Name: SARepo
|
3
|
-
Version: 0.1.6
|
4
|
-
Summary: Minimal, explicit Repository & Unit-of-Work layer over SQLAlchemy 2.x
|
5
|
-
Author: nurbergenovv
|
6
|
-
License: MIT
|
7
|
-
Requires-Python: >=3.11
|
8
|
-
Description-Content-Type: text/markdown
|
9
|
-
License-File: LICENSE
|
10
|
-
Requires-Dist: SQLAlchemy>=2.0
|
11
|
-
Dynamic: license-file
|
12
|
-
|
13
|
-
|
14
|
-
# SARepo
|
15
|
-
|
16
|
-
Minimal, explicit **Repository** + **Unit of Work** layer on top of **SQLAlchemy 2.x** (sync & async).
|
17
|
-
No magic method-names, just clean typed APIs, composable specs, pagination, and optional soft-delete.
|
18
|
-
|
19
|
-
## Install (editable)
|
20
|
-
|
21
|
-
```bash
|
22
|
-
pip install -e .
|
23
|
-
```
|
24
|
-
|
25
|
-
## Quick Start (sync)
|
26
|
-
|
27
|
-
```python
|
28
|
-
from sqlalchemy import create_engine
|
29
|
-
from sqlalchemy.orm import sessionmaker, Mapped, mapped_column
|
30
|
-
from sqlalchemy.orm import DeclarativeBase
|
31
|
-
from sqlalchemy import String
|
32
|
-
|
33
|
-
from SARepo.sa_repo import SARepository
|
34
|
-
from SARepo.base import PageRequest
|
35
|
-
from SARepo.specs import and_specs, ilike
|
36
|
-
|
37
|
-
class Base(DeclarativeBase): pass
|
38
|
-
|
39
|
-
class Request(Base):
|
40
|
-
__tablename__ = "requests"
|
41
|
-
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
42
|
-
title: Mapped[str] = mapped_column(String(255))
|
43
|
-
status: Mapped[str] = mapped_column(String(50))
|
44
|
-
|
45
|
-
engine = create_engine("sqlite+pysqlite:///:memory:", echo=False)
|
46
|
-
Base.metadata.create_all(engine)
|
47
|
-
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
|
48
|
-
|
49
|
-
with SessionLocal() as session:
|
50
|
-
repo = SARepository(Request, session)
|
51
|
-
# create
|
52
|
-
r = repo.add(Request(title="Hello", status="NEW"))
|
53
|
-
session.commit()
|
54
|
-
|
55
|
-
# search + paginate
|
56
|
-
page = repo.page(PageRequest(0, 10), spec=ilike(Request.title, "%He%"))
|
57
|
-
print(page.total, [i.title for i in page.items])
|
58
|
-
```
|
59
|
-
|
60
|
-
## Async Quick Start
|
61
|
-
|
62
|
-
```python
|
63
|
-
import asyncio
|
64
|
-
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
65
|
-
from SARepo.sa_repo import SAAsyncRepository
|
66
|
-
from SARepo.base import PageRequest
|
67
|
-
|
68
|
-
async def main():
|
69
|
-
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
70
|
-
async with engine.begin() as conn:
|
71
|
-
await conn.run_sync(Base.metadata.create_all)
|
72
|
-
SessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False)
|
73
|
-
async with SessionLocal() as session:
|
74
|
-
repo = SAAsyncRepository(Request, session)
|
75
|
-
await repo.add(Request(title="Hello", status="NEW"))
|
76
|
-
await session.commit()
|
77
|
-
page = await repo.page(PageRequest(0, 10))
|
78
|
-
print(page.total)
|
79
|
-
|
80
|
-
asyncio.run(main())
|
81
|
-
```
|
82
|
-
|
83
|
-
## Features
|
84
|
-
- `SARepository` and `SAAsyncRepository` (CRUD, `page()` with total count)
|
85
|
-
- Composable **specs** (`eq`, `ilike`, `and_specs`, `not_deleted`)
|
86
|
-
- Optional **soft-delete** if the entity has `is_deleted: bool`
|
87
|
-
- Minimal **Unit of Work** helpers (sync & async)
|
88
|
-
|
89
|
-
## License
|
90
|
-
MIT
|
sarepo-0.1.6.dist-info/RECORD
DELETED
@@ -1,12 +0,0 @@
|
|
1
|
-
SARepo/__init__.py,sha256=tNYbuUDloC1qVnXq7uomS3jRcaPUvy0VEZiMdYR6x1M,183
|
2
|
-
SARepo/base.py,sha256=UbAdZ9WYh_o93mrCVL3D8Q0tY_8mvm0HspO_L5m0GTQ,874
|
3
|
-
SARepo/models.py,sha256=ypSmbKAijvNH4WjSeJBgyNT8mKa_e_7-I5kiNisjGAI,570
|
4
|
-
SARepo/repo.py,sha256=q99K9odCVuxJ9HIKwgVpu3p9-hyzxmGlE-wkiCp9tbg,1820
|
5
|
-
SARepo/sa_repo.py,sha256=iPJan94ALJ3N58aU1D6rgO6uhqLDUxcHXvORpQrAt9c,17258
|
6
|
-
SARepo/specs.py,sha256=e-X1cCkva3e71M37hcfbjNDMezZAaOkkRaPToUzhEeU,687
|
7
|
-
SARepo/uow.py,sha256=yPlvi7PoH9x2pLNt6hEin9zT5qG9axUKn_TkZcIuz1s,859
|
8
|
-
sarepo-0.1.6.dist-info/licenses/LICENSE,sha256=px90NOQCrnZ77qoFT8IvAiNS1CXQ2OU27uVBKbki9DM,131
|
9
|
-
sarepo-0.1.6.dist-info/METADATA,sha256=4Yfl0Eh0h8Sb2Lrs_DxZRfPJuZzGTg7-I9blRPva9aw,2691
|
10
|
-
sarepo-0.1.6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
11
|
-
sarepo-0.1.6.dist-info/top_level.txt,sha256=0k952UYVZLIIv3kZzFlxI0yzBMusZo3-XCQtxms_kS0,7
|
12
|
-
sarepo-0.1.6.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|