FastAPI-fastkit 1.4.0__py3-none-any.whl → 1.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.
Files changed (39) hide show
  1. fastapi_fastkit/__init__.py +1 -1
  2. fastapi_fastkit/backend/inspection/context.py +4 -0
  3. fastapi_fastkit/backend/inspection/core.py +5 -1
  4. fastapi_fastkit/backend/inspection/docker.py +150 -15
  5. fastapi_fastkit/backend/inspection/lint.py +44 -30
  6. fastapi_fastkit/backend/inspection/smoke.py +67 -13
  7. fastapi_fastkit/backend/inspection/strategies.py +32 -1
  8. fastapi_fastkit/backend/main.py +35 -40
  9. fastapi_fastkit/backend/route_generators.py +163 -0
  10. fastapi_fastkit/backend/route_wiring.py +117 -0
  11. fastapi_fastkit/backend/transducer.py +101 -2
  12. fastapi_fastkit/cli.py +13 -2
  13. fastapi_fastkit/core/settings.py +3 -3
  14. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/pyproject.toml-tpl +1 -1
  15. fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/requirements.txt-tpl +1 -1
  16. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/api/router.py-tpl +1 -1
  17. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/router.py-tpl +1 -1
  18. fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/tests/conftest.py-tpl +16 -4
  19. fastapi_fastkit/fastapi_project_template/fastapi-mcp/.env-tpl +1 -1
  20. fastapi_fastkit/fastapi_project_template/fastapi-mcp/README.md-tpl +1 -1
  21. fastapi_fastkit/fastapi_project_template/fastapi-mcp/pyproject.toml-tpl +2 -3
  22. fastapi_fastkit/fastapi_project_template/fastapi-mcp/requirements.txt-tpl +5 -9
  23. fastapi_fastkit/fastapi_project_template/fastapi-mcp/setup.cfg-tpl +2 -3
  24. fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/api/routes/auth.py-tpl +9 -9
  25. fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/auth/dependencies.py-tpl +2 -2
  26. fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/core/config.py-tpl +1 -1
  27. fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/.env-tpl +2 -0
  28. fastapi_fastkit/fastapi_project_template/modules/domain/__init__.py-tpl +10 -0
  29. fastapi_fastkit/fastapi_project_template/modules/domain/models.py-tpl +11 -0
  30. fastapi_fastkit/fastapi_project_template/modules/domain/repository.py-tpl +50 -0
  31. fastapi_fastkit/fastapi_project_template/modules/domain/router.py-tpl +66 -0
  32. fastapi_fastkit/fastapi_project_template/modules/domain/schemas.py-tpl +16 -0
  33. fastapi_fastkit/fastapi_project_template/modules/domain/service.py-tpl +47 -0
  34. fastapi_fastkit/fragments/auth/jwt.py.j2 +6 -6
  35. {fastapi_fastkit-1.4.0.dist-info → fastapi_fastkit-1.5.0.dist-info}/METADATA +31 -11
  36. {fastapi_fastkit-1.4.0.dist-info → fastapi_fastkit-1.5.0.dist-info}/RECORD +39 -31
  37. {fastapi_fastkit-1.4.0.dist-info → fastapi_fastkit-1.5.0.dist-info}/WHEEL +1 -1
  38. {fastapi_fastkit-1.4.0.dist-info → fastapi_fastkit-1.5.0.dist-info}/entry_points.txt +0 -0
  39. {fastapi_fastkit-1.4.0.dist-info → fastapi_fastkit-1.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,163 @@
1
+ # --------------------------------------------------------------------------
2
+ # Route generators for fastkit addroute.
3
+ # --------------------------------------------------------------------------
4
+ import keyword
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Dict, Optional, Union
8
+
9
+ from fastapi_fastkit.backend import main as backend
10
+ from fastapi_fastkit.backend.project_builder.preset_layout import PresetLayoutStrategist
11
+ from fastapi_fastkit.backend.route_wiring import register_router, router_alias
12
+ from fastapi_fastkit.backend.transducer import copy_and_convert_template_file
13
+ from fastapi_fastkit.core.exceptions import BackendExceptions
14
+ from fastapi_fastkit.core.settings import settings
15
+ from fastapi_fastkit.utils.main import print_warning, read_fastkit_metadata
16
+
17
+ ROUTE_LAYOUTS = ("classic-layer", "domain")
18
+
19
+
20
+ def resolve_route_layout(project_dir: str, layout: Optional[str] = None) -> str:
21
+ """Resolve the route layout from the option or project metadata."""
22
+ if layout is not None:
23
+ if layout not in ROUTE_LAYOUTS:
24
+ raise BackendExceptions(f"Unknown route layout: {layout!r}")
25
+ return layout
26
+
27
+ metadata = read_fastkit_metadata(project_dir)
28
+ preset = metadata.get("preset")
29
+ if not preset:
30
+ template = metadata.get("template")
31
+ domain_template = PresetLayoutStrategist("domain-starter").base_template
32
+ if template == domain_template:
33
+ return "domain"
34
+ if preset == "domain-starter":
35
+ return "domain"
36
+ if preset and preset not in ("classic-layered", "minimal", "single-module"):
37
+ print_warning(f"Unknown preset {preset!r}; using classic-layer route layout.")
38
+ return "classic-layer"
39
+
40
+
41
+ def _validate_route_name(route_name: str) -> None:
42
+ """Validate the route module name."""
43
+ if not route_name.isidentifier() or keyword.iskeyword(route_name):
44
+ raise BackendExceptions(f"Route name {route_name!r} is not a Python identifier")
45
+
46
+
47
+ class ClassicLayeredRouteGenerator:
48
+ """Generate api/routes, crud, and schemas modules."""
49
+
50
+ def add_new_route(
51
+ self, project_dir: str, route_name: str, project_layout: Dict[str, str]
52
+ ) -> None:
53
+ """Create route files and register the router."""
54
+ src_dir = project_layout["package_dir"]
55
+ _validate_route_name(route_name)
56
+ modules_dir = os.path.join(settings.FASTKIT_TEMPLATE_ROOT, "modules")
57
+ target_dirs = backend._ensure_project_structure(src_dir)
58
+ backend._create_route_files(
59
+ modules_dir, target_dirs, route_name, project_layout["package_module"]
60
+ )
61
+ backend._handle_api_router_file(
62
+ target_dirs, modules_dir, route_name, project_layout["api_router_file"]
63
+ )
64
+ backend._process_init_files(
65
+ modules_dir, target_dirs, ["api/routes", "crud", "schemas"]
66
+ )
67
+ backend._update_main_app(
68
+ src_dir,
69
+ route_name,
70
+ router_module=project_layout["api_router_module"],
71
+ main_py_path=project_layout["main"],
72
+ )
73
+
74
+
75
+ class DomainRouteGenerator:
76
+ """Generate a domain module."""
77
+
78
+ def add_new_route(
79
+ self, project_dir: str, route_name: str, project_layout: Dict[str, str]
80
+ ) -> None:
81
+ """Create domain files and register the router."""
82
+ package = Path(project_layout["package_dir"])
83
+ _validate_route_name(route_name)
84
+ if not package.is_dir():
85
+ raise BackendExceptions(f"Source directory not found at {package}")
86
+
87
+ domain_module = _create_domain_files(package, route_name, project_layout)
88
+ alias = router_alias(
89
+ project_layout["api_router_file"],
90
+ domain_module,
91
+ "router",
92
+ f"{route_name}_router",
93
+ )
94
+ register_router(
95
+ project_layout,
96
+ f"from {domain_module} import router as {alias}",
97
+ f'api_router.include_router({alias}.router, prefix="/{route_name}", tags=["{route_name}"])',
98
+ )
99
+ backend._update_main_app(
100
+ str(package),
101
+ route_name,
102
+ router_module=project_layout["api_router_module"],
103
+ main_py_path=project_layout["main"],
104
+ )
105
+
106
+
107
+ def _create_domain_files(
108
+ package: Path, route_name: str, project_layout: Dict[str, str]
109
+ ) -> str:
110
+ templates = Path(settings.FASTKIT_TEMPLATE_ROOT) / "modules" / "domain"
111
+ filenames = (
112
+ "__init__.py",
113
+ "models.py",
114
+ "schemas.py",
115
+ "repository.py",
116
+ "service.py",
117
+ "router.py",
118
+ )
119
+ for filename in filenames:
120
+ if not (templates / f"{filename}-tpl").is_file():
121
+ raise BackendExceptions(f"Missing domain template: {filename}-tpl")
122
+
123
+ domain_dir = package / "domains" / route_name
124
+ domain_dir.mkdir(parents=True, exist_ok=True)
125
+ domains_init = domain_dir.parent / "__init__.py"
126
+ if not domains_init.exists():
127
+ domains_init.write_text("", encoding="utf-8")
128
+ prefix = project_layout["package_module"]
129
+ domain_module = (
130
+ f"{prefix}.domains.{route_name}" if prefix else f"domains.{route_name}"
131
+ )
132
+ entity_class = (
133
+ "".join(part[:1].upper() + part[1:] for part in route_name.split("_")) or "_"
134
+ )
135
+ if keyword.iskeyword(entity_class):
136
+ entity_class += "Entity"
137
+ replacements = {
138
+ "<new_route>": route_name,
139
+ "<domain_module>": domain_module,
140
+ "<entity_class>": entity_class,
141
+ }
142
+ for filename in filenames:
143
+ target = domain_dir / filename
144
+ if target.exists():
145
+ print_warning(f"File {target} already exists, skipping...")
146
+ continue
147
+ if not copy_and_convert_template_file(
148
+ str(templates / f"{filename}-tpl"), str(target), replacements
149
+ ):
150
+ raise BackendExceptions(f"Failed to create domain file: {target}")
151
+
152
+ return domain_module
153
+
154
+
155
+ def get_route_generator(
156
+ route_layout: str,
157
+ ) -> Union[ClassicLayeredRouteGenerator, DomainRouteGenerator]:
158
+ """Return the generator for the selected layout."""
159
+ if route_layout == "classic-layer":
160
+ return ClassicLayeredRouteGenerator()
161
+ if route_layout == "domain":
162
+ return DomainRouteGenerator()
163
+ raise BackendExceptions(f"Unknown route layout: {route_layout!r}")
@@ -0,0 +1,117 @@
1
+ """Router registration helpers."""
2
+
3
+ import ast
4
+ from pathlib import Path
5
+ from typing import Dict
6
+
7
+ from fastapi_fastkit.utils.main import print_info
8
+
9
+
10
+ def _parse_module(content: str) -> ast.Module:
11
+ try:
12
+ return ast.parse(content)
13
+ except SyntaxError:
14
+ statements: list[ast.stmt] = []
15
+ for line in content.splitlines():
16
+ try:
17
+ statements.extend(ast.parse(line.strip()).body)
18
+ except SyntaxError:
19
+ continue
20
+ return ast.Module(body=statements, type_ignores=[])
21
+
22
+
23
+ def _router_argument(call: ast.Call) -> ast.expr | None:
24
+ if call.args:
25
+ return call.args[0]
26
+ return next((item.value for item in call.keywords if item.arg == "router"), None)
27
+
28
+
29
+ def register_router(
30
+ project_layout: Dict[str, str], import_line: str, statement: str
31
+ ) -> None:
32
+ """Register a router in the API router module."""
33
+
34
+ from fastapi_fastkit.backend.main import insert_import_line, insert_statement_line
35
+
36
+ api_dir = Path(project_layout["api_dir"])
37
+ api_dir.mkdir(parents=True, exist_ok=True)
38
+ init = api_dir / "__init__.py"
39
+ if not init.exists():
40
+ init.write_text("", encoding="utf-8")
41
+ target = Path(project_layout["api_router_file"])
42
+ content = target.read_text(encoding="utf-8") if target.exists() else ""
43
+ if not content.strip():
44
+ content = "from fastapi import APIRouter\n\napi_router = APIRouter()\n"
45
+ registration = ast.parse(statement).body[0]
46
+ existing = None
47
+ if isinstance(registration, ast.Expr) and isinstance(registration.value, ast.Call):
48
+ expected = registration.value
49
+ expected_router = _router_argument(expected)
50
+ existing = next(
51
+ (
52
+ node
53
+ for node in ast.walk(_parse_module(content))
54
+ if isinstance(node, ast.Call)
55
+ and ast.dump(node.func) == ast.dump(expected.func)
56
+ and (argument := _router_argument(node)) is not None
57
+ and expected_router is not None
58
+ and ast.dump(argument) == ast.dump(expected_router)
59
+ ),
60
+ None,
61
+ )
62
+ if existing is not None:
63
+ current_options = {
64
+ item.arg: ast.dump(item.value)
65
+ for item in existing.keywords
66
+ if item.arg != "router"
67
+ }
68
+ expected_options = {
69
+ item.arg: ast.dump(item.value)
70
+ for item in expected.keywords
71
+ if item.arg != "router"
72
+ }
73
+ if (
74
+ current_options != expected_options
75
+ or existing.args[1:] != expected.args[1:]
76
+ ):
77
+ print_info(
78
+ f"Keeping existing router registration in {target} with its current prefix and options."
79
+ )
80
+ updated = insert_import_line(content, import_line)
81
+ if existing is None:
82
+ updated = insert_statement_line(updated, statement)
83
+ if not target.exists() or updated != content:
84
+ target.write_text(updated, encoding="utf-8")
85
+
86
+
87
+ def router_alias(file: str, module: str, name: str, preferred: str) -> str:
88
+ """Reuse the router import alias or choose an unused name."""
89
+
90
+ target = Path(file)
91
+ if not target.exists():
92
+ return preferred
93
+ tree = _parse_module(target.read_text(encoding="utf-8"))
94
+ occupied: set[str] = set()
95
+ for node in tree.body:
96
+ if isinstance(node, ast.ImportFrom):
97
+ for imported in node.names:
98
+ if node.module == module and imported.name == name:
99
+ return imported.asname or name
100
+ occupied.add(imported.asname or imported.name)
101
+ elif isinstance(node, ast.Import):
102
+ occupied.update(
103
+ item.asname or item.name.split(".")[0] for item in node.names
104
+ )
105
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
106
+ occupied.add(node.name)
107
+ elif isinstance(node, (ast.Assign, ast.AnnAssign)):
108
+ targets = node.targets if isinstance(node, ast.Assign) else [node.target]
109
+ occupied.update(
110
+ target.id for target in targets if isinstance(target, ast.Name)
111
+ )
112
+ alias = preferred
113
+ counter = 2
114
+ while alias in occupied:
115
+ alias = f"{preferred}_{counter}"
116
+ counter += 1
117
+ return alias
@@ -22,6 +22,104 @@ logger = get_logger(__name__)
22
22
  #: here are the *converted* names (the ``-tpl`` marker already stripped).
23
23
  TEMPLATE_ONLY_FILES = frozenset({"template-config.yml"})
24
24
 
25
+ #: Extensions of files that are known to be text and therefore safe to rewrite
26
+ #: with Unix line endings. Anything outside this list is copied byte for byte,
27
+ #: so an image or an archive shipped inside a template survives untouched.
28
+ TEXT_FILE_EXTENSIONS = frozenset(
29
+ {
30
+ ".bash",
31
+ ".cfg",
32
+ ".css",
33
+ ".env",
34
+ ".html",
35
+ ".ini",
36
+ ".js",
37
+ ".json",
38
+ ".mako",
39
+ ".md",
40
+ ".py",
41
+ ".rst",
42
+ ".sh",
43
+ ".sql",
44
+ ".toml",
45
+ ".ts",
46
+ ".txt",
47
+ ".yaml",
48
+ ".yml",
49
+ }
50
+ )
51
+
52
+ #: Extension-less file names that are text as well.
53
+ TEXT_FILE_NAMES = frozenset(
54
+ {
55
+ ".dockerignore",
56
+ ".env",
57
+ ".gitignore",
58
+ "CHANGELOG",
59
+ "Dockerfile",
60
+ "LICENSE",
61
+ "Makefile",
62
+ "Procfile",
63
+ "README",
64
+ }
65
+ )
66
+
67
+ #: How much of a file is sampled when looking for a NUL byte. A NUL in the
68
+ #: first chunk is the same heuristic Git uses to call a blob binary.
69
+ BINARY_SNIFF_BYTES = 8192
70
+
71
+
72
+ def _looks_like_text_file(file_path: str, file_name: str) -> bool:
73
+ """
74
+ Decide whether a copied file may have its line endings normalised.
75
+
76
+ The check is deliberately conservative: the name has to be on the text
77
+ whitelist *and* the content must carry no NUL byte, so a mislabelled
78
+ binary is left alone rather than corrupted.
79
+
80
+ :param file_path: Path of the file to inspect
81
+ :param file_name: File name used for the extension/name whitelist
82
+ :return: True when the file is safe to rewrite as text
83
+ """
84
+ _, extension = os.path.splitext(file_name)
85
+ if (
86
+ extension.lower() not in TEXT_FILE_EXTENSIONS
87
+ and file_name not in TEXT_FILE_NAMES
88
+ ):
89
+ return False
90
+
91
+ try:
92
+ with open(file_path, "rb") as f:
93
+ return b"\x00" not in f.read(BINARY_SNIFF_BYTES)
94
+ except OSError as e:
95
+ debug_log(f"Could not sniff {file_path} for binary content: {e}", "warning")
96
+ return False
97
+
98
+
99
+ def _normalize_line_endings(file_path: str, file_name: str) -> None:
100
+ """
101
+ Rewrite a copied text file with Unix line endings.
102
+
103
+ Templates checked out on Windows (or with ``core.autocrlf=true``) carry
104
+ CRLF, which makes a generated project's shell scripts unusable inside a
105
+ Linux container: ``env: 'bash\r': No such file or directory``. The rewrite
106
+ happens in place through :func:`fix_script_line_endings`, which truncates
107
+ rather than recreates the file and therefore keeps the executable bit
108
+ ``shutil.copy2`` just carried over.
109
+
110
+ :param file_path: Path of the copied file
111
+ :param file_name: File name used for the text/binary decision
112
+ """
113
+ if not _looks_like_text_file(file_path, file_name):
114
+ return
115
+
116
+ # Imported lazily: ``fastapi_fastkit.backend.inspection`` pulls in the
117
+ # scaffolder, which imports this module, so a top-level import would be
118
+ # circular.
119
+ from fastapi_fastkit.backend.inspection.fsutils import fix_script_line_endings
120
+
121
+ fix_script_line_endings(file_path)
122
+
25
123
 
26
124
  def copy_and_convert_template(
27
125
  template_dir: str, target_dir: str, project_name: str = ""
@@ -130,6 +228,7 @@ def _copy_template_file(
130
228
 
131
229
  try:
132
230
  shutil.copy2(src_file, dst_file)
231
+ _normalize_line_endings(dst_file, dst_file_name)
133
232
  debug_log(f"Copied {src_file} to {dst_file}", "debug")
134
233
  return dst_file
135
234
 
@@ -223,8 +322,8 @@ def _write_target_file(target_file: str, content: str, source_file: str) -> bool
223
322
  target_dir = os.path.dirname(target_file)
224
323
  os.makedirs(target_dir, exist_ok=True)
225
324
 
226
- with open(target_file, "w", encoding="utf-8") as f:
227
- f.write(content)
325
+ with open(target_file, "w", encoding="utf-8", newline="\n") as f:
326
+ f.write(content.replace("\r\n", "\n").replace("\r", "\n"))
228
327
 
229
328
  debug_log(
230
329
  f"Successfully copied template file from {source_file} to {target_file}",
fastapi_fastkit/cli.py CHANGED
@@ -37,6 +37,7 @@ from fastapi_fastkit.backend.project_builder.config_schema import (
37
37
  ConfigSchemaError,
38
38
  normalize_project_config,
39
39
  )
40
+ from fastapi_fastkit.backend.route_generators import resolve_route_layout
40
41
  from fastapi_fastkit.backend.scaffolder import (
41
42
  ProjectScaffolder,
42
43
  ScaffoldOptions,
@@ -829,8 +830,16 @@ def _init_from_config(
829
830
  @fastkit_cli.command()
830
831
  @click.argument("route_name")
831
832
  @click.argument("project_dir", default=".")
833
+ @click.option(
834
+ "--layout",
835
+ type=click.Choice(["classic-layer", "domain"]),
836
+ default=None,
837
+ help="Route layout. Defaults to domain for domain-starter, otherwise classic-layer.",
838
+ )
832
839
  @click.pass_context
833
- def addroute(ctx: Context, route_name: str, project_dir: str) -> None:
840
+ def addroute(
841
+ ctx: Context, route_name: str, project_dir: str, layout: Optional[str]
842
+ ) -> None:
834
843
  """
835
844
  Add a new route to the FastAPI project.
836
845
 
@@ -879,12 +888,14 @@ def addroute(ctx: Context, route_name: str, project_dir: str) -> None:
879
888
  return
880
889
 
881
890
  try:
891
+ route_layout = resolve_route_layout(actual_project_dir, layout)
882
892
  # Show information about the operation
883
893
  table = create_info_table(
884
894
  "Adding New Route",
885
895
  {
886
896
  "Project": project_name,
887
897
  "Route Name": route_name,
898
+ "Layout": route_layout,
888
899
  "Target Directory": actual_project_dir,
889
900
  },
890
901
  )
@@ -903,7 +914,7 @@ def addroute(ctx: Context, route_name: str, project_dir: str) -> None:
903
914
  return
904
915
 
905
916
  # Add the new route
906
- add_new_route(actual_project_dir, route_name)
917
+ add_new_route(actual_project_dir, route_name, layout=route_layout)
907
918
 
908
919
  if project_dir == ".":
909
920
  print_success(
@@ -257,13 +257,13 @@ class FastkitConfig:
257
257
  DatabaseChoice.NONE: [],
258
258
  },
259
259
  FeatureAxis.AUTHENTICATION: {
260
- AuthChoice.JWT: ["python-jose[cryptography]", "passlib[bcrypt]"],
260
+ AuthChoice.JWT: ["pyjwt[crypto]", "pwdlib[argon2]"],
261
261
  # SessionMiddleware (used by the OAuth2 login flow) needs itsdangerous.
262
262
  AuthChoice.OAUTH2: ["authlib", "itsdangerous", "httpx"],
263
263
  AuthChoice.FASTAPI_USERS: [
264
264
  "fastapi-users[sqlalchemy]",
265
- "python-jose[cryptography]",
266
- "passlib[bcrypt]",
265
+ "pyjwt[crypto]",
266
+ "pwdlib[argon2]",
267
267
  ],
268
268
  AuthChoice.SESSION: ["itsdangerous"],
269
269
  AuthChoice.NONE: [],
@@ -19,7 +19,7 @@ dependencies = [
19
19
  "sqlmodel>=0.0.42",
20
20
  "SQLAlchemy>=2.0.52",
21
21
  "alembic>=1.19.1",
22
- "PyJWT>=2.13.0",
22
+ "PyJWT>=2.15.1",
23
23
  "pwdlib[argon2]>=0.3.1",
24
24
  "psycopg[binary]>=3.3.5",
25
25
  ]
@@ -12,7 +12,7 @@ SQLAlchemy==2.0.52
12
12
  alembic==1.19.1
13
13
  psycopg[binary]==3.3.5
14
14
  # Authentication
15
- PyJWT==2.13.0
15
+ PyJWT==2.15.1
16
16
  pwdlib[argon2]==0.3.1
17
17
  # Development & tests
18
18
  pytest==9.1.1
@@ -11,5 +11,5 @@ from src.app.domains.items import router as items_router
11
11
 
12
12
  api_router = APIRouter()
13
13
  api_router.include_router(health.router)
14
- api_router.include_router(items_router.router)
14
+ api_router.include_router(items_router.router, prefix="/items", tags=["items"])
15
15
  # fastkit:routes
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Response, status
7
7
  from src.app.domains.items.schemas import ItemCreate, ItemRead
8
8
  from src.app.domains.items.service import ItemNotFoundError, ItemService
9
9
 
10
- router = APIRouter(prefix="/items", tags=["items"])
10
+ router = APIRouter()
11
11
 
12
12
 
13
13
  def get_item_service() -> ItemService:
@@ -2,19 +2,31 @@
2
2
  # pytest fixtures for <project_name>.
3
3
  # --------------------------------------------------------------------------
4
4
  from collections.abc import Generator
5
+ from importlib import import_module
6
+ from pkgutil import iter_modules
5
7
 
6
8
  import pytest
7
9
  from fastapi.testclient import TestClient
10
+ from src.app import domains
8
11
  from src.app.domains.items.repository import ItemRepository
9
12
  from src.app.main import app
10
13
 
11
14
 
12
- @pytest.fixture(autouse=True)
13
- def reset_items_store() -> Generator[None, None, None]:
14
- """Each test starts with an empty items store."""
15
+ def reset_stores() -> None:
15
16
  ItemRepository().reset()
17
+ for module in iter_modules(domains.__path__, prefix=f"{domains.__name__}."):
18
+ if module.ispkg:
19
+ domain = import_module(module.name)
20
+ reset = getattr(getattr(domain, "service", None), "reset_store", None)
21
+ if reset is not None:
22
+ reset()
23
+
24
+
25
+ @pytest.fixture(autouse=True)
26
+ def reset_domain_stores() -> Generator[None, None, None]:
27
+ reset_stores()
16
28
  yield
17
- ItemRepository().reset()
29
+ reset_stores()
18
30
 
19
31
 
20
32
  @pytest.fixture
@@ -21,7 +21,7 @@ MCP_TITLE=FastAPI MCP Server
21
21
  MCP_DESCRIPTION=FastAPI endpoints exposed as MCP tools
22
22
 
23
23
  # Authentication Settings
24
- SECRET_KEY=changethis
24
+ SECRET_KEY=changethis-please-use-openssl-rand-hex-32
25
25
  ALGORITHM=HS256
26
26
  ACCESS_TOKEN_EXPIRE_MINUTES=30
27
27
 
@@ -75,7 +75,7 @@ MCP_TITLE=FastAPI MCP Server
75
75
  MCP_DESCRIPTION=FastAPI endpoints exposed as MCP tools
76
76
 
77
77
  # Authentication Settings
78
- SECRET_KEY=changethis
78
+ SECRET_KEY=changethis-please-use-openssl-rand-hex-32
79
79
  ALGORITHM=HS256
80
80
  ACCESS_TOKEN_EXPIRE_MINUTES=30
81
81
 
@@ -17,9 +17,8 @@ dependencies = [
17
17
  "python-dotenv>=1.2.3",
18
18
  "fastapi-mcp>=0.3.7,<0.4.0",
19
19
  "mcp>=1.29.1,<2.0.0",
20
- "bcrypt>=4.0.1,<4.1.0",
21
- "passlib>=1.7.4",
22
- "python-jose>=3.5.0",
20
+ "pwdlib[argon2]>=0.3.1",
21
+ "PyJWT[crypto]>=2.15.1",
23
22
  "python-multipart>=0.0.32",
24
23
  ]
25
24
 
@@ -1,9 +1,10 @@
1
1
  annotated-doc==0.0.5
2
2
  annotated-types==0.8.0
3
3
  anyio==4.15.0
4
+ argon2-cffi==25.1.0
5
+ argon2-cffi-bindings==26.1.0
4
6
  ast-serialize==0.9.0
5
7
  attrs==26.1.0
6
- bcrypt==4.0.1
7
8
  black==26.5.1
8
9
  certifi==2026.7.22
9
10
  cffi==2.1.1
@@ -11,7 +12,6 @@ charset-normalizer==3.5.1
11
12
  click==8.5.0
12
13
  coverage==7.16.0
13
14
  cryptography==50.0.1
14
- ecdsa==0.19.2
15
15
  fastapi==0.141.1
16
16
  fastapi-mcp==0.3.7
17
17
  h11==0.16.0
@@ -31,21 +31,19 @@ mdurl==0.1.2
31
31
  mypy==2.3.1
32
32
  mypy-extensions==1.1.0
33
33
  packaging==26.3
34
- passlib==1.7.4
35
34
  pathspec==1.1.1
36
35
  platformdirs==4.11.7
37
36
  pluggy==1.6.0
38
- pyasn1==0.6.4
37
+ pwdlib==0.3.1
39
38
  pycparser==3.0
40
39
  pydantic==2.13.5
41
40
  pydantic-core==2.46.5
42
41
  pydantic-settings==2.15.0
43
42
  pygments==2.21.0
44
- pyjwt==2.13.0
43
+ PyJWT==2.15.1
45
44
  pytest==9.1.1
46
45
  pytest-cov==7.1.0
47
46
  python-dotenv==1.2.3
48
- python-jose==3.5.0
49
47
  python-multipart==0.0.32
50
48
  pytokens==0.4.1
51
49
  PyYAML==6.0.3
@@ -53,9 +51,7 @@ referencing==0.37.0
53
51
  requests==2.34.2
54
52
  rich==15.0.0
55
53
  rpds-py==2026.6.3
56
- rsa==4.9.1
57
54
  shellingham==1.5.4
58
- six==1.17.0
59
55
  SQLAlchemy==2.0.52
60
56
  sse-starlette==3.4.10
61
57
  starlette==1.6.0
@@ -63,7 +59,7 @@ tomli==2.4.1
63
59
  typer==0.27.2
64
60
  typing-extensions==4.16.0
65
61
  typing-inspection==0.4.4
66
- urllib3==2.7.0
62
+ urllib3==2.8.0
67
63
  uvicorn==0.52.4
68
64
  uvloop==0.22.1
69
65
  watchfiles==1.2.0
@@ -29,9 +29,8 @@ install_requires =
29
29
  pydantic>=2.10.6
30
30
  pydantic-settings>=2.7.1
31
31
  python-dotenv>=1.0.1
32
- python-jose>=3.3.0
33
- passlib>=1.7.4
34
- bcrypt>=4.1.2
32
+ PyJWT[crypto]>=2.15.1
33
+ pwdlib[argon2]>=0.3.1
35
34
  python-multipart>=0.0.17
36
35
 
37
36
  [options.extras_require]