fastapi-toolsets 0.4.0__py3-none-any.whl → 0.5.0__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.
@@ -21,4 +21,4 @@ Example usage:
21
21
  return Response(data={"user": user.username}, message="Success")
22
22
  """
23
23
 
24
- __version__ = "0.4.0"
24
+ __version__ = "0.5.0"
@@ -1,5 +1,6 @@
1
1
  """CLI for FastAPI projects."""
2
2
 
3
- from .app import app, register_command
3
+ from .app import cli
4
+ from .utils import async_command
4
5
 
5
- __all__ = ["app", "register_command"]
6
+ __all__ = ["async_command", "cli"]
@@ -1,97 +1,25 @@
1
1
  """Main CLI application."""
2
2
 
3
- import importlib.util
4
- import sys
5
- from pathlib import Path
6
- from typing import Annotated
7
-
8
3
  import typer
9
4
 
10
- from .commands import fixtures
5
+ from .config import load_config
11
6
 
12
- app = typer.Typer(
13
- name="fastapi-utils",
7
+ cli = typer.Typer(
8
+ name="manager",
14
9
  help="CLI utilities for FastAPI projects.",
15
10
  no_args_is_help=True,
16
11
  )
17
12
 
18
- # Register built-in commands
19
- app.add_typer(fixtures.app, name="fixtures")
20
-
21
-
22
- def register_command(command: typer.Typer, name: str) -> None:
23
- """Register a custom command group.
24
-
25
- Args:
26
- command: Typer app for the command group
27
- name: Name for the command group
13
+ _config = load_config()
28
14
 
29
- Example:
30
- # In your project's cli.py:
31
- import typer
32
- from fastapi_toolsets.cli import app, register_command
15
+ if _config.fixtures:
16
+ from .commands.fixtures import fixture_cli
33
17
 
34
- my_commands = typer.Typer()
18
+ cli.add_typer(fixture_cli, name="fixtures")
35
19
 
36
- @my_commands.command()
37
- def seed():
38
- '''Seed the database.'''
39
- ...
40
20
 
41
- register_command(my_commands, "db")
42
- # Now available as: fastapi-utils db seed
43
- """
44
- app.add_typer(command, name=name)
45
-
46
-
47
- @app.callback()
48
- def main(
49
- ctx: typer.Context,
50
- config: Annotated[
51
- Path | None,
52
- typer.Option(
53
- "--config",
54
- "-c",
55
- help="Path to project config file (Python module with fixtures registry).",
56
- envvar="FASTAPI_TOOLSETS_CONFIG",
57
- ),
58
- ] = None,
59
- ) -> None:
21
+ @cli.callback()
22
+ def main(ctx: typer.Context) -> None:
60
23
  """FastAPI utilities CLI."""
61
24
  ctx.ensure_object(dict)
62
-
63
- if config:
64
- ctx.obj["config_path"] = config
65
- # Load the config module
66
- config_module = _load_module_from_path(config)
67
- ctx.obj["config_module"] = config_module
68
-
69
-
70
- def _load_module_from_path(path: Path) -> object:
71
- """Load a Python module from a file path.
72
-
73
- Handles both absolute and relative imports by adding the config's
74
- parent directory to sys.path temporarily.
75
- """
76
- path = path.resolve()
77
-
78
- # Add the parent directory to sys.path to support relative imports
79
- parent_dir = str(
80
- path.parent.parent
81
- ) # Go up two levels (e.g., from app/cli_config.py to project root)
82
- if parent_dir not in sys.path:
83
- sys.path.insert(0, parent_dir)
84
-
85
- # Also add immediate parent for direct module imports
86
- immediate_parent = str(path.parent)
87
- if immediate_parent not in sys.path:
88
- sys.path.insert(0, immediate_parent)
89
-
90
- spec = importlib.util.spec_from_file_location("config", path)
91
- if spec is None or spec.loader is None:
92
- raise typer.BadParameter(f"Cannot load module from {path}")
93
-
94
- module = importlib.util.module_from_spec(spec)
95
- sys.modules["config"] = module
96
- spec.loader.exec_module(module)
97
- return module
25
+ ctx.obj["config"] = _config
@@ -1,55 +1,29 @@
1
1
  """Fixture management commands."""
2
2
 
3
- import asyncio
4
3
  from typing import Annotated
5
4
 
6
5
  import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
7
8
 
8
- from ...fixtures import Context, FixtureRegistry, LoadStrategy, load_fixtures_by_context
9
+ from ...fixtures import Context, LoadStrategy, load_fixtures_by_context
10
+ from ..config import CliConfig
11
+ from ..utils import async_command
9
12
 
10
- app = typer.Typer(
13
+ fixture_cli = typer.Typer(
11
14
  name="fixtures",
12
15
  help="Manage database fixtures.",
13
16
  no_args_is_help=True,
14
17
  )
18
+ console = Console()
15
19
 
16
20
 
17
- def _get_registry(ctx: typer.Context) -> FixtureRegistry:
18
- """Get fixture registry from context."""
19
- config = ctx.obj.get("config_module") if ctx.obj else None
20
- if config is None:
21
- raise typer.BadParameter(
22
- "No config provided. Use --config to specify a config file with a 'fixtures' registry."
23
- )
24
-
25
- registry = getattr(config, "fixtures", None)
26
- if registry is None:
27
- raise typer.BadParameter(
28
- "Config module must have a 'fixtures' attribute (FixtureRegistry instance)."
29
- )
30
-
31
- if not isinstance(registry, FixtureRegistry):
32
- raise typer.BadParameter(
33
- f"'fixtures' must be a FixtureRegistry instance, got {type(registry).__name__}"
34
- )
35
-
36
- return registry
37
-
21
+ def _get_config(ctx: typer.Context) -> CliConfig:
22
+ """Get CLI config from context."""
23
+ return ctx.obj["config"]
38
24
 
39
- def _get_db_context(ctx: typer.Context):
40
- """Get database context manager from config."""
41
- config = ctx.obj.get("config_module") if ctx.obj else None
42
- if config is None:
43
- raise typer.BadParameter("No config provided.")
44
25
 
45
- get_db_context = getattr(config, "get_db_context", None)
46
- if get_db_context is None:
47
- raise typer.BadParameter("Config module must have a 'get_db_context' function.")
48
-
49
- return get_db_context
50
-
51
-
52
- @app.command("list")
26
+ @fixture_cli.command("list")
53
27
  def list_fixtures(
54
28
  ctx: typer.Context,
55
29
  context: Annotated[
@@ -62,64 +36,28 @@ def list_fixtures(
62
36
  ] = None,
63
37
  ) -> None:
64
38
  """List all registered fixtures."""
65
- registry = _get_registry(ctx)
66
-
67
- if context:
68
- fixtures = registry.get_by_context(context)
69
- else:
70
- fixtures = registry.get_all()
39
+ config = _get_config(ctx)
40
+ registry = config.get_fixtures_registry()
41
+ fixtures = registry.get_by_context(context) if context else registry.get_all()
71
42
 
72
43
  if not fixtures:
73
- typer.echo("No fixtures found.")
44
+ print("No fixtures found.")
74
45
  return
75
46
 
76
- typer.echo(f"\n{'Name':<30} {'Contexts':<30} {'Dependencies'}")
77
- typer.echo("-" * 80)
47
+ table = Table("Name", "Contexts", "Dependencies")
78
48
 
79
49
  for fixture in fixtures:
80
50
  contexts = ", ".join(fixture.contexts)
81
51
  deps = ", ".join(fixture.depends_on) if fixture.depends_on else "-"
82
- typer.echo(f"{fixture.name:<30} {contexts:<30} {deps}")
52
+ table.add_row(fixture.name, contexts, deps)
83
53
 
84
- typer.echo(f"\nTotal: {len(fixtures)} fixture(s)")
54
+ console.print(table)
55
+ print(f"\nTotal: {len(fixtures)} fixture(s)")
85
56
 
86
57
 
87
- @app.command("graph")
88
- def show_graph(
89
- ctx: typer.Context,
90
- fixture_name: Annotated[
91
- str | None,
92
- typer.Argument(help="Show dependencies for a specific fixture."),
93
- ] = None,
94
- ) -> None:
95
- """Show fixture dependency graph."""
96
- registry = _get_registry(ctx)
97
-
98
- if fixture_name:
99
- try:
100
- order = registry.resolve_dependencies(fixture_name)
101
- typer.echo(f"\nDependency chain for '{fixture_name}':\n")
102
- for i, name in enumerate(order):
103
- indent = " " * i
104
- arrow = "└─> " if i > 0 else ""
105
- typer.echo(f"{indent}{arrow}{name}")
106
- except KeyError:
107
- typer.echo(f"Fixture '{fixture_name}' not found.", err=True)
108
- raise typer.Exit(1)
109
- else:
110
- # Show full graph
111
- fixtures = registry.get_all()
112
-
113
- typer.echo("\nFixture Dependency Graph:\n")
114
- for fixture in fixtures:
115
- deps = (
116
- f" -> [{', '.join(fixture.depends_on)}]" if fixture.depends_on else ""
117
- )
118
- typer.echo(f" {fixture.name}{deps}")
119
-
120
-
121
- @app.command("load")
122
- def load(
58
+ @fixture_cli.command("load")
59
+ @async_command
60
+ async def load(
123
61
  ctx: typer.Context,
124
62
  contexts: Annotated[
125
63
  list[str] | None,
@@ -141,16 +79,12 @@ def load(
141
79
  ] = False,
142
80
  ) -> None:
143
81
  """Load fixtures into the database."""
144
- registry = _get_registry(ctx)
145
- get_db_context = _get_db_context(ctx)
82
+ config = _get_config(ctx)
83
+ registry = config.get_fixtures_registry()
84
+ get_db_context = config.get_db_context()
146
85
 
147
- # Parse contexts
148
- if contexts:
149
- context_list = contexts
150
- else:
151
- context_list = [Context.BASE]
86
+ context_list = contexts if contexts else [Context.BASE]
152
87
 
153
- # Parse strategy
154
88
  try:
155
89
  load_strategy = LoadStrategy(strategy)
156
90
  except ValueError:
@@ -159,67 +93,27 @@ def load(
159
93
  )
160
94
  raise typer.Exit(1)
161
95
 
162
- # Resolve what will be loaded
163
96
  ordered = registry.resolve_context_dependencies(*context_list)
164
97
 
165
98
  if not ordered:
166
- typer.echo("No fixtures to load for the specified context(s).")
99
+ print("No fixtures to load for the specified context(s).")
167
100
  return
168
101
 
169
- typer.echo(f"\nFixtures to load ({load_strategy.value} strategy):")
102
+ print(f"\nFixtures to load ({load_strategy.value} strategy):")
170
103
  for name in ordered:
171
104
  fixture = registry.get(name)
172
105
  instances = list(fixture.func())
173
106
  model_name = type(instances[0]).__name__ if instances else "?"
174
- typer.echo(f" - {name}: {len(instances)} {model_name}(s)")
107
+ print(f" - {name}: {len(instances)} {model_name}(s)")
175
108
 
176
109
  if dry_run:
177
- typer.echo("\n[Dry run - no changes made]")
110
+ print("\n[Dry run - no changes made]")
178
111
  return
179
112
 
180
- typer.echo("\nLoading...")
181
-
182
- async def do_load():
183
- async with get_db_context() as session:
184
- result = await load_fixtures_by_context(
185
- session, registry, *context_list, strategy=load_strategy
186
- )
187
- return result
188
-
189
- result = asyncio.run(do_load())
113
+ async with get_db_context() as session:
114
+ result = await load_fixtures_by_context(
115
+ session, registry, *context_list, strategy=load_strategy
116
+ )
190
117
 
191
118
  total = sum(len(items) for items in result.values())
192
- typer.echo(f"\nLoaded {total} record(s) successfully.")
193
-
194
-
195
- @app.command("show")
196
- def show_fixture(
197
- ctx: typer.Context,
198
- name: Annotated[str, typer.Argument(help="Fixture name to show.")],
199
- ) -> None:
200
- """Show details of a specific fixture."""
201
- registry = _get_registry(ctx)
202
-
203
- try:
204
- fixture = registry.get(name)
205
- except KeyError:
206
- typer.echo(f"Fixture '{name}' not found.", err=True)
207
- raise typer.Exit(1)
208
-
209
- typer.echo(f"\nFixture: {fixture.name}")
210
- typer.echo(f"Contexts: {', '.join(fixture.contexts)}")
211
- typer.echo(
212
- f"Dependencies: {', '.join(fixture.depends_on) if fixture.depends_on else 'None'}"
213
- )
214
-
215
- # Show instances
216
- instances = list(fixture.func())
217
- if instances:
218
- model_name = type(instances[0]).__name__
219
- typer.echo(f"\nInstances ({len(instances)} {model_name}):")
220
- for instance in instances[:10]: # Limit to 10
221
- typer.echo(f" - {instance!r}")
222
- if len(instances) > 10:
223
- typer.echo(f" ... and {len(instances) - 10} more")
224
- else:
225
- typer.echo("\nNo instances (empty fixture)")
119
+ print(f"\nLoaded {total} record(s) successfully.")
@@ -0,0 +1,92 @@
1
+ """CLI configuration."""
2
+
3
+ import importlib
4
+ import sys
5
+ import tomllib
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ import typer
10
+
11
+
12
+ @dataclass
13
+ class CliConfig:
14
+ """CLI configuration loaded from pyproject.toml."""
15
+
16
+ fixtures: str | None = None
17
+ db_context: str | None = None
18
+
19
+ def get_fixtures_registry(self):
20
+ """Import and return the fixtures registry."""
21
+ from ..fixtures import FixtureRegistry
22
+
23
+ if not self.fixtures:
24
+ raise typer.BadParameter(
25
+ "No fixtures registry configured. "
26
+ "Add 'fixtures' to [tool.fastapi-toolsets] in pyproject.toml."
27
+ )
28
+
29
+ registry = _import_from_string(self.fixtures)
30
+
31
+ if not isinstance(registry, FixtureRegistry):
32
+ raise typer.BadParameter(
33
+ f"'fixtures' must be a FixtureRegistry instance, got {type(registry).__name__}"
34
+ )
35
+
36
+ return registry
37
+
38
+ def get_db_context(self):
39
+ """Import and return the db_context function."""
40
+ if not self.db_context:
41
+ raise typer.BadParameter(
42
+ "No db_context configured. "
43
+ "Add 'db_context' to [tool.fastapi-toolsets] in pyproject.toml."
44
+ )
45
+ return _import_from_string(self.db_context)
46
+
47
+
48
+ def _import_from_string(import_path: str):
49
+ """Import an object from a string path like 'module.submodule:attribute'."""
50
+ if ":" not in import_path:
51
+ raise typer.BadParameter(
52
+ f"Invalid import path '{import_path}'. Expected format: 'module:attribute'"
53
+ )
54
+
55
+ module_path, attr_name = import_path.rsplit(":", 1)
56
+
57
+ # Add cwd to sys.path for local imports
58
+ cwd = str(Path.cwd())
59
+ if cwd not in sys.path:
60
+ sys.path.insert(0, cwd)
61
+
62
+ try:
63
+ module = importlib.import_module(module_path)
64
+ except ImportError as e:
65
+ raise typer.BadParameter(f"Cannot import module '{module_path}': {e}")
66
+
67
+ if not hasattr(module, attr_name):
68
+ raise typer.BadParameter(
69
+ f"Module '{module_path}' has no attribute '{attr_name}'"
70
+ )
71
+
72
+ return getattr(module, attr_name)
73
+
74
+
75
+ def load_config() -> CliConfig:
76
+ """Load CLI configuration from pyproject.toml."""
77
+ pyproject_path = Path.cwd() / "pyproject.toml"
78
+
79
+ if not pyproject_path.exists():
80
+ return CliConfig()
81
+
82
+ try:
83
+ with open(pyproject_path, "rb") as f:
84
+ data = tomllib.load(f)
85
+
86
+ tool_config = data.get("tool", {}).get("fastapi-toolsets", {})
87
+ return CliConfig(
88
+ fixtures=tool_config.get("fixtures"),
89
+ db_context=tool_config.get("db_context"),
90
+ )
91
+ except Exception:
92
+ return CliConfig()
@@ -0,0 +1,27 @@
1
+ """CLI utility functions."""
2
+
3
+ import asyncio
4
+ import functools
5
+ from collections.abc import Callable, Coroutine
6
+ from typing import Any, ParamSpec, TypeVar
7
+
8
+ P = ParamSpec("P")
9
+ T = TypeVar("T")
10
+
11
+
12
+ def async_command(func: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, T]:
13
+ """Decorator to run an async function as a sync CLI command.
14
+
15
+ Example:
16
+ @fixture_cli.command("load")
17
+ @async_command
18
+ async def load(ctx: typer.Context) -> None:
19
+ async with get_db_context() as session:
20
+ await load_fixtures(session, registry)
21
+ """
22
+
23
+ @functools.wraps(func)
24
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
25
+ return asyncio.run(func(*args, **kwargs))
26
+
27
+ return wrapper
@@ -4,7 +4,6 @@ from ..exceptions import NoSearchableFieldsError
4
4
  from .factory import CrudFactory
5
5
  from .search import (
6
6
  SearchConfig,
7
- SearchFieldType,
8
7
  get_searchable_fields,
9
8
  )
10
9
 
@@ -13,5 +12,4 @@ __all__ = [
13
12
  "get_searchable_fields",
14
13
  "NoSearchableFieldsError",
15
14
  "SearchConfig",
16
- "SearchFieldType",
17
15
  ]
@@ -17,6 +17,7 @@ from ..exceptions import NotFoundError
17
17
  from .search import SearchConfig, SearchFieldType, build_search_filters
18
18
 
19
19
  ModelType = TypeVar("ModelType", bound=DeclarativeBase)
20
+ JoinType = list[tuple[type[DeclarativeBase], Any]]
20
21
 
21
22
 
22
23
  class AsyncCrud(Generic[ModelType]):
@@ -55,6 +56,8 @@ class AsyncCrud(Generic[ModelType]):
55
56
  session: AsyncSession,
56
57
  filters: list[Any],
57
58
  *,
59
+ joins: JoinType | None = None,
60
+ outer_join: bool = False,
58
61
  with_for_update: bool = False,
59
62
  load_options: list[Any] | None = None,
60
63
  ) -> ModelType:
@@ -63,6 +66,8 @@ class AsyncCrud(Generic[ModelType]):
63
66
  Args:
64
67
  session: DB async session
65
68
  filters: List of SQLAlchemy filter conditions
69
+ joins: List of (model, condition) tuples for joining related tables
70
+ outer_join: Use LEFT OUTER JOIN instead of INNER JOIN
66
71
  with_for_update: Lock the row for update
67
72
  load_options: SQLAlchemy loader options (e.g., selectinload)
68
73
 
@@ -73,7 +78,15 @@ class AsyncCrud(Generic[ModelType]):
73
78
  NotFoundError: If no record found
74
79
  MultipleResultsFound: If more than one record found
75
80
  """
76
- q = select(cls.model).where(and_(*filters))
81
+ q = select(cls.model)
82
+ if joins:
83
+ for model, condition in joins:
84
+ q = (
85
+ q.outerjoin(model, condition)
86
+ if outer_join
87
+ else q.join(model, condition)
88
+ )
89
+ q = q.where(and_(*filters))
77
90
  if load_options:
78
91
  q = q.options(*load_options)
79
92
  if with_for_update:
@@ -90,6 +103,8 @@ class AsyncCrud(Generic[ModelType]):
90
103
  session: AsyncSession,
91
104
  filters: list[Any] | None = None,
92
105
  *,
106
+ joins: JoinType | None = None,
107
+ outer_join: bool = False,
93
108
  load_options: list[Any] | None = None,
94
109
  ) -> ModelType | None:
95
110
  """Get the first matching record, or None.
@@ -97,12 +112,21 @@ class AsyncCrud(Generic[ModelType]):
97
112
  Args:
98
113
  session: DB async session
99
114
  filters: List of SQLAlchemy filter conditions
115
+ joins: List of (model, condition) tuples for joining related tables
116
+ outer_join: Use LEFT OUTER JOIN instead of INNER JOIN
100
117
  load_options: SQLAlchemy loader options
101
118
 
102
119
  Returns:
103
120
  Model instance or None
104
121
  """
105
122
  q = select(cls.model)
123
+ if joins:
124
+ for model, condition in joins:
125
+ q = (
126
+ q.outerjoin(model, condition)
127
+ if outer_join
128
+ else q.join(model, condition)
129
+ )
106
130
  if filters:
107
131
  q = q.where(and_(*filters))
108
132
  if load_options:
@@ -116,6 +140,8 @@ class AsyncCrud(Generic[ModelType]):
116
140
  session: AsyncSession,
117
141
  *,
118
142
  filters: list[Any] | None = None,
143
+ joins: JoinType | None = None,
144
+ outer_join: bool = False,
119
145
  load_options: list[Any] | None = None,
120
146
  order_by: Any | None = None,
121
147
  limit: int | None = None,
@@ -126,6 +152,8 @@ class AsyncCrud(Generic[ModelType]):
126
152
  Args:
127
153
  session: DB async session
128
154
  filters: List of SQLAlchemy filter conditions
155
+ joins: List of (model, condition) tuples for joining related tables
156
+ outer_join: Use LEFT OUTER JOIN instead of INNER JOIN
129
157
  load_options: SQLAlchemy loader options
130
158
  order_by: Column or list of columns to order by
131
159
  limit: Max number of rows to return
@@ -135,6 +163,13 @@ class AsyncCrud(Generic[ModelType]):
135
163
  List of model instances
136
164
  """
137
165
  q = select(cls.model)
166
+ if joins:
167
+ for model, condition in joins:
168
+ q = (
169
+ q.outerjoin(model, condition)
170
+ if outer_join
171
+ else q.join(model, condition)
172
+ )
138
173
  if filters:
139
174
  q = q.where(and_(*filters))
140
175
  if load_options:
@@ -254,17 +289,29 @@ class AsyncCrud(Generic[ModelType]):
254
289
  cls: type[Self],
255
290
  session: AsyncSession,
256
291
  filters: list[Any] | None = None,
292
+ *,
293
+ joins: JoinType | None = None,
294
+ outer_join: bool = False,
257
295
  ) -> int:
258
296
  """Count records matching the filters.
259
297
 
260
298
  Args:
261
299
  session: DB async session
262
300
  filters: List of SQLAlchemy filter conditions
301
+ joins: List of (model, condition) tuples for joining related tables
302
+ outer_join: Use LEFT OUTER JOIN instead of INNER JOIN
263
303
 
264
304
  Returns:
265
305
  Number of matching records
266
306
  """
267
307
  q = select(func.count()).select_from(cls.model)
308
+ if joins:
309
+ for model, condition in joins:
310
+ q = (
311
+ q.outerjoin(model, condition)
312
+ if outer_join
313
+ else q.join(model, condition)
314
+ )
268
315
  if filters:
269
316
  q = q.where(and_(*filters))
270
317
  result = await session.execute(q)
@@ -275,17 +322,30 @@ class AsyncCrud(Generic[ModelType]):
275
322
  cls: type[Self],
276
323
  session: AsyncSession,
277
324
  filters: list[Any],
325
+ *,
326
+ joins: JoinType | None = None,
327
+ outer_join: bool = False,
278
328
  ) -> bool:
279
329
  """Check if a record exists.
280
330
 
281
331
  Args:
282
332
  session: DB async session
283
333
  filters: List of SQLAlchemy filter conditions
334
+ joins: List of (model, condition) tuples for joining related tables
335
+ outer_join: Use LEFT OUTER JOIN instead of INNER JOIN
284
336
 
285
337
  Returns:
286
338
  True if at least one record matches
287
339
  """
288
- q = select(cls.model).where(and_(*filters)).exists().select()
340
+ q = select(cls.model)
341
+ if joins:
342
+ for model, condition in joins:
343
+ q = (
344
+ q.outerjoin(model, condition)
345
+ if outer_join
346
+ else q.join(model, condition)
347
+ )
348
+ q = q.where(and_(*filters)).exists().select()
289
349
  result = await session.execute(q)
290
350
  return bool(result.scalar())
291
351
 
@@ -295,6 +355,8 @@ class AsyncCrud(Generic[ModelType]):
295
355
  session: AsyncSession,
296
356
  *,
297
357
  filters: list[Any] | None = None,
358
+ joins: JoinType | None = None,
359
+ outer_join: bool = False,
298
360
  load_options: list[Any] | None = None,
299
361
  order_by: Any | None = None,
300
362
  page: int = 1,
@@ -307,6 +369,8 @@ class AsyncCrud(Generic[ModelType]):
307
369
  Args:
308
370
  session: DB async session
309
371
  filters: List of SQLAlchemy filter conditions
372
+ joins: List of (model, condition) tuples for joining related tables
373
+ outer_join: Use LEFT OUTER JOIN instead of INNER JOIN
310
374
  load_options: SQLAlchemy loader options
311
375
  order_by: Column or list of columns to order by
312
376
  page: Page number (1-indexed)
@@ -319,7 +383,7 @@ class AsyncCrud(Generic[ModelType]):
319
383
  """
320
384
  filters = list(filters) if filters else []
321
385
  offset = (page - 1) * items_per_page
322
- joins: list[Any] = []
386
+ search_joins: list[Any] = []
323
387
 
324
388
  # Build search filters
325
389
  if search:
@@ -330,11 +394,21 @@ class AsyncCrud(Generic[ModelType]):
330
394
  default_fields=cls.searchable_fields,
331
395
  )
332
396
  filters.extend(search_filters)
333
- joins.extend(search_joins)
334
397
 
335
398
  # Build query with joins
336
399
  q = select(cls.model)
337
- for join_rel in joins:
400
+
401
+ # Apply explicit joins
402
+ if joins:
403
+ for model, condition in joins:
404
+ q = (
405
+ q.outerjoin(model, condition)
406
+ if outer_join
407
+ else q.join(model, condition)
408
+ )
409
+
410
+ # Apply search joins (always outer joins for search)
411
+ for join_rel in search_joins:
338
412
  q = q.outerjoin(join_rel)
339
413
 
340
414
  if filters:
@@ -352,8 +426,20 @@ class AsyncCrud(Generic[ModelType]):
352
426
  pk_col = cls.model.__mapper__.primary_key[0]
353
427
  count_q = select(func.count(func.distinct(getattr(cls.model, pk_col.name))))
354
428
  count_q = count_q.select_from(cls.model)
355
- for join_rel in joins:
429
+
430
+ # Apply explicit joins to count query
431
+ if joins:
432
+ for model, condition in joins:
433
+ count_q = (
434
+ count_q.outerjoin(model, condition)
435
+ if outer_join
436
+ else count_q.join(model, condition)
437
+ )
438
+
439
+ # Apply search joins to count query
440
+ for join_rel in search_joins:
356
441
  count_q = count_q.outerjoin(join_rel)
442
+
357
443
  if filters:
358
444
  count_q = count_q.where(and_(*filters))
359
445
 
@@ -404,6 +490,20 @@ def CrudFactory(
404
490
 
405
491
  # With search
406
492
  result = await UserCrud.paginate(session, search="john")
493
+
494
+ # With joins (inner join by default):
495
+ users = await UserCrud.get_multi(
496
+ session,
497
+ joins=[(Post, Post.user_id == User.id)],
498
+ filters=[Post.published == True],
499
+ )
500
+
501
+ # With outer join:
502
+ users = await UserCrud.get_multi(
503
+ session,
504
+ joins=[(Post, Post.user_id == User.id)],
505
+ outer_join=True,
506
+ )
407
507
  """
408
508
  cls = type(
409
509
  f"Async{model.__name__}Crud",
@@ -129,11 +129,12 @@ def build_search_filters(
129
129
  else:
130
130
  column = field
131
131
 
132
- # Build the filter
132
+ # Build the filter (cast to String for non-text columns)
133
+ column_as_string = column.cast(String)
133
134
  if config.case_sensitive:
134
- filters.append(column.like(f"%{query}%"))
135
+ filters.append(column_as_string.like(f"%{query}%"))
135
136
  else:
136
- filters.append(column.ilike(f"%{query}%"))
137
+ filters.append(column_as_string.ilike(f"%{query}%"))
137
138
 
138
139
  if not filters:
139
140
  return [], []
@@ -1,4 +1,5 @@
1
1
  from .exceptions import (
2
+ ApiError,
2
3
  ApiException,
3
4
  ConflictError,
4
5
  ForbiddenError,
@@ -10,11 +11,12 @@ from .exceptions import (
10
11
  from .handler import init_exceptions_handlers
11
12
 
12
13
  __all__ = [
13
- "init_exceptions_handlers",
14
- "generate_error_responses",
14
+ "ApiError",
15
15
  "ApiException",
16
16
  "ConflictError",
17
17
  "ForbiddenError",
18
+ "generate_error_responses",
19
+ "init_exceptions_handlers",
18
20
  "NoSearchableFieldsError",
19
21
  "NotFoundError",
20
22
  "UnauthorizedError",
@@ -50,8 +50,16 @@ class FixtureRegistry:
50
50
  ]
51
51
  """
52
52
 
53
- def __init__(self) -> None:
53
+ def __init__(
54
+ self,
55
+ contexts: list[str | Context] | None = None,
56
+ ) -> None:
54
57
  self._fixtures: dict[str, Fixture] = {}
58
+ self._default_contexts: list[str] | None = (
59
+ [c.value if isinstance(c, Context) else c for c in contexts]
60
+ if contexts
61
+ else None
62
+ )
55
63
 
56
64
  def register(
57
65
  self,
@@ -85,10 +93,14 @@ class FixtureRegistry:
85
93
  fn: Callable[[], Sequence[DeclarativeBase]],
86
94
  ) -> Callable[[], Sequence[DeclarativeBase]]:
87
95
  fixture_name = name or cast(Any, fn).__name__
88
- fixture_contexts = [
89
- c.value if isinstance(c, Context) else c
90
- for c in (contexts or [Context.BASE])
91
- ]
96
+ if contexts is not None:
97
+ fixture_contexts = [
98
+ c.value if isinstance(c, Context) else c for c in contexts
99
+ ]
100
+ elif self._default_contexts is not None:
101
+ fixture_contexts = self._default_contexts
102
+ else:
103
+ fixture_contexts = [Context.BASE.value]
92
104
 
93
105
  self._fixtures[fixture_name] = Fixture(
94
106
  name=fixture_name,
@@ -102,6 +114,32 @@ class FixtureRegistry:
102
114
  return decorator(func)
103
115
  return decorator
104
116
 
117
+ def include_registry(self, registry: "FixtureRegistry") -> None:
118
+ """Include another `FixtureRegistry` in the same current `FixtureRegistry`.
119
+
120
+ Args:
121
+ registry: The `FixtureRegistry` to include
122
+
123
+ Raises:
124
+ ValueError: If a fixture name already exists in the current registry
125
+
126
+ Example:
127
+ registry = FixtureRegistry()
128
+ dev_registry = FixtureRegistry()
129
+
130
+ @dev_registry.register
131
+ def dev_data():
132
+ return [...]
133
+
134
+ registry.include_registry(registry=dev_registry)
135
+ """
136
+ for name, fixture in registry._fixtures.items():
137
+ if name in self._fixtures:
138
+ raise ValueError(
139
+ f"Fixture '{name}' already exists in the current registry"
140
+ )
141
+ self._fixtures[name] = fixture
142
+
105
143
  def get(self, name: str) -> Fixture:
106
144
  """Get a fixture by name."""
107
145
  if name not in self._fixtures:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-toolsets
3
- Version: 0.4.0
3
+ Version: 0.5.0
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
@@ -0,0 +1,28 @@
1
+ fastapi_toolsets/__init__.py,sha256=OLAcxL7oaBcCFw2nD7oCp9JXiI9SCjLvwEkuG4_5TGc,820
2
+ fastapi_toolsets/cli/__init__.py,sha256=CGLeg0ud6-cKZwvE_qNKw9tXmzVfFCJD-U2eMdQ7InI,123
3
+ fastapi_toolsets/cli/app.py,sha256=cpBDDYPngjTv9ui6FaXrTzD4kfKyBBlqMQBT9wWdjuk,483
4
+ fastapi_toolsets/cli/commands/__init__.py,sha256=BogehmsY6olwLdfIBzviuppXP1LLl9znnxtmji3eLwI,29
5
+ fastapi_toolsets/cli/commands/fixtures.py,sha256=yBOMlrMRrMk0y-WD6YYXzIrMKGtM0egNVC-vG5GA9Bs,3348
6
+ fastapi_toolsets/cli/config.py,sha256=tRpvi2NnSEwPlDjyacMCHIapH61ONCp-24vpCxm4ElM,2678
7
+ fastapi_toolsets/cli/utils.py,sha256=KsW-FW9tBCAjUd_qludXa1jy8ch8TpkT-_ltcO0DWVs,733
8
+ fastapi_toolsets/crud/__init__.py,sha256=XSl_2k5qfjr0ZFtS7JgNtiNJmZjYL8bDh9BE5qrDyw0,325
9
+ fastapi_toolsets/crud/factory.py,sha256=wBtmFNC7cqzeZfyJLKW-lj3Dd0zo15bZfeTNA1DvqNE,16663
10
+ fastapi_toolsets/crud/search.py,sha256=OAhbkuDan-elAWyAUz0msieZxYkE76fpJYwaA1BRTQ8,4635
11
+ fastapi_toolsets/db.py,sha256=YUj5CrxCnREg7AqpJLNrLR2RDIOCS7stQCNOSS3cRho,5619
12
+ fastapi_toolsets/exceptions/__init__.py,sha256=vNP19B7aaVKnBpRXG_fdCZ0P_GlPDiZDYydPLxfdhD0,481
13
+ fastapi_toolsets/exceptions/exceptions.py,sha256=hu8_lvE9KmnYID9YgJqlzZMkCD0kASPrGAmN1hUe2bY,5086
14
+ fastapi_toolsets/exceptions/handler.py,sha256=IXfKiIr_LPo-11PRpOIrNRAXBkeQ5TdLcu3Gy-r6ChU,5916
15
+ fastapi_toolsets/fixtures/__init__.py,sha256=i5N6dt4LLVxhC0fBNhDTokuqUf43oXJChBkVMQ94hLA,328
16
+ fastapi_toolsets/fixtures/enum.py,sha256=02T4CrkH3-A3mPxpHaLzBQD4yzqExwjycaDBJr1ameA,715
17
+ fastapi_toolsets/fixtures/registry.py,sha256=O5BlaPh-BOZQmq-Yi0GWD-pYmAtdeIxUc-QJxoPGu5M,6587
18
+ fastapi_toolsets/fixtures/utils.py,sha256=DlsGBVl0zyxtKX5E3CEcV7rhzBYng6KD5hELD7bK0wo,4711
19
+ fastapi_toolsets/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
+ fastapi_toolsets/pytest/__init__.py,sha256=0GnnFxWNfpaShBBYsfRGIgSygwC1eo8TydmXCA3tmoM,188
21
+ fastapi_toolsets/pytest/plugin.py,sha256=lbEiumS2zi7jARY6eYBUPAlfKCplbLFrZXcmp0-RkcA,6892
22
+ fastapi_toolsets/pytest/utils.py,sha256=VqkxtbpEU8w7-0xfcZG0m8Tpn3LtdnvAJMyqWS7WtIw,3447
23
+ fastapi_toolsets/schemas.py,sha256=LBzrq4s5VWYeQqlUfOEvWDtpFdO8scgY0LRypk9KUAE,2639
24
+ fastapi_toolsets-0.5.0.dist-info/licenses/LICENSE,sha256=V2jCjI-VPB-veGY2Ktb0sU4vT_TldRciZ9lCE98bMoE,1063
25
+ fastapi_toolsets-0.5.0.dist-info/WHEEL,sha256=fAguSjoiATBe7TNBkJwOjyL1Tt4wwiaQGtNtjRPNMQA,80
26
+ fastapi_toolsets-0.5.0.dist-info/entry_points.txt,sha256=I-wL3xlhglq38WG6WDitgmE4Hstk9tmSUf4eIjz9ckk,54
27
+ fastapi_toolsets-0.5.0.dist-info/METADATA,sha256=5s38SOEaDNo7Qkn70UQpOtNhqFPwHDUQ72gyoTahlyE,4221
28
+ fastapi_toolsets-0.5.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.9.27
2
+ Generator: uv 0.9.28
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ manager = fastapi_toolsets.cli:cli
3
+
@@ -1,26 +0,0 @@
1
- fastapi_toolsets/__init__.py,sha256=gzOvHPBZ5Kdja8XWMQM-zTxHKPJ7M7ptud7tUsd9bws,820
2
- fastapi_toolsets/cli/__init__.py,sha256=QAcrenphE7D5toid_Kn777Cy1icSOWiEBjSE_oCuU4o,111
3
- fastapi_toolsets/cli/app.py,sha256=G3y3PNN3biHs8GtiiGsrX2X8nXNjkrsUhHLXQo2-MXc,2588
4
- fastapi_toolsets/cli/commands/__init__.py,sha256=BogehmsY6olwLdfIBzviuppXP1LLl9znnxtmji3eLwI,29
5
- fastapi_toolsets/cli/commands/fixtures.py,sha256=qiC2dcrJ_Rb1PRzx6EycTFQQXwa_GQUVoptpx4chf9k,6679
6
- fastapi_toolsets/crud/__init__.py,sha256=LRR57jsFna2IqSj6aTomMzFcaQbx7WVo5lnfhcTSU1g,369
7
- fastapi_toolsets/crud/factory.py,sha256=c5aH2c38Qa44pQ3XKoJTcRox5x4rD4tuEJQ5DKFjSnc,13014
8
- fastapi_toolsets/crud/search.py,sha256=a46SGMg484uv2DruNfEB-Rq6lJRi_K-Nr8tFTWQ2ouY,4530
9
- fastapi_toolsets/db.py,sha256=YUj5CrxCnREg7AqpJLNrLR2RDIOCS7stQCNOSS3cRho,5619
10
- fastapi_toolsets/exceptions/__init__.py,sha256=wlV4pVXuGdOtUvlThRJXmEc8g8Nmwt8MOMG_A-3j9zw,451
11
- fastapi_toolsets/exceptions/exceptions.py,sha256=hu8_lvE9KmnYID9YgJqlzZMkCD0kASPrGAmN1hUe2bY,5086
12
- fastapi_toolsets/exceptions/handler.py,sha256=IXfKiIr_LPo-11PRpOIrNRAXBkeQ5TdLcu3Gy-r6ChU,5916
13
- fastapi_toolsets/fixtures/__init__.py,sha256=i5N6dt4LLVxhC0fBNhDTokuqUf43oXJChBkVMQ94hLA,328
14
- fastapi_toolsets/fixtures/enum.py,sha256=02T4CrkH3-A3mPxpHaLzBQD4yzqExwjycaDBJr1ameA,715
15
- fastapi_toolsets/fixtures/registry.py,sha256=lfoLdC6aZeJQR7_l0g_P5Y6DChbs8_zAgLQsR0Plmfg,5276
16
- fastapi_toolsets/fixtures/utils.py,sha256=DlsGBVl0zyxtKX5E3CEcV7rhzBYng6KD5hELD7bK0wo,4711
17
- fastapi_toolsets/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
- fastapi_toolsets/pytest/__init__.py,sha256=0GnnFxWNfpaShBBYsfRGIgSygwC1eo8TydmXCA3tmoM,188
19
- fastapi_toolsets/pytest/plugin.py,sha256=lbEiumS2zi7jARY6eYBUPAlfKCplbLFrZXcmp0-RkcA,6892
20
- fastapi_toolsets/pytest/utils.py,sha256=VqkxtbpEU8w7-0xfcZG0m8Tpn3LtdnvAJMyqWS7WtIw,3447
21
- fastapi_toolsets/schemas.py,sha256=LBzrq4s5VWYeQqlUfOEvWDtpFdO8scgY0LRypk9KUAE,2639
22
- fastapi_toolsets-0.4.0.dist-info/licenses/LICENSE,sha256=V2jCjI-VPB-veGY2Ktb0sU4vT_TldRciZ9lCE98bMoE,1063
23
- fastapi_toolsets-0.4.0.dist-info/WHEEL,sha256=e_m4S054HL0hyR3CpOk-b7Q7fDX6BuFkgL5OjAExXas,80
24
- fastapi_toolsets-0.4.0.dist-info/entry_points.txt,sha256=pNU38Nn_DXBgYd-nLZCizMvrrdaPhHmkRwouDoBqvzw,63
25
- fastapi_toolsets-0.4.0.dist-info/METADATA,sha256=cRTaYwgiAEVdU_DD4lG7idF6agtcxgSHpFsFIwp-7HY,4221
26
- fastapi_toolsets-0.4.0.dist-info/RECORD,,
@@ -1,3 +0,0 @@
1
- [console_scripts]
2
- fastapi-toolsets = fastapi_toolsets.cli:app
3
-