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
@@ -1 +1 @@
1
- __version__ = 'v1.4.0'
1
+ __version__ = 'v1.5.0'
@@ -43,6 +43,10 @@ class InspectionContext:
43
43
  warnings: List[str] = field(default_factory=list)
44
44
  #: Populated by a test strategy so later checks can reuse the environment.
45
45
  venv_path: Optional[str] = None
46
+ #: Populated when a test strategy already exercised the app's HTTP surface
47
+ #: (the Docker strategy probes the running container), so the Smoke Test
48
+ #: step reuses that verdict instead of booting a second server.
49
+ smoke_result: Optional[bool] = None
46
50
 
47
51
  def add_error(self, message: str) -> None:
48
52
  """Record a fatal finding."""
@@ -252,8 +252,12 @@ class TemplateInspector:
252
252
  ("Configuration Consistency", self._check_configuration_consistency),
253
253
  ("FastAPI Implementation", self._check_fastapi_implementation),
254
254
  ("Placeholder Substitution", self._check_no_placeholder_residue),
255
- ("Template Tests", self._test_template),
255
+ # Compile Check is a pure static check: it runs before the (much
256
+ # heavier) Template Tests so a syntax error is reported without
257
+ # waiting for an environment, and so it never observes files a
258
+ # containerised test run left behind.
256
259
  ("Compile Check", self._check_compileall),
260
+ ("Template Tests", self._test_template),
257
261
  ("Type Check", self._check_mypy),
258
262
  ("Smoke Test", self._check_smoke_test),
259
263
  ("Dependency Freshness", self._check_dependency_freshness),
@@ -7,6 +7,7 @@
7
7
  # @author bnbong
8
8
  # --------------------------------------------------------------------------
9
9
  import json
10
+ import os
10
11
  import subprocess
11
12
  import time
12
13
  from typing import Any, Dict, List, Optional, Sequence
@@ -14,6 +15,12 @@ from typing import Any, Dict, List, Optional, Sequence
14
15
  from fastapi_fastkit.utils.logging import debug_log
15
16
 
16
17
  COMPOSE_COMMAND = "docker-compose"
18
+ #: Fallback for hosts that only ship the ``docker compose`` CLI plugin.
19
+ COMPOSE_PLUGIN_COMMAND = ["docker", "compose"]
20
+ #: Resolved compose invocation, switched by :meth:`DockerCompose.is_available`.
21
+ _compose_prefix: List[str] = [COMPOSE_COMMAND]
22
+ #: Throwaway image used to give bind-mounted files back to the host user.
23
+ RECLAIM_IMAGE = "alpine:3.20"
17
24
  SHORT_TIMEOUT = 10
18
25
  STATUS_TIMEOUT = 30
19
26
  CLEANUP_TIMEOUT = 60
@@ -43,27 +50,49 @@ class DockerCompose:
43
50
  debug_log(f"Command {' '.join(args)} failed: {e}", "warning")
44
51
  return None
45
52
 
53
+ @staticmethod
54
+ def compose_command() -> List[str]:
55
+ """Return the compose invocation resolved by :meth:`is_available`."""
56
+ return list(_compose_prefix)
57
+
46
58
  def _compose(
47
59
  self, args: Sequence[str], timeout: int
48
60
  ) -> Optional[subprocess.CompletedProcess[str]]:
49
61
  """Run a docker-compose subcommand against the configured compose file."""
50
62
  return self._run(
51
- [COMPOSE_COMMAND, "-f", self.compose_file, *args], timeout=timeout
63
+ [*self.compose_command(), "-f", self.compose_file, *args], timeout=timeout
52
64
  )
53
65
 
66
+ @staticmethod
67
+ def _probe(command: Sequence[str]) -> bool:
68
+ """Return whether ``command`` exits successfully."""
69
+ try:
70
+ result = subprocess.run(
71
+ list(command), capture_output=True, text=True, timeout=SHORT_TIMEOUT
72
+ )
73
+ except (subprocess.TimeoutExpired, OSError):
74
+ return False
75
+ return result.returncode == 0
76
+
54
77
  @staticmethod
55
78
  def is_available() -> bool:
56
- """Check that both docker and docker-compose respond."""
57
- for command in (["docker", "--version"], [COMPOSE_COMMAND, "--version"]):
58
- try:
59
- result = subprocess.run(
60
- command, capture_output=True, text=True, timeout=SHORT_TIMEOUT
61
- )
62
- except (subprocess.TimeoutExpired, OSError):
63
- return False
64
- if result.returncode != 0:
65
- return False
66
- return True
79
+ """Check that docker responds and a compose implementation exists.
80
+
81
+ ``docker-compose`` stays the preferred command; hosts that only ship
82
+ the ``docker compose`` CLI plugin fall back to it.
83
+ """
84
+ global _compose_prefix
85
+
86
+ if not DockerCompose._probe(["docker", "--version"]):
87
+ return False
88
+ if DockerCompose._probe([COMPOSE_COMMAND, "--version"]):
89
+ _compose_prefix = [COMPOSE_COMMAND]
90
+ return True
91
+ if DockerCompose._probe([*COMPOSE_PLUGIN_COMMAND, "version"]):
92
+ debug_log("Falling back to the 'docker compose' CLI plugin", "info")
93
+ _compose_prefix = list(COMPOSE_PLUGIN_COMMAND)
94
+ return True
95
+ return False
67
96
 
68
97
  def _services(self, timeout: int) -> List[Dict[str, Any]]:
69
98
  """Return the parsed ``docker-compose ps --format json`` entries."""
@@ -71,8 +100,34 @@ class DockerCompose:
71
100
  if result is None or result.returncode != 0:
72
101
  return []
73
102
 
103
+ # Compose sometimes interleaves ``time="..." level=warning`` log lines
104
+ # with the JSON payload, so only JSON-looking lines are considered.
105
+ payload = "\n".join(
106
+ line
107
+ for line in result.stdout.splitlines()
108
+ if line.strip().startswith(("[", "{"))
109
+ ).strip()
110
+ if not payload:
111
+ return []
112
+
113
+ # Compose < v2.21 prints a single JSON array, newer versions print one
114
+ # JSON object per line (NDJSON).
115
+ try:
116
+ document = json.loads(payload)
117
+ except json.JSONDecodeError:
118
+ return DockerCompose._parse_json_lines(payload)
119
+
120
+ if isinstance(document, dict):
121
+ return [document]
122
+ if isinstance(document, list):
123
+ return [entry for entry in document if isinstance(entry, dict)]
124
+ return []
125
+
126
+ @staticmethod
127
+ def _parse_json_lines(payload: str) -> List[Dict[str, Any]]:
128
+ """Parse NDJSON output, skipping malformed or non-object lines."""
74
129
  services: List[Dict[str, Any]] = []
75
- for line in result.stdout.strip().split("\n"):
130
+ for line in payload.split("\n"):
76
131
  if not line.strip():
77
132
  continue
78
133
  try:
@@ -109,7 +164,7 @@ class DockerCompose:
109
164
  """Build and start the compose services in the background."""
110
165
  result = self._compose(["up", "-d", "--build"], timeout=timeout)
111
166
  if result is None:
112
- raise subprocess.TimeoutExpired(COMPOSE_COMMAND, timeout)
167
+ raise subprocess.TimeoutExpired(" ".join(self.compose_command()), timeout)
113
168
  return result
114
169
 
115
170
  def wait_until_healthy(self, timeout: int) -> None:
@@ -166,6 +221,50 @@ class DockerCompose:
166
221
  debug_log("All required services are running", "info")
167
222
  return None
168
223
 
224
+ def published_port(self, service_hint: str = "app") -> Optional[int]:
225
+ """Return the host port the app container publishes, if any.
226
+
227
+ ``docker-compose ps --format json`` reports a ``Publishers`` list per
228
+ service; an entry with a non-zero ``PublishedPort`` is a port bound on
229
+ the host, which is what a smoke test can reach. Services whose name
230
+ contains ``service_hint`` are preferred, so a database that happens to
231
+ publish a port is never mistaken for the application.
232
+ """
233
+ services = self._services(timeout=STATUS_TIMEOUT)
234
+ if not services:
235
+ return None
236
+
237
+ def _matches(service: Dict[str, Any]) -> bool:
238
+ name = f"{service.get('Service', '')} {service.get('Name', '')}"
239
+ return service_hint in name
240
+
241
+ for candidate in (
242
+ [entry for entry in services if _matches(entry)],
243
+ services,
244
+ ):
245
+ for service in candidate:
246
+ port = self._first_published_port(service)
247
+ if port is not None:
248
+ return port
249
+ return None
250
+
251
+ @staticmethod
252
+ def _first_published_port(service: Dict[str, Any]) -> Optional[int]:
253
+ """Extract the first host-bound port from one ``ps`` entry."""
254
+ publishers = service.get("Publishers")
255
+ if not isinstance(publishers, list):
256
+ return None
257
+ for publisher in publishers:
258
+ if not isinstance(publisher, dict):
259
+ continue
260
+ try:
261
+ port = int(publisher.get("PublishedPort", 0))
262
+ except (TypeError, ValueError):
263
+ continue
264
+ if port > 0:
265
+ return port
266
+ return None
267
+
169
268
  def exec_tests(
170
269
  self, use_test_script: bool
171
270
  ) -> Optional[subprocess.CompletedProcess[str]]:
@@ -176,10 +275,46 @@ class DockerCompose:
176
275
  command = ["exec", "-T", "app", "python", "-m", "pytest", "tests/", "-v"]
177
276
  return self._compose(command, timeout=TEST_TIMEOUT)
178
277
 
278
+ def reclaim_bind_mount_ownership(self) -> None:
279
+ """Give the bind-mounted project directory back to the host user.
280
+
281
+ Containers run as root, so a test run inside the stack leaves
282
+ root-owned artefacts (``__pycache__``, ``.pytest_cache``, ...) in the
283
+ mounted project. Later host-side steps and the temp directory cleanup
284
+ cannot touch those and fail with a PermissionError, so the ownership
285
+ is reset from inside a throwaway container. Best effort: a failure
286
+ here is never worth failing an inspection over.
287
+ """
288
+ if not hasattr(os, "getuid"): # pragma: no cover - Windows hosts
289
+ return
290
+
291
+ uid, gid = os.getuid(), os.getgid()
292
+ if uid == 0:
293
+ return
294
+
295
+ debug_log("Reclaiming ownership of bind-mounted project files", "info")
296
+ self._run(
297
+ [
298
+ "docker",
299
+ "run",
300
+ "--rm",
301
+ "-v",
302
+ f"{self.project_dir}:/mnt",
303
+ RECLAIM_IMAGE,
304
+ "chown",
305
+ "-R",
306
+ f"{uid}:{gid}",
307
+ "/mnt",
308
+ ],
309
+ timeout=CLEANUP_TIMEOUT,
310
+ )
311
+
179
312
  def cleanup(self) -> None:
180
313
  """Tear down services and volumes, ignoring any failure."""
181
314
  debug_log("Cleaning up Docker services", "info")
315
+ self.reclaim_bind_mount_ownership()
182
316
  self._run(
183
- [COMPOSE_COMMAND, "down", "-v", "--remove-orphans"], timeout=CLEANUP_TIMEOUT
317
+ [*self.compose_command(), "down", "-v", "--remove-orphans"],
318
+ timeout=CLEANUP_TIMEOUT,
184
319
  )
185
320
  self._run(["docker", "system", "prune", "-f"], timeout=STATUS_TIMEOUT)
@@ -1,12 +1,17 @@
1
1
  # --------------------------------------------------------------------------
2
2
  # Static analysis of the generated project.
3
3
  #
4
- # ``compileall`` is mandatory - a template that cannot be byte-compiled is
5
- # broken beyond argument. ``mypy`` is opt-in because it needs the template's
6
- # own dependencies installed and is far slower.
4
+ # The compile check is mandatory - a template that cannot be compiled is
5
+ # broken beyond argument. It is performed in-process with ``compile()`` and
6
+ # deliberately writes nothing to disk: a Docker test run beforehand can leave
7
+ # root-owned ``__pycache__`` directories in the bind-mounted project, and
8
+ # ``compileall`` would then fail with a PermissionError that says nothing
9
+ # about the template. ``mypy`` is opt-in because it needs the template's own
10
+ # dependencies installed and is far slower.
7
11
  #
8
12
  # @author bnbong
9
13
  # --------------------------------------------------------------------------
14
+ import os
10
15
  import subprocess
11
16
  import sys
12
17
  from typing import List
@@ -15,43 +20,52 @@ from fastapi_fastkit.utils.logging import debug_log
15
20
 
16
21
  from .context import InspectionContext
17
22
 
18
- COMPILE_TIMEOUT = 120
19
23
  MYPY_TIMEOUT = 300
20
24
 
25
+ #: Directories never worth compiling - third party code and build artefacts.
26
+ EXCLUDED_DIRS = {".venv", "venv", "__pycache__", "node_modules"}
27
+
21
28
 
22
29
  def _interpreter(ctx: InspectionContext) -> str:
23
30
  """Prefer the inspection venv interpreter, falling back to the host one."""
24
31
  return ctx.python_executable() or sys.executable
25
32
 
26
33
 
34
+ def _iter_python_files(root: str) -> List[str]:
35
+ """Collect every ``.py`` file below ``root``, skipping excluded directories."""
36
+ python_files: List[str] = []
37
+ for dirpath, dirnames, filenames in os.walk(root):
38
+ dirnames[:] = [name for name in dirnames if name not in EXCLUDED_DIRS]
39
+ for file_name in sorted(filenames):
40
+ if file_name.endswith(".py"):
41
+ python_files.append(os.path.join(dirpath, file_name))
42
+ return python_files
43
+
44
+
27
45
  def check_compileall(ctx: InspectionContext) -> bool:
28
- """Byte-compile every Python file of the generated project."""
29
- command: List[str] = [
30
- _interpreter(ctx),
31
- "-m",
32
- "compileall",
33
- "-q",
34
- "-x",
35
- r"(\.venv|venv|node_modules)",
36
- ctx.temp_dir,
37
- ]
38
- try:
39
- result = subprocess.run(
40
- command,
41
- cwd=ctx.temp_dir,
42
- capture_output=True,
43
- text=True,
44
- timeout=COMPILE_TIMEOUT,
45
- )
46
- except subprocess.TimeoutExpired:
47
- ctx.add_error("compileall timed out")
48
- return False
49
- except OSError as e:
50
- ctx.add_error(f"Failed to run compileall: {e}")
51
- return False
46
+ """Compile every Python file of the generated project, in memory.
52
47
 
53
- if result.returncode != 0:
54
- detail = (result.stderr or result.stdout or "").strip()
48
+ Nothing is written to disk - no ``__pycache__`` is created - so the check
49
+ stays a pure syntax gate that cannot trip over file ownership left behind
50
+ by a containerised test run.
51
+ """
52
+ failures: List[str] = []
53
+
54
+ for path in _iter_python_files(ctx.temp_dir):
55
+ try:
56
+ with open(path, "rb") as f:
57
+ source = f.read()
58
+ except OSError as e:
59
+ failures.append(f"{path}: could not be read ({e})")
60
+ continue
61
+
62
+ try:
63
+ compile(source, path, "exec", dont_inherit=True)
64
+ except (SyntaxError, ValueError) as e:
65
+ failures.append(f"{path}: {e}")
66
+
67
+ if failures:
68
+ detail = "\n".join(failures)
55
69
  ctx.add_error(f"Generated project failed to compile:\n{detail}")
56
70
  return False
57
71
 
@@ -115,12 +115,17 @@ def _probe(url: str, timeout: int = 5) -> Optional[int]:
115
115
 
116
116
 
117
117
  def _wait_for_server(
118
- process: "subprocess.Popen[bytes]", base_url: str, timeout: int
118
+ process: Optional["subprocess.Popen[bytes]"], base_url: str, timeout: int
119
119
  ) -> Tuple[bool, str]:
120
- """Poll ``/docs`` until the server answers, the process dies or time runs out."""
120
+ """Poll ``/docs`` until the server answers, the process dies or time runs out.
121
+
122
+ ``process`` is ``None`` when the server is not ours to watch - a container
123
+ started by the Docker strategy, for one - in which case only the timeout
124
+ bounds the wait.
125
+ """
121
126
  deadline = time.time() + timeout
122
127
  while time.time() < deadline:
123
- if process.poll() is not None:
128
+ if process is not None and process.poll() is not None:
124
129
  return False, "server process exited before becoming reachable"
125
130
  status = _probe(f"{base_url}/docs")
126
131
  if status is not None:
@@ -228,14 +233,70 @@ def _read_log_tail(log_file: IO[bytes]) -> str:
228
233
  return ""
229
234
 
230
235
 
236
+ def _check_health_endpoint(ctx: InspectionContext, base_url: str) -> Optional[str]:
237
+ """Probe ``/health``; return a failure message, or ``None`` when acceptable.
238
+
239
+ A template without a ``/health`` route answers 404, which is fine; an
240
+ unreachable endpoint is only worth a warning, but a route that exists and
241
+ answers with anything other than 200 is a real failure.
242
+ """
243
+ health_status = _probe(f"{base_url}/health")
244
+ if health_status is None:
245
+ ctx.add_warning("Smoke test: /health did not respond")
246
+ elif health_status == 404:
247
+ debug_log("Template exposes no /health endpoint, skipping", "info")
248
+ elif health_status != 200:
249
+ return (
250
+ f"Smoke test failed: /health returned HTTP {health_status} (expected 200)"
251
+ )
252
+ return None
253
+
254
+
255
+ def run_http_smoke(ctx: InspectionContext, base_url: str) -> bool:
256
+ """Verify the HTTP surface of a server someone else already started.
257
+
258
+ Used by the Docker strategy, which has the real application running in a
259
+ container: probing its published port is more honest than booting a second
260
+ copy on the host, and templates that require Docker never get a host venv
261
+ to boot one with in the first place.
262
+ """
263
+ debug_log(f"Running smoke test against {base_url}", "info")
264
+ reachable, reason = _wait_for_server(None, base_url, ctx.options.smoke_timeout)
265
+ if not reachable:
266
+ ctx.add_error(f"Smoke test failed: {reason}")
267
+ return False
268
+
269
+ failure = _check_health_endpoint(ctx, base_url)
270
+ if failure:
271
+ ctx.add_error(failure)
272
+ return False
273
+
274
+ debug_log("Smoke test passed", "info")
275
+ return True
276
+
277
+
278
+ def _requires_docker(ctx: InspectionContext) -> bool:
279
+ """Whether the template declares that it can only run under Docker."""
280
+ return bool((ctx.template_config or {}).get("requires_docker", False))
281
+
282
+
231
283
  def check_smoke_test(ctx: InspectionContext) -> bool:
232
284
  """Boot the generated project and verify its HTTP surface."""
233
285
  if not ctx.options.run_smoke_test:
234
286
  debug_log("Smoke test disabled", "info")
235
287
  return True
236
288
 
289
+ if ctx.smoke_result is not None:
290
+ debug_log("Reusing the smoke test result recorded while testing", "info")
291
+ return ctx.smoke_result
292
+
237
293
  python_executable = ctx.python_executable()
238
294
  if not python_executable or not os.path.exists(python_executable):
295
+ if _requires_docker(ctx):
296
+ ctx.add_warning(
297
+ "smoke test skipped: Docker template without published port"
298
+ )
299
+ return True
239
300
  ctx.add_error(
240
301
  "Smoke test requires an installed environment, but no virtual "
241
302
  "environment was prepared for the generated project"
@@ -306,16 +367,9 @@ def check_smoke_test(ctx: InspectionContext) -> bool:
306
367
  ctx.add_error(last_failure)
307
368
  return False
308
369
 
309
- health_status = _probe(f"{base_url}/health")
310
- if health_status is None:
311
- ctx.add_warning("Smoke test: /health did not respond")
312
- elif health_status == 404:
313
- debug_log("Template exposes no /health endpoint, skipping", "info")
314
- elif health_status != 200:
315
- ctx.add_error(
316
- f"Smoke test failed: /health returned HTTP {health_status} "
317
- "(expected 200)\n" + _read_log_tail(log_file)
318
- )
370
+ failure = _check_health_endpoint(ctx, base_url)
371
+ if failure:
372
+ ctx.add_error(f"{failure}\n{_read_log_tail(log_file)}".rstrip())
319
373
  return False
320
374
  finally:
321
375
  _terminate(process)
@@ -16,6 +16,7 @@ from typing import Any, Dict, List
16
16
  from fastapi_fastkit.backend.main import create_venv, install_dependencies_with_manager
17
17
  from fastapi_fastkit.utils.logging import debug_log
18
18
 
19
+ from . import smoke
19
20
  from .context import InspectionContext
20
21
  from .docker import DockerCompose
21
22
  from .fsutils import fix_all_script_line_endings, fix_script_line_endings
@@ -278,7 +279,15 @@ class DockerStrategy(TestStrategy):
278
279
  self.ctx.add_error(verification_error)
279
280
  return False
280
281
 
281
- return self._run_tests(compose)
282
+ if not self._run_tests(compose):
283
+ return False
284
+
285
+ # The application is running right here, in a container with a
286
+ # published port: probing that is the only way a Docker-only
287
+ # template gets a smoke test, since no host venv is ever built for
288
+ # it. Done before the ``finally`` below tears the stack down.
289
+ self._run_smoke_test(compose)
290
+ return True
282
291
  except subprocess.TimeoutExpired:
283
292
  self.ctx.add_error("Docker Compose setup timed out")
284
293
  return False
@@ -307,6 +316,28 @@ class DockerStrategy(TestStrategy):
307
316
  debug_log("Docker tests passed successfully", "info")
308
317
  return True
309
318
 
319
+ def _run_smoke_test(self, compose: DockerCompose) -> None:
320
+ """Probe the running container's HTTP surface and record the verdict.
321
+
322
+ The result lands on the context so the pipeline's Smoke Test step
323
+ reuses it instead of trying (and failing) to boot the project from a
324
+ virtual environment that a Docker run never creates.
325
+ """
326
+ if not self.ctx.options.run_smoke_test:
327
+ return
328
+
329
+ port = compose.published_port()
330
+ if port is None:
331
+ self.ctx.add_warning(
332
+ "smoke test skipped: Docker template without published port"
333
+ )
334
+ self.ctx.smoke_result = True
335
+ return
336
+
337
+ self.ctx.smoke_result = smoke.run_http_smoke(
338
+ self.ctx, f"http://127.0.0.1:{port}"
339
+ )
340
+
310
341
 
311
342
  def select_fallback_strategy(ctx: InspectionContext) -> TestStrategy:
312
343
  """Pick the fallback strategy, or the standard one when none is configured."""
@@ -18,6 +18,7 @@ from fastapi_fastkit.backend.package_managers import PackageManagerFactory
18
18
  from fastapi_fastkit.backend.project_builder.preset_layout import (
19
19
  app_module_from_main_path,
20
20
  )
21
+ from fastapi_fastkit.backend.route_wiring import register_router, router_alias
21
22
  from fastapi_fastkit.backend.transducer import (
22
23
  copy_and_convert_template,
23
24
  copy_and_convert_template_file,
@@ -1464,6 +1465,16 @@ def _handle_api_router_file(
1464
1465
  if os.path.exists(api_source):
1465
1466
  copy_and_convert_template_file(api_source, api_router_file)
1466
1467
 
1468
+ # The __init__ template may be empty; initialize the API router.
1469
+ if os.path.exists(api_router_file):
1470
+ with open(api_router_file, "r", encoding="utf-8") as f:
1471
+ content = f.read()
1472
+ if not content.strip():
1473
+ with open(api_router_file, "w", encoding="utf-8") as f:
1474
+ f.write(
1475
+ "from fastapi import APIRouter\n\napi_router = APIRouter()\n"
1476
+ )
1477
+
1467
1478
  # Update API router to include new route
1468
1479
  if os.path.exists(api_router_file):
1469
1480
  _update_api_router(api_router_file, route_name)
@@ -1484,23 +1495,22 @@ def _update_api_router(api_router_file: str, route_name: str) -> None:
1484
1495
  with open(api_router_file, "r", encoding="utf-8") as f:
1485
1496
  content = f.read()
1486
1497
 
1487
- route_import = f"from .routes import {route_name}"
1498
+ alias = router_alias(api_router_file, "routes", route_name, route_name)
1499
+ suffix = f" as {alias}" if alias != route_name else ""
1500
+ route_import = f"from .routes import {route_name}{suffix}"
1488
1501
  route_include = (
1489
- f"api_router.include_router({route_name}.router, "
1502
+ f"api_router.include_router({alias}.router, "
1490
1503
  f'prefix="/{route_name}", tags=["{route_name}"])'
1491
1504
  )
1492
1505
 
1493
- if (
1494
- route_import in content
1495
- and f"api_router.include_router({route_name}.router" in content
1496
- ):
1497
- return # Already included
1498
-
1499
- content = insert_import_line(content, route_import)
1500
- content = insert_statement_line(content, route_include)
1501
-
1502
- with open(api_router_file, "w", encoding="utf-8") as f:
1503
- f.write(content)
1506
+ register_router(
1507
+ {
1508
+ "api_dir": os.path.dirname(api_router_file),
1509
+ "api_router_file": api_router_file,
1510
+ },
1511
+ route_import,
1512
+ route_include,
1513
+ )
1504
1514
 
1505
1515
  debug_log(f"Updated API router to include {route_name}", "info")
1506
1516
 
@@ -1594,7 +1604,9 @@ def _update_main_app(
1594
1604
  print_warning(f"Failed to update main.py: {e}")
1595
1605
 
1596
1606
 
1597
- def add_new_route(project_dir: str, route_name: str) -> None:
1607
+ def add_new_route(
1608
+ project_dir: str, route_name: str, layout: Optional[str] = None
1609
+ ) -> None:
1598
1610
  """
1599
1611
  Add a new API route to an existing FastAPI project.
1600
1612
 
@@ -1604,37 +1616,20 @@ def add_new_route(project_dir: str, route_name: str) -> None:
1604
1616
 
1605
1617
  :param project_dir: Path to the project directory
1606
1618
  :param route_name: Name of the new route to add
1619
+ :param layout: Explicit route layout, or None to select from preset metadata
1607
1620
  :raises BackendExceptions: If route addition fails
1608
1621
  """
1609
1622
  try:
1610
- # Setup paths
1611
- modules_dir = os.path.join(settings.FASTKIT_TEMPLATE_ROOT, "modules")
1612
- layout = resolve_project_layout(project_dir)
1613
- src_dir = layout["package_dir"]
1614
-
1615
- # Ensure project structure exists
1616
- target_dirs = _ensure_project_structure(src_dir)
1617
-
1618
- # Create route files
1619
- _create_route_files(
1620
- modules_dir, target_dirs, route_name, layout["package_module"]
1623
+ # Import here to avoid a circular import with route_generators.
1624
+ from fastapi_fastkit.backend.route_generators import (
1625
+ get_route_generator,
1626
+ resolve_route_layout,
1621
1627
  )
1622
1628
 
1623
- # Handle API router file
1624
- _handle_api_router_file(
1625
- target_dirs, modules_dir, route_name, layout["api_router_file"]
1626
- )
1627
-
1628
- # Process init files
1629
- module_types = ["api/routes", "crud", "schemas"]
1630
- _process_init_files(modules_dir, target_dirs, module_types)
1631
-
1632
- # Update main application
1633
- _update_main_app(
1634
- src_dir,
1635
- route_name,
1636
- router_module=layout["api_router_module"],
1637
- main_py_path=layout["main"],
1629
+ route_layout = resolve_route_layout(project_dir, layout)
1630
+ project_layout = resolve_project_layout(project_dir)
1631
+ get_route_generator(route_layout).add_new_route(
1632
+ project_dir, route_name, project_layout
1638
1633
  )
1639
1634
 
1640
1635
  debug_log(f"Successfully added new route: {route_name}", "info")