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.
- fastapi_fastkit/__init__.py +1 -1
- fastapi_fastkit/backend/inspection/context.py +4 -0
- fastapi_fastkit/backend/inspection/core.py +5 -1
- fastapi_fastkit/backend/inspection/docker.py +150 -15
- fastapi_fastkit/backend/inspection/lint.py +44 -30
- fastapi_fastkit/backend/inspection/smoke.py +67 -13
- fastapi_fastkit/backend/inspection/strategies.py +32 -1
- fastapi_fastkit/backend/main.py +35 -40
- fastapi_fastkit/backend/route_generators.py +163 -0
- fastapi_fastkit/backend/route_wiring.py +117 -0
- fastapi_fastkit/backend/transducer.py +101 -2
- fastapi_fastkit/cli.py +13 -2
- fastapi_fastkit/core/settings.py +3 -3
- fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/pyproject.toml-tpl +1 -1
- fastapi_fastkit/fastapi_project_template/fastapi-auth-jwt/requirements.txt-tpl +1 -1
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/api/router.py-tpl +1 -1
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/router.py-tpl +1 -1
- fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/tests/conftest.py-tpl +16 -4
- fastapi_fastkit/fastapi_project_template/fastapi-mcp/.env-tpl +1 -1
- fastapi_fastkit/fastapi_project_template/fastapi-mcp/README.md-tpl +1 -1
- fastapi_fastkit/fastapi_project_template/fastapi-mcp/pyproject.toml-tpl +2 -3
- fastapi_fastkit/fastapi_project_template/fastapi-mcp/requirements.txt-tpl +5 -9
- fastapi_fastkit/fastapi_project_template/fastapi-mcp/setup.cfg-tpl +2 -3
- fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/api/routes/auth.py-tpl +9 -9
- fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/auth/dependencies.py-tpl +2 -2
- fastapi_fastkit/fastapi_project_template/fastapi-mcp/src/core/config.py-tpl +1 -1
- fastapi_fastkit/fastapi_project_template/fastapi-psql-orm/.env-tpl +2 -0
- fastapi_fastkit/fastapi_project_template/modules/domain/__init__.py-tpl +10 -0
- fastapi_fastkit/fastapi_project_template/modules/domain/models.py-tpl +11 -0
- fastapi_fastkit/fastapi_project_template/modules/domain/repository.py-tpl +50 -0
- fastapi_fastkit/fastapi_project_template/modules/domain/router.py-tpl +66 -0
- fastapi_fastkit/fastapi_project_template/modules/domain/schemas.py-tpl +16 -0
- fastapi_fastkit/fastapi_project_template/modules/domain/service.py-tpl +47 -0
- fastapi_fastkit/fragments/auth/jwt.py.j2 +6 -6
- {fastapi_fastkit-1.4.0.dist-info → fastapi_fastkit-1.5.0.dist-info}/METADATA +31 -11
- {fastapi_fastkit-1.4.0.dist-info → fastapi_fastkit-1.5.0.dist-info}/RECORD +39 -31
- {fastapi_fastkit-1.4.0.dist-info → fastapi_fastkit-1.5.0.dist-info}/WHEEL +1 -1
- {fastapi_fastkit-1.4.0.dist-info → fastapi_fastkit-1.5.0.dist-info}/entry_points.txt +0 -0
- {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(
|
|
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(
|
fastapi_fastkit/core/settings.py
CHANGED
|
@@ -257,13 +257,13 @@ class FastkitConfig:
|
|
|
257
257
|
DatabaseChoice.NONE: [],
|
|
258
258
|
},
|
|
259
259
|
FeatureAxis.AUTHENTICATION: {
|
|
260
|
-
AuthChoice.JWT: ["
|
|
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
|
-
"
|
|
266
|
-
"
|
|
265
|
+
"pyjwt[crypto]",
|
|
266
|
+
"pwdlib[argon2]",
|
|
267
267
|
],
|
|
268
268
|
AuthChoice.SESSION: ["itsdangerous"],
|
|
269
269
|
AuthChoice.NONE: [],
|
|
@@ -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
|
fastapi_fastkit/fastapi_project_template/fastapi-domain-starter/src/app/domains/items/router.py-tpl
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
29
|
+
reset_stores()
|
|
18
30
|
|
|
19
31
|
|
|
20
32
|
@pytest.fixture
|
|
@@ -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
|
-
"
|
|
21
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
33
|
-
|
|
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]
|