fastapi-toolsets 0.3.0__tar.gz → 0.4.1__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.
Files changed (25) hide show
  1. {fastapi_toolsets-0.3.0 → fastapi_toolsets-0.4.1}/PKG-INFO +2 -1
  2. {fastapi_toolsets-0.3.0 → fastapi_toolsets-0.4.1}/pyproject.toml +2 -1
  3. {fastapi_toolsets-0.3.0 → fastapi_toolsets-0.4.1}/src/fastapi_toolsets/__init__.py +1 -1
  4. fastapi_toolsets-0.4.1/src/fastapi_toolsets/crud/__init__.py +17 -0
  5. fastapi_toolsets-0.3.0/src/fastapi_toolsets/crud.py → fastapi_toolsets-0.4.1/src/fastapi_toolsets/crud/factory.py +67 -29
  6. fastapi_toolsets-0.4.1/src/fastapi_toolsets/crud/search.py +146 -0
  7. {fastapi_toolsets-0.3.0 → fastapi_toolsets-0.4.1}/src/fastapi_toolsets/exceptions/__init__.py +6 -2
  8. {fastapi_toolsets-0.3.0 → fastapi_toolsets-0.4.1}/src/fastapi_toolsets/exceptions/exceptions.py +19 -0
  9. {fastapi_toolsets-0.3.0 → fastapi_toolsets-0.4.1}/LICENSE +0 -0
  10. {fastapi_toolsets-0.3.0 → fastapi_toolsets-0.4.1}/README.md +0 -0
  11. {fastapi_toolsets-0.3.0 → fastapi_toolsets-0.4.1}/src/fastapi_toolsets/cli/__init__.py +0 -0
  12. {fastapi_toolsets-0.3.0 → fastapi_toolsets-0.4.1}/src/fastapi_toolsets/cli/app.py +0 -0
  13. {fastapi_toolsets-0.3.0 → fastapi_toolsets-0.4.1}/src/fastapi_toolsets/cli/commands/__init__.py +0 -0
  14. {fastapi_toolsets-0.3.0 → fastapi_toolsets-0.4.1}/src/fastapi_toolsets/cli/commands/fixtures.py +0 -0
  15. {fastapi_toolsets-0.3.0 → fastapi_toolsets-0.4.1}/src/fastapi_toolsets/db.py +0 -0
  16. {fastapi_toolsets-0.3.0 → fastapi_toolsets-0.4.1}/src/fastapi_toolsets/exceptions/handler.py +0 -0
  17. {fastapi_toolsets-0.3.0 → fastapi_toolsets-0.4.1}/src/fastapi_toolsets/fixtures/__init__.py +0 -0
  18. {fastapi_toolsets-0.3.0 → fastapi_toolsets-0.4.1}/src/fastapi_toolsets/fixtures/enum.py +0 -0
  19. {fastapi_toolsets-0.3.0 → fastapi_toolsets-0.4.1}/src/fastapi_toolsets/fixtures/registry.py +0 -0
  20. {fastapi_toolsets-0.3.0 → fastapi_toolsets-0.4.1}/src/fastapi_toolsets/fixtures/utils.py +0 -0
  21. {fastapi_toolsets-0.3.0 → fastapi_toolsets-0.4.1}/src/fastapi_toolsets/py.typed +0 -0
  22. {fastapi_toolsets-0.3.0 → fastapi_toolsets-0.4.1}/src/fastapi_toolsets/pytest/__init__.py +0 -0
  23. {fastapi_toolsets-0.3.0 → fastapi_toolsets-0.4.1}/src/fastapi_toolsets/pytest/plugin.py +0 -0
  24. {fastapi_toolsets-0.3.0 → fastapi_toolsets-0.4.1}/src/fastapi_toolsets/pytest/utils.py +0 -0
  25. {fastapi_toolsets-0.3.0 → fastapi_toolsets-0.4.1}/src/fastapi_toolsets/schemas.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-toolsets
3
- Version: 0.3.0
3
+ Version: 0.4.1
4
4
  Summary: Reusable tools for FastAPI: async CRUD, fixtures, CLI, and standardized responses for SQLAlchemy + PostgreSQL
5
5
  Keywords: fastapi,sqlalchemy,postgresql
6
6
  Author: d3vyce
@@ -20,6 +20,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
20
20
  Classifier: Programming Language :: Python :: 3.11
21
21
  Classifier: Programming Language :: Python :: 3.12
22
22
  Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
23
24
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
25
  Classifier: Topic :: Software Development :: Libraries
25
26
  Classifier: Topic :: Software Development
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-toolsets"
3
- version = "0.3.0"
3
+ version = "0.4.1"
4
4
  description = "Reusable tools for FastAPI: async CRUD, fixtures, CLI, and standardized responses for SQLAlchemy + PostgreSQL"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -24,6 +24,7 @@ classifiers = [
24
24
  "Programming Language :: Python :: 3.11",
25
25
  "Programming Language :: Python :: 3.12",
26
26
  "Programming Language :: Python :: 3.13",
27
+ "Programming Language :: Python :: 3.14",
27
28
  "Topic :: Software Development :: Libraries :: Python Modules",
28
29
  "Topic :: Software Development :: Libraries",
29
30
  "Topic :: Software Development",
@@ -21,4 +21,4 @@ Example usage:
21
21
  return Response(data={"user": user.username}, message="Success")
22
22
  """
23
23
 
24
- __version__ = "0.3.0"
24
+ __version__ = "0.4.1"
@@ -0,0 +1,17 @@
1
+ """Generic async CRUD operations for SQLAlchemy models."""
2
+
3
+ from ..exceptions import NoSearchableFieldsError
4
+ from .factory import CrudFactory
5
+ from .search import (
6
+ SearchConfig,
7
+ SearchFieldType,
8
+ get_searchable_fields,
9
+ )
10
+
11
+ __all__ = [
12
+ "CrudFactory",
13
+ "get_searchable_fields",
14
+ "NoSearchableFieldsError",
15
+ "SearchConfig",
16
+ "SearchFieldType",
17
+ ]
@@ -12,13 +12,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
12
12
  from sqlalchemy.orm import DeclarativeBase
13
13
  from sqlalchemy.sql.roles import WhereHavingRole
14
14
 
15
- from .db import get_transaction
16
- from .exceptions import NotFoundError
17
-
18
- __all__ = [
19
- "AsyncCrud",
20
- "CrudFactory",
21
- ]
15
+ from ..db import get_transaction
16
+ from ..exceptions import NotFoundError
17
+ from .search import SearchConfig, SearchFieldType, build_search_filters
22
18
 
23
19
  ModelType = TypeVar("ModelType", bound=DeclarativeBase)
24
20
 
@@ -27,20 +23,10 @@ class AsyncCrud(Generic[ModelType]):
27
23
  """Generic async CRUD operations for SQLAlchemy models.
28
24
 
29
25
  Subclass this and set the `model` class variable, or use `CrudFactory`.
30
-
31
- Example:
32
- class UserCrud(AsyncCrud[User]):
33
- model = User
34
-
35
- # Or use the factory:
36
- UserCrud = CrudFactory(User)
37
-
38
- # Then use it:
39
- user = await UserCrud.get(session, [User.id == 1])
40
- users = await UserCrud.get_multi(session, limit=10)
41
26
  """
42
27
 
43
28
  model: ClassVar[type[DeclarativeBase]]
29
+ searchable_fields: ClassVar[Sequence[SearchFieldType] | None] = None
44
30
 
45
31
  @classmethod
46
32
  async def create(
@@ -313,6 +299,8 @@ class AsyncCrud(Generic[ModelType]):
313
299
  order_by: Any | None = None,
314
300
  page: int = 1,
315
301
  items_per_page: int = 20,
302
+ search: str | SearchConfig | None = None,
303
+ search_fields: Sequence[SearchFieldType] | None = None,
316
304
  ) -> dict[str, Any]:
317
305
  """Get paginated results with metadata.
318
306
 
@@ -323,23 +311,54 @@ class AsyncCrud(Generic[ModelType]):
323
311
  order_by: Column or list of columns to order by
324
312
  page: Page number (1-indexed)
325
313
  items_per_page: Number of items per page
314
+ search: Search query string or SearchConfig object
315
+ search_fields: Fields to search in (overrides class default)
326
316
 
327
317
  Returns:
328
318
  Dict with 'data' and 'pagination' keys
329
319
  """
330
- filters = filters or []
320
+ filters = list(filters) if filters else []
331
321
  offset = (page - 1) * items_per_page
322
+ joins: list[Any] = []
323
+
324
+ # Build search filters
325
+ if search:
326
+ search_filters, search_joins = build_search_filters(
327
+ cls.model,
328
+ search,
329
+ search_fields=search_fields,
330
+ default_fields=cls.searchable_fields,
331
+ )
332
+ filters.extend(search_filters)
333
+ joins.extend(search_joins)
332
334
 
333
- items = await cls.get_multi(
334
- session,
335
- filters=filters,
336
- load_options=load_options,
337
- order_by=order_by,
338
- limit=items_per_page,
339
- offset=offset,
340
- )
335
+ # Build query with joins
336
+ q = select(cls.model)
337
+ for join_rel in joins:
338
+ q = q.outerjoin(join_rel)
341
339
 
342
- total_count = await cls.count(session, filters=filters)
340
+ if filters:
341
+ q = q.where(and_(*filters))
342
+ if load_options:
343
+ q = q.options(*load_options)
344
+ if order_by is not None:
345
+ q = q.order_by(order_by)
346
+
347
+ q = q.offset(offset).limit(items_per_page)
348
+ result = await session.execute(q)
349
+ items = result.unique().scalars().all()
350
+
351
+ # Count query (with same joins and filters)
352
+ pk_col = cls.model.__mapper__.primary_key[0]
353
+ count_q = select(func.count(func.distinct(getattr(cls.model, pk_col.name))))
354
+ count_q = count_q.select_from(cls.model)
355
+ for join_rel in joins:
356
+ count_q = count_q.outerjoin(join_rel)
357
+ if filters:
358
+ count_q = count_q.where(and_(*filters))
359
+
360
+ count_result = await session.execute(count_q)
361
+ total_count = count_result.scalar_one()
343
362
 
344
363
  return {
345
364
  "data": items,
@@ -354,11 +373,14 @@ class AsyncCrud(Generic[ModelType]):
354
373
 
355
374
  def CrudFactory(
356
375
  model: type[ModelType],
376
+ *,
377
+ searchable_fields: Sequence[SearchFieldType] | None = None,
357
378
  ) -> type[AsyncCrud[ModelType]]:
358
379
  """Create a CRUD class for a specific model.
359
380
 
360
381
  Args:
361
382
  model: SQLAlchemy model class
383
+ searchable_fields: Optional list of searchable fields
362
384
 
363
385
  Returns:
364
386
  AsyncCrud subclass bound to the model
@@ -370,9 +392,25 @@ def CrudFactory(
370
392
  UserCrud = CrudFactory(User)
371
393
  PostCrud = CrudFactory(Post)
372
394
 
395
+ # With searchable fields:
396
+ UserCrud = CrudFactory(
397
+ User,
398
+ searchable_fields=[User.username, User.email, (User.role, Role.name)]
399
+ )
400
+
373
401
  # Usage
374
402
  user = await UserCrud.get(session, [User.id == 1])
375
403
  posts = await PostCrud.get_multi(session, filters=[Post.user_id == user.id])
404
+
405
+ # With search
406
+ result = await UserCrud.paginate(session, search="john")
376
407
  """
377
- cls = type(f"Async{model.__name__}Crud", (AsyncCrud,), {"model": model})
408
+ cls = type(
409
+ f"Async{model.__name__}Crud",
410
+ (AsyncCrud,),
411
+ {
412
+ "model": model,
413
+ "searchable_fields": searchable_fields,
414
+ },
415
+ )
378
416
  return cast(type[AsyncCrud[ModelType]], cls)
@@ -0,0 +1,146 @@
1
+ """Search utilities for AsyncCrud."""
2
+
3
+ from collections.abc import Sequence
4
+ from dataclasses import dataclass
5
+ from typing import TYPE_CHECKING, Any, Literal
6
+
7
+ from sqlalchemy import String, or_
8
+ from sqlalchemy.orm import DeclarativeBase
9
+ from sqlalchemy.orm.attributes import InstrumentedAttribute
10
+
11
+ from ..exceptions import NoSearchableFieldsError
12
+
13
+ if TYPE_CHECKING:
14
+ from sqlalchemy.sql.elements import ColumnElement
15
+
16
+ SearchFieldType = InstrumentedAttribute[Any] | tuple[InstrumentedAttribute[Any], ...]
17
+
18
+
19
+ @dataclass
20
+ class SearchConfig:
21
+ """Advanced search configuration.
22
+
23
+ Attributes:
24
+ query: The search string
25
+ fields: Fields to search (columns or tuples for relationships)
26
+ case_sensitive: Case-sensitive search (default: False)
27
+ match_mode: "any" (OR) or "all" (AND) to combine fields
28
+ """
29
+
30
+ query: str
31
+ fields: Sequence[SearchFieldType] | None = None
32
+ case_sensitive: bool = False
33
+ match_mode: Literal["any", "all"] = "any"
34
+
35
+
36
+ def get_searchable_fields(
37
+ model: type[DeclarativeBase],
38
+ *,
39
+ include_relationships: bool = True,
40
+ max_depth: int = 1,
41
+ ) -> list[SearchFieldType]:
42
+ """Auto-detect String fields on a model and its relationships.
43
+
44
+ Args:
45
+ model: SQLAlchemy model class
46
+ include_relationships: Include fields from many-to-one/one-to-one relationships
47
+ max_depth: Max depth for relationship traversal (default: 1)
48
+
49
+ Returns:
50
+ List of columns and tuples (relationship, column)
51
+ """
52
+ fields: list[SearchFieldType] = []
53
+ mapper = model.__mapper__
54
+
55
+ # Direct String columns
56
+ for col in mapper.columns:
57
+ if isinstance(col.type, String):
58
+ fields.append(getattr(model, col.key))
59
+
60
+ # Relationships (one-to-one, many-to-one only)
61
+ if include_relationships and max_depth > 0:
62
+ for rel_name, rel_prop in mapper.relationships.items():
63
+ if rel_prop.uselist: # Skip collections (one-to-many, many-to-many)
64
+ continue
65
+
66
+ rel_attr = getattr(model, rel_name)
67
+ related_model = rel_prop.mapper.class_
68
+
69
+ for col in related_model.__mapper__.columns:
70
+ if isinstance(col.type, String):
71
+ fields.append((rel_attr, getattr(related_model, col.key)))
72
+
73
+ return fields
74
+
75
+
76
+ def build_search_filters(
77
+ model: type[DeclarativeBase],
78
+ search: str | SearchConfig,
79
+ search_fields: Sequence[SearchFieldType] | None = None,
80
+ default_fields: Sequence[SearchFieldType] | None = None,
81
+ ) -> tuple[list["ColumnElement[bool]"], list[InstrumentedAttribute[Any]]]:
82
+ """Build SQLAlchemy filter conditions for search.
83
+
84
+ Args:
85
+ model: SQLAlchemy model class
86
+ search: Search string or SearchConfig
87
+ search_fields: Fields specified per-call (takes priority)
88
+ default_fields: Default fields (from ClassVar)
89
+
90
+ Returns:
91
+ Tuple of (filter_conditions, joins_needed)
92
+ """
93
+ # Normalize input
94
+ if isinstance(search, str):
95
+ config = SearchConfig(query=search, fields=search_fields)
96
+ else:
97
+ config = search
98
+ if search_fields is not None:
99
+ config = SearchConfig(
100
+ query=config.query,
101
+ fields=search_fields,
102
+ case_sensitive=config.case_sensitive,
103
+ match_mode=config.match_mode,
104
+ )
105
+
106
+ if not config.query or not config.query.strip():
107
+ return [], []
108
+
109
+ # Determine which fields to search
110
+ fields = config.fields or default_fields or get_searchable_fields(model)
111
+
112
+ if not fields:
113
+ raise NoSearchableFieldsError(model)
114
+
115
+ query = config.query.strip()
116
+ filters: list[ColumnElement[bool]] = []
117
+ joins: list[InstrumentedAttribute[Any]] = []
118
+ added_joins: set[str] = set()
119
+
120
+ for field in fields:
121
+ if isinstance(field, tuple):
122
+ # Relationship: (User.role, Role.name) or deeper
123
+ for rel in field[:-1]:
124
+ rel_key = str(rel)
125
+ if rel_key not in added_joins:
126
+ joins.append(rel)
127
+ added_joins.add(rel_key)
128
+ column = field[-1]
129
+ else:
130
+ column = field
131
+
132
+ # Build the filter (cast to String for non-text columns)
133
+ column_as_string = column.cast(String)
134
+ if config.case_sensitive:
135
+ filters.append(column_as_string.like(f"%{query}%"))
136
+ else:
137
+ filters.append(column_as_string.ilike(f"%{query}%"))
138
+
139
+ if not filters:
140
+ return [], []
141
+
142
+ # Combine based on match_mode
143
+ if config.match_mode == "any":
144
+ return [or_(*filters)], joins
145
+ else:
146
+ return filters, joins
@@ -1,7 +1,9 @@
1
1
  from .exceptions import (
2
+ ApiError,
2
3
  ApiException,
3
4
  ConflictError,
4
5
  ForbiddenError,
6
+ NoSearchableFieldsError,
5
7
  NotFoundError,
6
8
  UnauthorizedError,
7
9
  generate_error_responses,
@@ -9,11 +11,13 @@ from .exceptions import (
9
11
  from .handler import init_exceptions_handlers
10
12
 
11
13
  __all__ = [
12
- "init_exceptions_handlers",
13
- "generate_error_responses",
14
+ "ApiError",
14
15
  "ApiException",
15
16
  "ConflictError",
16
17
  "ForbiddenError",
18
+ "generate_error_responses",
19
+ "init_exceptions_handlers",
20
+ "NoSearchableFieldsError",
17
21
  "NotFoundError",
18
22
  "UnauthorizedError",
19
23
  ]
@@ -119,6 +119,25 @@ class RoleNotFoundError(NotFoundError):
119
119
  )
120
120
 
121
121
 
122
+ class NoSearchableFieldsError(ApiException):
123
+ """Raised when search is requested but no searchable fields are available."""
124
+
125
+ api_error = ApiError(
126
+ code=400,
127
+ msg="No Searchable Fields",
128
+ desc="No searchable fields configured for this resource.",
129
+ err_code="SEARCH-400",
130
+ )
131
+
132
+ def __init__(self, model: type) -> None:
133
+ self.model = model
134
+ detail = (
135
+ f"No searchable fields found for model '{model.__name__}'. "
136
+ "Provide 'search_fields' parameter or set 'searchable_fields' on the CRUD class."
137
+ )
138
+ super().__init__(detail)
139
+
140
+
122
141
  def generate_error_responses(
123
142
  *errors: type[ApiException],
124
143
  ) -> dict[int | str, dict[str, Any]]: