coding-client 1.21.2__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.
Files changed (109) hide show
  1. coding_client/__init__.py +1 -0
  2. coding_client/api/__init__.py +19 -0
  3. coding_client/api/exception_handlers.py +158 -0
  4. coding_client/api/logging_setup.py +44 -0
  5. coding_client/api/metrics.py +40 -0
  6. coding_client/api/openapi_config.py +124 -0
  7. coding_client/api/request_logging.py +60 -0
  8. coding_client/api/validation.py +150 -0
  9. coding_client/auth/__init__.py +78 -0
  10. coding_client/auth/_default_tenant.py +132 -0
  11. coding_client/auth/_header_trust.py +98 -0
  12. coding_client/auth/_onbehalf.py +315 -0
  13. coding_client/auth/_trusted_networks.py +94 -0
  14. coding_client/auth/dependencies.py +61 -0
  15. coding_client/auth/middleware.py +445 -0
  16. coding_client/auth/models.py +83 -0
  17. coding_client/auth/outbound.py +137 -0
  18. coding_client/auth/rbac.py +21 -0
  19. coding_client/auth/rbac_coding.py +5 -0
  20. coding_client/auth/tenant.py +650 -0
  21. coding_client/auth/websocket.py +148 -0
  22. coding_client/clients/__init__.py +24 -0
  23. coding_client/clients/base.py +131 -0
  24. coding_client/clients/coding_ai.py +33 -0
  25. coding_client/clients/coding_analysis.py +36 -0
  26. coding_client/clients/coding_containers.py +45 -0
  27. coding_client/clients/coding_github.py +127 -0
  28. coding_client/clients/coding_realtime.py +127 -0
  29. coding_client/clients/coding_security.py +51 -0
  30. coding_client/clients/coding_testing.py +31 -0
  31. coding_client/clients/coding_workspaces.py +265 -0
  32. coding_client/config/__init__.py +12 -0
  33. coding_client/config/base_settings.py +61 -0
  34. coding_client/config/yaml_config_loader.py +172 -0
  35. coding_client/db/__init__.py +42 -0
  36. coding_client/db/db_types.py +122 -0
  37. coding_client/db/exceptions.py +59 -0
  38. coding_client/db/postgres_connection.py +572 -0
  39. coding_client/db/tenant_query_helper.py +417 -0
  40. coding_client/db_init.py +49 -0
  41. coding_client/exceptions/__init__.py +67 -0
  42. coding_client/exceptions/api_exceptions.py +395 -0
  43. coding_client/exceptions/pydantic_ai_exceptions.py +533 -0
  44. coding_client/interfaces/__init__.py +22 -0
  45. coding_client/interfaces/cache_interface.py +179 -0
  46. coding_client/interfaces/container_interface.py +49 -0
  47. coding_client/interfaces/docker_interface.py +283 -0
  48. coding_client/interfaces/graph_interface.py +28 -0
  49. coding_client/interfaces/security_interface.py +72 -0
  50. coding_client/interfaces/webhook_interface.py +67 -0
  51. coding_client/llm/__init__.py +54 -0
  52. coding_client/llm/base_agent.py +195 -0
  53. coding_client/llm/context.py +121 -0
  54. coding_client/llm/responses.py +102 -0
  55. coding_client/llm/routing.py +82 -0
  56. coding_client/llm/settings.py +102 -0
  57. coding_client/models/__init__.py +62 -0
  58. coding_client/models/agent.py +38 -0
  59. coding_client/models/ai_test_failure.py +745 -0
  60. coding_client/models/analysis.py +330 -0
  61. coding_client/models/architecture.py +685 -0
  62. coding_client/models/bug.py +128 -0
  63. coding_client/models/code_graph.py +317 -0
  64. coding_client/models/container.py +241 -0
  65. coding_client/models/core.py +878 -0
  66. coding_client/models/docker_container.py +104 -0
  67. coding_client/models/github.py +293 -0
  68. coding_client/models/logging.py +29 -0
  69. coding_client/models/package_manager.py +257 -0
  70. coding_client/models/pr_validation.py +440 -0
  71. coding_client/models/pydantic_ai.py +395 -0
  72. coding_client/models/repo_scanner.py +249 -0
  73. coding_client/models/responses.py +431 -0
  74. coding_client/models/security.py +185 -0
  75. coding_client/models/static_analysis.py +508 -0
  76. coding_client/models/system.py +48 -0
  77. coding_client/models/test_executor.py +204 -0
  78. coding_client/models/testing.py +673 -0
  79. coding_client/models/testing_config.py +200 -0
  80. coding_client/models/validation.py +127 -0
  81. coding_client/models_init.py +272 -0
  82. coding_client/telemetry.py +195 -0
  83. coding_client/testing.py +180 -0
  84. coding_client/types/__init__.py +130 -0
  85. coding_client/types/constraint_types.py +276 -0
  86. coding_client/types/core_types.py +112 -0
  87. coding_client/types/state_machine.py +443 -0
  88. coding_client/utils/__init__.py +86 -0
  89. coding_client/utils/archive_utils.py +387 -0
  90. coding_client/utils/ast_debug.py +45 -0
  91. coding_client/utils/async_timeout.py +364 -0
  92. coding_client/utils/constants.py +518 -0
  93. coding_client/utils/content_processing.py +326 -0
  94. coding_client/utils/database.py +916 -0
  95. coding_client/utils/encoding_helper.py +222 -0
  96. coding_client/utils/enum_helpers.py +166 -0
  97. coding_client/utils/file_operations.py +177 -0
  98. coding_client/utils/filesystem_ops.py +528 -0
  99. coding_client/utils/http_client.py +562 -0
  100. coding_client/utils/path_utils.py +471 -0
  101. coding_client/utils/profiling.py +421 -0
  102. coding_client/utils/response_builder.py +240 -0
  103. coding_client/utils/retry_helper.py +712 -0
  104. coding_client/utils/security_validation.py +423 -0
  105. coding_client-1.21.2.dist-info/METADATA +234 -0
  106. coding_client-1.21.2.dist-info/RECORD +109 -0
  107. coding_client-1.21.2.dist-info/WHEEL +5 -0
  108. coding_client-1.21.2.dist-info/licenses/LICENSE +201 -0
  109. coding_client-1.21.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,916 @@
1
+ """Database abstraction layer for common database operations.
2
+
3
+ This module provides reusable database utilities and patterns to reduce
4
+ code duplication across services and repositories.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import inspect
10
+ import logging
11
+ from contextlib import asynccontextmanager, contextmanager
12
+ from functools import wraps
13
+ from typing import (
14
+ Any,
15
+ TypeVar,
16
+ cast,
17
+ )
18
+
19
+ from pydantic import BaseModel
20
+ from sqlalchemy import and_, func, select, update
21
+ from sqlalchemy.exc import DataError, IntegrityError, OperationalError
22
+ from sqlalchemy.ext.asyncio import AsyncSession
23
+ from sqlalchemy.orm import Query, Session
24
+ from sqlalchemy.sql import Select
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # Type variables for generic operations
29
+ T = TypeVar("T")
30
+ ModelType = TypeVar("ModelType")
31
+ SchemaType = TypeVar("SchemaType", bound=BaseModel)
32
+
33
+
34
+ class DatabaseError(Exception):
35
+ """Base exception for database operations."""
36
+
37
+ pass
38
+
39
+
40
+ class RecordNotFoundError(DatabaseError):
41
+ """Raised when a requested record is not found."""
42
+
43
+ pass
44
+
45
+
46
+ class DuplicateRecordError(DatabaseError):
47
+ """Raised when trying to create a duplicate record."""
48
+
49
+ pass
50
+
51
+
52
+ class DatabaseOperationError(DatabaseError):
53
+ """Raised when a database operation fails."""
54
+
55
+ pass
56
+
57
+
58
+ class BaseDatabaseOperations:
59
+ """Base class for common database operations."""
60
+
61
+ def __init__(self, model: type[ModelType]):
62
+ """Initialize with a SQLAlchemy model.
63
+
64
+ Args:
65
+ model: SQLAlchemy model class
66
+ """
67
+ self.model = model
68
+
69
+ @staticmethod
70
+ def handle_database_error(func):
71
+ """Decorator to handle common database errors."""
72
+
73
+ @wraps(func)
74
+ def wrapper(*args, **kwargs):
75
+ try:
76
+ return func(*args, **kwargs)
77
+ except IntegrityError as e:
78
+ logger.error(f"Database integrity error: {e}")
79
+ if "UNIQUE constraint" in str(e) or "duplicate key" in str(e).lower():
80
+ raise DuplicateRecordError("Record already exists") from e
81
+ raise DatabaseOperationError(f"Database integrity error: {e}") from e
82
+ except DataError as e:
83
+ logger.error(f"Database data error: {e}")
84
+ raise DatabaseOperationError(f"Invalid data: {e}") from e
85
+ except OperationalError as e:
86
+ logger.error(f"Database operational error: {e}")
87
+ raise DatabaseOperationError(f"Database operation failed: {e}") from e
88
+ except Exception as e:
89
+ logger.error(f"Unexpected database error: {e}")
90
+ raise DatabaseError(f"Unexpected error: {e}") from e
91
+
92
+ @wraps(func)
93
+ async def async_wrapper(*args, **kwargs):
94
+ try:
95
+ return await func(*args, **kwargs)
96
+ except IntegrityError as e:
97
+ logger.error(f"Database integrity error: {e}")
98
+ if "UNIQUE constraint" in str(e) or "duplicate key" in str(e).lower():
99
+ raise DuplicateRecordError("Record already exists") from e
100
+ raise DatabaseOperationError(f"Database integrity error: {e}") from e
101
+ except DataError as e:
102
+ logger.error(f"Database data error: {e}")
103
+ raise DatabaseOperationError(f"Invalid data: {e}") from e
104
+ except OperationalError as e:
105
+ logger.error(f"Database operational error: {e}")
106
+ raise DatabaseOperationError(f"Database operation failed: {e}") from e
107
+ except Exception as e:
108
+ logger.error(f"Unexpected database error: {e}")
109
+ raise DatabaseError(f"Unexpected error: {e}") from e
110
+
111
+ if inspect.iscoroutinefunction(func):
112
+ return async_wrapper
113
+ return wrapper
114
+
115
+
116
+ class SyncDatabaseOperations[ModelType](BaseDatabaseOperations):
117
+ """Synchronous database operations."""
118
+
119
+ def __init__(self, model: type[ModelType], session: Session):
120
+ """Initialize with model and session.
121
+
122
+ Args:
123
+ model: SQLAlchemy model class
124
+ session: SQLAlchemy session
125
+ """
126
+ super().__init__(model)
127
+ self.session = session
128
+
129
+ @BaseDatabaseOperations.handle_database_error
130
+ def get_by_id(self, id: Any) -> ModelType | None:
131
+ """Get a record by ID.
132
+
133
+ Args:
134
+ id: Record ID
135
+
136
+ Returns:
137
+ Model instance or None if not found
138
+ """
139
+ return cast(
140
+ ModelType | None,
141
+ self.session.query(self.model).filter(getattr(self.model, "id") == id).first(),
142
+ )
143
+
144
+ def get_by_id_or_404(self, id: Any) -> ModelType:
145
+ """Get a record by ID or raise RecordNotFoundError.
146
+
147
+ Args:
148
+ id: Record ID
149
+
150
+ Returns:
151
+ Model instance
152
+
153
+ Raises:
154
+ RecordNotFoundError: If record not found
155
+ """
156
+ record = self.get_by_id(id)
157
+ if not record:
158
+ raise RecordNotFoundError(f"{self.model.__name__} with id {id} not found")
159
+ return record
160
+
161
+ @BaseDatabaseOperations.handle_database_error
162
+ def get_all(
163
+ self,
164
+ filters: dict[str, Any] | None = None,
165
+ order_by: str | None = None,
166
+ limit: int | None = None,
167
+ offset: int | None = None,
168
+ ) -> list[ModelType]:
169
+ """Get all records with optional filtering and pagination.
170
+
171
+ Args:
172
+ filters: Dictionary of field:value filters
173
+ order_by: Field name to order by (prefix with - for DESC)
174
+ limit: Maximum number of records
175
+ offset: Number of records to skip
176
+
177
+ Returns:
178
+ List of model instances
179
+ """
180
+ query = self.session.query(self.model)
181
+
182
+ # Apply filters
183
+ if filters:
184
+ for field, value in filters.items():
185
+ if hasattr(self.model, field):
186
+ if isinstance(value, list):
187
+ query = query.filter(getattr(self.model, field).in_(value))
188
+ else:
189
+ query = query.filter(getattr(self.model, field) == value)
190
+
191
+ # Apply ordering
192
+ if order_by:
193
+ if order_by.startswith("-"):
194
+ field = order_by[1:]
195
+ if hasattr(self.model, field):
196
+ query = query.order_by(getattr(self.model, field).desc())
197
+ else:
198
+ if hasattr(self.model, order_by):
199
+ query = query.order_by(getattr(self.model, order_by))
200
+
201
+ # Apply pagination
202
+ if offset:
203
+ query = query.offset(offset)
204
+ if limit:
205
+ query = query.limit(limit)
206
+
207
+ return cast(list[ModelType], query.all())
208
+
209
+ @BaseDatabaseOperations.handle_database_error
210
+ def get_one(self, **kwargs: Any) -> ModelType | None:
211
+ """Get a single record by field values.
212
+
213
+ Args:
214
+ **kwargs: Field:value pairs to filter by
215
+
216
+ Returns:
217
+ Model instance or None if not found
218
+ """
219
+ return cast(
220
+ ModelType | None,
221
+ self.session.query(self.model).filter_by(**kwargs).first(),
222
+ )
223
+
224
+ def get_one_or_404(self, **kwargs: Any) -> ModelType:
225
+ """Get a single record by field values or raise RecordNotFoundError.
226
+
227
+ Args:
228
+ **kwargs: Field:value pairs to filter by
229
+
230
+ Returns:
231
+ Model instance
232
+
233
+ Raises:
234
+ RecordNotFoundError: If record not found
235
+ """
236
+ record = self.get_one(**kwargs)
237
+ if not record:
238
+ raise RecordNotFoundError(f"{self.model.__name__} not found with filters: {kwargs}")
239
+ return record
240
+
241
+ @BaseDatabaseOperations.handle_database_error
242
+ def create(self, data: dict[str, Any] | BaseModel, commit: bool = True) -> ModelType:
243
+ """Create a new record.
244
+
245
+ Args:
246
+ data: Dictionary or Pydantic schema with record data
247
+ commit: Whether to commit the transaction
248
+
249
+ Returns:
250
+ Created model instance
251
+ """
252
+ data_dict: dict[str, Any]
253
+ if isinstance(data, BaseModel):
254
+ data_dict = data.model_dump(exclude_unset=True)
255
+ else:
256
+ data_dict = data
257
+
258
+ instance = cast(ModelType, self.model(**data_dict))
259
+ self.session.add(instance)
260
+
261
+ if commit:
262
+ self.session.commit()
263
+ self.session.refresh(instance)
264
+
265
+ return instance
266
+
267
+ @BaseDatabaseOperations.handle_database_error
268
+ def bulk_create(
269
+ self, data_list: list[dict[str, Any] | BaseModel], commit: bool = True
270
+ ) -> list[ModelType]:
271
+ """Create multiple records.
272
+
273
+ Args:
274
+ data_list: List of dictionaries or Pydantic schemas
275
+ commit: Whether to commit the transaction
276
+
277
+ Returns:
278
+ List of created model instances
279
+ """
280
+ instances: list[ModelType] = []
281
+ for data in data_list:
282
+ data_dict: dict[str, Any]
283
+ if isinstance(data, BaseModel):
284
+ data_dict = data.model_dump(exclude_unset=True)
285
+ else:
286
+ data_dict = data
287
+ instance = cast(ModelType, self.model(**data_dict))
288
+ instances.append(instance)
289
+ self.session.add(instance)
290
+
291
+ if commit:
292
+ self.session.commit()
293
+ for instance in instances:
294
+ self.session.refresh(instance)
295
+
296
+ return instances
297
+
298
+ @BaseDatabaseOperations.handle_database_error
299
+ def update(
300
+ self, id: Any, data: dict[str, Any] | BaseModel, commit: bool = True
301
+ ) -> ModelType | None:
302
+ """Update a record by ID.
303
+
304
+ Args:
305
+ id: Record ID
306
+ data: Dictionary or Pydantic schema with update data
307
+ commit: Whether to commit the transaction
308
+
309
+ Returns:
310
+ Updated model instance or None if not found
311
+ """
312
+ instance = self.get_by_id(id)
313
+ if not instance:
314
+ return None
315
+
316
+ data_dict: dict[str, Any]
317
+ if isinstance(data, BaseModel):
318
+ data_dict = data.model_dump(exclude_unset=True)
319
+ else:
320
+ data_dict = data
321
+
322
+ for key, value in data_dict.items():
323
+ if hasattr(instance, key):
324
+ setattr(instance, key, value)
325
+
326
+ if commit:
327
+ self.session.commit()
328
+ self.session.refresh(instance)
329
+
330
+ return instance
331
+
332
+ @BaseDatabaseOperations.handle_database_error
333
+ def update_or_create(
334
+ self, defaults: dict[str, Any], commit: bool = True, **kwargs: Any
335
+ ) -> tuple[ModelType, bool]:
336
+ """Update existing record or create new one.
337
+
338
+ Args:
339
+ defaults: Dictionary of default values for creation/update
340
+ commit: Whether to commit the transaction
341
+ **kwargs: Fields to search by
342
+
343
+ Returns:
344
+ Tuple of (instance, created) where created is True if a new record was created
345
+ """
346
+ existing = self.get_one(**kwargs)
347
+ created = False
348
+
349
+ if existing:
350
+ # Update existing
351
+ for key, value in defaults.items():
352
+ if hasattr(existing, key):
353
+ setattr(existing, key, value)
354
+ instance = existing
355
+ else:
356
+ # Create new
357
+ data = {**kwargs, **defaults}
358
+ instance = cast(ModelType, self.model(**data))
359
+ self.session.add(instance)
360
+ created = True
361
+
362
+ if commit:
363
+ self.session.commit()
364
+ self.session.refresh(instance)
365
+
366
+ return instance, created
367
+
368
+ @BaseDatabaseOperations.handle_database_error
369
+ def delete(self, id: Any, commit: bool = True) -> bool:
370
+ """Delete a record by ID.
371
+
372
+ Args:
373
+ id: Record ID
374
+ commit: Whether to commit the transaction
375
+
376
+ Returns:
377
+ True if deleted, False if not found
378
+ """
379
+ instance = self.get_by_id(id)
380
+ if not instance:
381
+ return False
382
+
383
+ self.session.delete(instance)
384
+
385
+ if commit:
386
+ self.session.commit()
387
+
388
+ return True
389
+
390
+ @BaseDatabaseOperations.handle_database_error
391
+ def delete_many(self, filters: dict[str, Any], commit: bool = True) -> int:
392
+ """Delete multiple records matching filters.
393
+
394
+ Args:
395
+ filters: Dictionary of field:value filters
396
+ commit: Whether to commit the transaction
397
+
398
+ Returns:
399
+ Number of deleted records
400
+ """
401
+ query = self.session.query(self.model)
402
+
403
+ for field, value in filters.items():
404
+ if hasattr(self.model, field):
405
+ if isinstance(value, list):
406
+ query = query.filter(getattr(self.model, field).in_(value))
407
+ else:
408
+ query = query.filter(getattr(self.model, field) == value)
409
+
410
+ count = query.count()
411
+ query.delete()
412
+
413
+ if commit:
414
+ self.session.commit()
415
+
416
+ return count
417
+
418
+ @BaseDatabaseOperations.handle_database_error
419
+ def count(self, filters: dict[str, Any] | None = None) -> int:
420
+ """Count records with optional filtering.
421
+
422
+ Args:
423
+ filters: Dictionary of field:value filters
424
+
425
+ Returns:
426
+ Count of matching records
427
+ """
428
+ query = self.session.query(func.count(getattr(self.model, "id")))
429
+
430
+ if filters:
431
+ for field, value in filters.items():
432
+ if hasattr(self.model, field):
433
+ if isinstance(value, list):
434
+ query = query.filter(getattr(self.model, field).in_(value))
435
+ else:
436
+ query = query.filter(getattr(self.model, field) == value)
437
+
438
+ result = query.scalar()
439
+ return result if result is not None else 0
440
+
441
+ @BaseDatabaseOperations.handle_database_error
442
+ def exists(self, **kwargs: Any) -> bool:
443
+ """Check if a record exists.
444
+
445
+ Args:
446
+ **kwargs: Field:value pairs to filter by
447
+
448
+ Returns:
449
+ True if exists, False otherwise
450
+ """
451
+ return self.session.query(
452
+ self.session.query(self.model).filter_by(**kwargs).exists()
453
+ ).scalar()
454
+
455
+ def transaction(self):
456
+ """Context manager for database transactions."""
457
+ return database_transaction(self.session)
458
+
459
+
460
+ class AsyncDatabaseOperations[ModelType](BaseDatabaseOperations):
461
+ """Asynchronous database operations."""
462
+
463
+ def __init__(self, model: type[ModelType], session: AsyncSession):
464
+ """Initialize with model and async session.
465
+
466
+ Args:
467
+ model: SQLAlchemy model class
468
+ session: SQLAlchemy async session
469
+ """
470
+ super().__init__(model)
471
+ self.session = session
472
+
473
+ @BaseDatabaseOperations.handle_database_error
474
+ async def get_by_id(self, id: Any) -> ModelType | None:
475
+ """Get a record by ID.
476
+
477
+ Args:
478
+ id: Record ID
479
+
480
+ Returns:
481
+ Model instance or None if not found
482
+ """
483
+ result = await self.session.execute(
484
+ select(self.model).where(getattr(self.model, "id") == id)
485
+ )
486
+ return cast(ModelType | None, result.scalar_one_or_none())
487
+
488
+ @BaseDatabaseOperations.handle_database_error
489
+ async def get_by_id_or_404(self, id: Any) -> ModelType:
490
+ """Get a record by ID or raise RecordNotFoundError.
491
+
492
+ Args:
493
+ id: Record ID
494
+
495
+ Returns:
496
+ Model instance
497
+
498
+ Raises:
499
+ RecordNotFoundError: If record not found
500
+ """
501
+ record = await self.get_by_id(id)
502
+ if not record:
503
+ raise RecordNotFoundError(f"{self.model.__name__} with id {id} not found")
504
+ return record
505
+
506
+ @BaseDatabaseOperations.handle_database_error
507
+ async def get_all(
508
+ self,
509
+ filters: dict[str, Any] | None = None,
510
+ order_by: str | None = None,
511
+ limit: int | None = None,
512
+ offset: int | None = None,
513
+ ) -> list[ModelType]:
514
+ """Get all records with optional filtering and pagination.
515
+
516
+ Args:
517
+ filters: Dictionary of field:value filters
518
+ order_by: Field name to order by (prefix with - for DESC)
519
+ limit: Maximum number of records
520
+ offset: Number of records to skip
521
+
522
+ Returns:
523
+ List of model instances
524
+ """
525
+ query = select(self.model)
526
+
527
+ # Apply filters
528
+ if filters:
529
+ conditions = []
530
+ for field, value in filters.items():
531
+ if hasattr(self.model, field):
532
+ if isinstance(value, list):
533
+ conditions.append(getattr(self.model, field).in_(value))
534
+ else:
535
+ conditions.append(getattr(self.model, field) == value)
536
+ if conditions:
537
+ query = query.where(and_(*conditions))
538
+
539
+ # Apply ordering
540
+ if order_by:
541
+ if order_by.startswith("-"):
542
+ field = order_by[1:]
543
+ if hasattr(self.model, field):
544
+ query = query.order_by(getattr(self.model, field).desc())
545
+ else:
546
+ if hasattr(self.model, order_by):
547
+ query = query.order_by(getattr(self.model, order_by))
548
+
549
+ # Apply pagination
550
+ if offset:
551
+ query = query.offset(offset)
552
+ if limit:
553
+ query = query.limit(limit)
554
+
555
+ result = await self.session.execute(query)
556
+ return cast(list[ModelType], list(result.scalars().all()))
557
+
558
+ @BaseDatabaseOperations.handle_database_error
559
+ async def get_one(self, **kwargs: Any) -> ModelType | None:
560
+ """Get a single record by field values.
561
+
562
+ Args:
563
+ **kwargs: Field:value pairs to filter by
564
+
565
+ Returns:
566
+ Model instance or None if not found
567
+ """
568
+ conditions = []
569
+ for field, value in kwargs.items():
570
+ if hasattr(self.model, field):
571
+ conditions.append(getattr(self.model, field) == value)
572
+
573
+ if not conditions:
574
+ return None
575
+
576
+ query = select(self.model).where(and_(*conditions))
577
+ result = await self.session.execute(query)
578
+ return cast(ModelType | None, result.scalar_one_or_none())
579
+
580
+ async def get_one_or_404(self, **kwargs: Any) -> ModelType:
581
+ """Get a single record by field values or raise RecordNotFoundError.
582
+
583
+ Args:
584
+ **kwargs: Field:value pairs to filter by
585
+
586
+ Returns:
587
+ Model instance
588
+
589
+ Raises:
590
+ RecordNotFoundError: If record not found
591
+ """
592
+ record = await self.get_one(**kwargs)
593
+ if not record:
594
+ raise RecordNotFoundError(f"{self.model.__name__} not found with filters: {kwargs}")
595
+ return record
596
+
597
+ @BaseDatabaseOperations.handle_database_error
598
+ async def create(self, data: dict[str, Any] | BaseModel, commit: bool = True) -> ModelType:
599
+ """Create a new record.
600
+
601
+ Args:
602
+ data: Dictionary or Pydantic schema with record data
603
+ commit: Whether to commit the transaction
604
+
605
+ Returns:
606
+ Created model instance
607
+ """
608
+ data_dict: dict[str, Any]
609
+ if isinstance(data, BaseModel):
610
+ data_dict = data.model_dump(exclude_unset=True)
611
+ else:
612
+ data_dict = data
613
+
614
+ instance = cast(ModelType, self.model(**data_dict))
615
+ self.session.add(instance)
616
+
617
+ if commit:
618
+ await self.session.commit()
619
+ await self.session.refresh(instance)
620
+
621
+ return instance
622
+
623
+ @BaseDatabaseOperations.handle_database_error
624
+ async def update(
625
+ self, id: Any, data: dict[str, Any] | BaseModel, commit: bool = True
626
+ ) -> ModelType | None:
627
+ """Update a record by ID.
628
+
629
+ Args:
630
+ id: Record ID
631
+ data: Dictionary or Pydantic schema with update data
632
+ commit: Whether to commit the transaction
633
+
634
+ Returns:
635
+ Updated model instance or None if not found
636
+ """
637
+ instance = await self.get_by_id(id)
638
+ if not instance:
639
+ return None
640
+
641
+ data_dict: dict[str, Any]
642
+ if isinstance(data, BaseModel):
643
+ data_dict = data.model_dump(exclude_unset=True)
644
+ else:
645
+ data_dict = data
646
+
647
+ for key, value in data_dict.items():
648
+ if hasattr(instance, key):
649
+ setattr(instance, key, value)
650
+
651
+ if commit:
652
+ await self.session.commit()
653
+ await self.session.refresh(instance)
654
+
655
+ return instance
656
+
657
+ @BaseDatabaseOperations.handle_database_error
658
+ async def delete(self, id: Any, commit: bool = True) -> bool:
659
+ """Delete a record by ID.
660
+
661
+ Args:
662
+ id: Record ID
663
+ commit: Whether to commit the transaction
664
+
665
+ Returns:
666
+ True if deleted, False if not found
667
+ """
668
+ instance = await self.get_by_id(id)
669
+ if not instance:
670
+ return False
671
+
672
+ await self.session.delete(instance)
673
+
674
+ if commit:
675
+ await self.session.commit()
676
+
677
+ return True
678
+
679
+ async def transaction(self):
680
+ """Context manager for async database transactions."""
681
+ return async_database_transaction(self.session)
682
+
683
+
684
+ # Transaction context managers
685
+
686
+
687
+ @contextmanager
688
+ def database_transaction(session: Session):
689
+ """Context manager for database transactions.
690
+
691
+ Usage:
692
+ with database_transaction(session) as trans:
693
+ # Perform operations
694
+ trans.commit() # Explicit commit
695
+ # Auto-rollback if no commit
696
+ """
697
+ try:
698
+ yield session
699
+ except Exception:
700
+ session.rollback()
701
+ raise
702
+ else:
703
+ # If no explicit commit was called, rollback
704
+ if session.new or session.dirty or session.deleted:
705
+ session.rollback()
706
+
707
+
708
+ @asynccontextmanager
709
+ async def async_database_transaction(session: AsyncSession):
710
+ """Async context manager for database transactions.
711
+
712
+ Usage:
713
+ async with async_database_transaction(session) as trans:
714
+ # Perform operations
715
+ await trans.commit() # Explicit commit
716
+ # Auto-rollback if no commit
717
+ """
718
+ try:
719
+ yield session
720
+ except Exception:
721
+ await session.rollback()
722
+ raise
723
+ else:
724
+ # If no explicit commit was called, rollback
725
+ if session.new or session.dirty or session.deleted:
726
+ await session.rollback()
727
+
728
+
729
+ # Utility functions
730
+
731
+
732
+ def paginate_query(
733
+ query: Query, page: int = 1, per_page: int = 20, max_per_page: int = 100
734
+ ) -> dict[str, Any]:
735
+ """Add pagination to a query and return paginated results.
736
+
737
+ Args:
738
+ query: SQLAlchemy query
739
+ page: Page number (1-indexed)
740
+ per_page: Items per page
741
+ max_per_page: Maximum allowed items per page
742
+
743
+ Returns:
744
+ Dictionary with items, total, page, per_page, pages
745
+ """
746
+ per_page = min(per_page, max_per_page)
747
+ page = max(1, page)
748
+
749
+ total = query.count()
750
+ items = query.limit(per_page).offset((page - 1) * per_page).all()
751
+
752
+ return {
753
+ "items": items,
754
+ "total": total,
755
+ "page": page,
756
+ "per_page": per_page,
757
+ "pages": (total + per_page - 1) // per_page, # Ceiling division
758
+ }
759
+
760
+
761
+ async def async_paginate_query(
762
+ session: AsyncSession,
763
+ query: Select[Any],
764
+ model: type[Any],
765
+ page: int = 1,
766
+ per_page: int = 20,
767
+ max_per_page: int = 100,
768
+ ) -> dict[str, Any]:
769
+ """Add pagination to an async query and return paginated results.
770
+
771
+ Args:
772
+ session: Async SQLAlchemy session
773
+ query: SQLAlchemy select statement
774
+ model: Model class for counting
775
+ page: Page number (1-indexed)
776
+ per_page: Items per page
777
+ max_per_page: Maximum allowed items per page
778
+
779
+ Returns:
780
+ Dictionary with items, total, page, per_page, pages
781
+ """
782
+ per_page = min(per_page, max_per_page)
783
+ page = max(1, page)
784
+
785
+ # Get total count
786
+ count_query = select(func.count()).select_from(model)
787
+ # Apply same where conditions as main query
788
+ whereclause = getattr(query, "whereclause", None)
789
+ if whereclause is not None:
790
+ count_query = count_query.where(whereclause)
791
+
792
+ total_result = await session.execute(count_query)
793
+ total = total_result.scalar() or 0
794
+
795
+ # Get paginated items
796
+ paginated_query = query.limit(per_page).offset((page - 1) * per_page)
797
+ result = await session.execute(paginated_query)
798
+ items = result.scalars().all()
799
+
800
+ return {
801
+ "items": items,
802
+ "total": total,
803
+ "page": page,
804
+ "per_page": per_page,
805
+ "pages": (total + per_page - 1) // per_page if total > 0 else 0,
806
+ }
807
+
808
+
809
+ def bulk_update_mappings(
810
+ session: Session,
811
+ model: type[Any],
812
+ mappings: list[dict[str, Any]],
813
+ commit: bool = True,
814
+ ) -> None:
815
+ """Bulk update records using mappings.
816
+
817
+ Args:
818
+ session: SQLAlchemy session
819
+ model: Model class
820
+ mappings: List of dictionaries with updates (must include id)
821
+ commit: Whether to commit the transaction
822
+ """
823
+ if not mappings:
824
+ return
825
+
826
+ # Use core update for each mapping since bulk_update_mappings expects a Mapper
827
+ for mapping in mappings:
828
+ mapping_copy = dict(mapping)
829
+ record_id = mapping_copy.pop("id", None)
830
+ if record_id is None:
831
+ continue
832
+ stmt = update(model).where(getattr(model, "id") == record_id).values(**mapping_copy)
833
+ session.execute(stmt)
834
+
835
+ if commit:
836
+ session.commit()
837
+
838
+
839
+ async def async_bulk_update_mappings(
840
+ session: AsyncSession,
841
+ model: type[Any],
842
+ mappings: list[dict[str, Any]],
843
+ commit: bool = True,
844
+ ) -> None:
845
+ """Async bulk update records using mappings.
846
+
847
+ Args:
848
+ session: Async SQLAlchemy session
849
+ model: Model class
850
+ mappings: List of dictionaries with updates (must include id)
851
+ commit: Whether to commit the transaction
852
+ """
853
+ if not mappings:
854
+ return
855
+
856
+ # For async, we need to use core update statements
857
+ for mapping in mappings:
858
+ mapping_copy = dict(mapping)
859
+ record_id = mapping_copy.pop("id", None)
860
+ if record_id is None:
861
+ continue
862
+
863
+ stmt = update(model).where(getattr(model, "id") == record_id).values(**mapping_copy)
864
+ await session.execute(stmt)
865
+
866
+ if commit:
867
+ await session.commit()
868
+
869
+
870
+ # Repository base classes
871
+
872
+
873
+ class BaseRepository[ModelType]:
874
+ """Base repository class for common patterns."""
875
+
876
+ db: SyncDatabaseOperations[ModelType] | AsyncDatabaseOperations[ModelType]
877
+
878
+ def __init__(self, model: type[ModelType], session: Session | AsyncSession):
879
+ """Initialize repository.
880
+
881
+ Args:
882
+ model: SQLAlchemy model class
883
+ session: Database session
884
+ """
885
+ self.model = model
886
+ self.session = session
887
+
888
+ # Initialize appropriate operations class
889
+ if isinstance(session, AsyncSession):
890
+ self.db = AsyncDatabaseOperations[ModelType](model, session)
891
+ else:
892
+ self.db = SyncDatabaseOperations[ModelType](model, session)
893
+
894
+
895
+ class SyncRepository(BaseRepository[ModelType]):
896
+ """Base class for synchronous repositories."""
897
+
898
+ db: SyncDatabaseOperations[ModelType]
899
+
900
+ def __init__(self, model: type[ModelType], session: Session):
901
+ """Initialize sync repository."""
902
+ self.model = model
903
+ self.session = session
904
+ self.db = SyncDatabaseOperations[ModelType](model, session)
905
+
906
+
907
+ class AsyncRepository(BaseRepository[ModelType]):
908
+ """Base class for asynchronous repositories."""
909
+
910
+ db: AsyncDatabaseOperations[ModelType]
911
+
912
+ def __init__(self, model: type[ModelType], session: AsyncSession):
913
+ """Initialize async repository."""
914
+ self.model = model
915
+ self.session = session
916
+ self.db = AsyncDatabaseOperations[ModelType](model, session)