csrd-utils 0.3.43__tar.gz → 0.3.45__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/PKG-INFO +1 -1
  2. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/pyproject.toml +1 -1
  3. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/__main__.py +0 -3
  4. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/augmentor.py +32 -3
  5. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/doctor.py +15 -3
  6. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/generator.py +133 -0
  7. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/pyproject.toml +0 -3
  8. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/conftest.py +1 -5
  9. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/test_items.py +4 -4
  10. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/unit/test_cache.py +1 -1
  11. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/unit/test_settings_contracts.py +5 -5
  12. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/unit/test_worker_broker.py +1 -1
  13. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/.gitignore +0 -0
  14. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/README.md +0 -0
  15. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/__init__.py +0 -0
  16. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/audit.py +0 -0
  17. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/cluster.py +0 -0
  18. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/caching/conftest.fragment.py +0 -0
  19. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/caching/docker-compose.fragment.yaml +0 -0
  20. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/caching/manifest.yaml +0 -0
  21. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/caching/requirements.fragment +0 -0
  22. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/caching/settings.fragment.py +0 -0
  23. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/caching/src/app/cache.py +0 -0
  24. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/caching/tests/test_cache.py +0 -0
  25. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/logging/conftest.fragment.py +0 -0
  26. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/logging/manifest.yaml +0 -0
  27. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/logging/requirements.fragment +0 -0
  28. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/logging/settings.fragment.py +0 -0
  29. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/logging/src/app/middleware/logging.py +0 -0
  30. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/logging/tests/test_logging_middleware.py +0 -0
  31. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/metrics/conftest.fragment.py +0 -0
  32. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/metrics/docker-compose.fragment.yaml +0 -0
  33. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/metrics/manifest.yaml +0 -0
  34. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/metrics/requirements.fragment +0 -0
  35. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/metrics/settings.fragment.py +0 -0
  36. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/metrics/src/app/middleware/metrics.py +0 -0
  37. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/metrics/tests/test_metrics_middleware.py +0 -0
  38. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/tracing/conftest.fragment.py +0 -0
  39. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/tracing/manifest.yaml +0 -0
  40. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/tracing/requirements.fragment +0 -0
  41. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/tracing/settings.fragment.py +0 -0
  42. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/tracing/src/app/middleware/tracing.py +0 -0
  43. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/tracing/tests/test_tracing_middleware.py +0 -0
  44. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/workers/conftest.fragment.py +0 -0
  45. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/workers/docker-compose.fragment.yaml +0 -0
  46. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/workers/manifest.yaml +0 -0
  47. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/workers/requirements.fragment +0 -0
  48. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/workers/settings.fragment.py +0 -0
  49. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/workers/src/app/workers/__init__.py +0 -0
  50. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/workers/src/app/workers/celery_app.py +0 -0
  51. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/workers/src/app/workers/tasks.py +0 -0
  52. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/features/workers/tests/test_workers.py +0 -0
  53. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/fragments.py +0 -0
  54. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/resources.py +0 -0
  55. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/README.md +0 -0
  56. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/cookiecutter.json +0 -0
  57. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/hooks/post_gen_project.py +0 -0
  58. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/.cookiecutterignore +0 -0
  59. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/.dockerignore +0 -0
  60. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/Dockerfile +0 -0
  61. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/README.md +0 -0
  62. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/__init__.py +0 -0
  63. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/cache.py +0 -0
  64. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/delegates/__init__.py +0 -0
  65. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/delegates/upstreams.py +0 -0
  66. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/dependencies/__init__.py +0 -0
  67. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/dependencies/delegates.py +0 -0
  68. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/dependencies/item_repository.py +0 -0
  69. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/dependencies/worker_broker.py +0 -0
  70. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/docker-compose.yml +0 -0
  71. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/middleware/__init__.py +0 -0
  72. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/middleware/logging.py +0 -0
  73. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/py.typed +0 -0
  74. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/repositories/__init__.py +0 -0
  75. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/repositories/item_repository.py +0 -0
  76. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/requirements.txt +0 -0
  77. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/settings.py +0 -0
  78. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/__init__.py +0 -0
  79. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/acceptance/test_logging_middleware.py +0 -0
  80. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/unit/test_database_settings.py +0 -0
  81. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/unit/test_delegates.py +0 -0
  82. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/views/__init__.py +0 -0
  83. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/views/unversioned/__init__.py +0 -0
  84. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/views/unversioned/health_view.py +0 -0
  85. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/views/unversioned/items_view.py +0 -0
  86. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/workers/__init__.py +0 -0
  87. {csrd_utils-0.3.43 → csrd_utils-0.3.45}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/workers/tasks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: csrd-utils
3
- Version: 0.3.43
3
+ Version: 0.3.45
4
4
  Summary: CLI utilities for csrd service generation and feature augmentation
5
5
  Project-URL: Repository, https://github.com/csrd-api/fastapi-common
6
6
  Project-URL: Documentation, https://github.com/csrd-api/fastapi-common/tree/main/packages/utils
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "csrd-utils"
3
- version = "0.3.43"
3
+ version = "0.3.45"
4
4
  description = "CLI utilities for csrd service generation and feature augmentation"
5
5
  license = { text = "MIT" }
6
6
  requires-python = ">=3.12"
@@ -598,7 +598,6 @@ def _is_service_root(path: Path) -> bool:
598
598
  return (
599
599
  path.is_dir()
600
600
  and (path / "requirements.txt").is_file()
601
- and (path / "tests").is_dir()
602
601
  and (path / "pyproject.toml").is_file()
603
602
  )
604
603
 
@@ -651,8 +650,6 @@ def _workspace_root_for_service(service_root: Path) -> Path | None:
651
650
  def _validate_service_root(service_root: Path) -> None:
652
651
  if not service_root.is_dir():
653
652
  raise ValueError(f"Service path is not a directory: {service_root}")
654
- if not (service_root / "tests").is_dir():
655
- raise ValueError(f"Missing required directory: {service_root / 'tests'}")
656
653
  if not (service_root / "requirements.txt").is_file():
657
654
  raise ValueError(f"Missing required file: {service_root / 'requirements.txt'}")
658
655
  if not (service_root / "pyproject.toml").is_file():
@@ -66,6 +66,20 @@ class ServiceAugmentor:
66
66
  )
67
67
  return candidate if candidate is not None else self.service_root / "src" / "app"
68
68
 
69
+ @property
70
+ def _workspace_root(self) -> Path | None:
71
+ parent = self.service_root.parent
72
+ if parent.name == "src" and (parent.parent / ".csrd-workspace").is_file():
73
+ return parent.parent
74
+ return None
75
+
76
+ @property
77
+ def _tests_dir(self) -> Path:
78
+ workspace_root = self._workspace_root
79
+ if workspace_root is not None:
80
+ return workspace_root / "tests"
81
+ return self.service_root / "tests"
82
+
69
83
  def load_feature(self, feature_name: str) -> bool:
70
84
  """
71
85
  Load and validate feature manifest.
@@ -108,7 +122,7 @@ class ServiceAugmentor:
108
122
  bool
109
123
  True if valid
110
124
  """
111
- required_dirs = [str(self._pkg_dir.relative_to(self.service_root)), "tests"]
125
+ required_dirs = [str(self._pkg_dir.relative_to(self.service_root))]
112
126
  required_files = ["requirements.txt"]
113
127
 
114
128
  for d in required_dirs:
@@ -316,7 +330,12 @@ class ServiceAugmentor:
316
330
  return True # Not required
317
331
 
318
332
  try:
319
- conftest_path = self.service_root / "tests/conftest.py"
333
+ if self._workspace_root is not None:
334
+ conftest_path = (
335
+ self._tests_dir / "acceptance" / self.service_root.name / "conftest.py"
336
+ )
337
+ else:
338
+ conftest_path = self._tests_dir / "conftest.py"
320
339
  if not conftest_path.exists():
321
340
  print("⚠ conftest.py not found, skipping")
322
341
  return True
@@ -401,7 +420,17 @@ class ServiceAugmentor:
401
420
  for src_file in test_dir.rglob("*"):
402
421
  if src_file.is_file():
403
422
  rel_path = src_file.relative_to(test_dir)
404
- dest_file = self.service_root / "tests" / rel_path
423
+ if self._workspace_root is not None:
424
+ parts = rel_path.parts
425
+ if parts and parts[0] in {"unit", "acceptance"}:
426
+ scoped_rel = Path(parts[0]) / self.service_root.name / Path(*parts[1:])
427
+ elif rel_path.name == "conftest.py":
428
+ scoped_rel = Path("acceptance") / self.service_root.name / "conftest.py"
429
+ else:
430
+ scoped_rel = Path("acceptance") / self.service_root.name / rel_path
431
+ dest_file = self._tests_dir / scoped_rel
432
+ else:
433
+ dest_file = self._tests_dir / rel_path
405
434
 
406
435
  dest_file.parent.mkdir(parents=True, exist_ok=True)
407
436
  shutil.copy2(src_file, dest_file)
@@ -19,7 +19,7 @@ def run_doctor(service_root: Path) -> DoctorReport:
19
19
  errors: list[str] = []
20
20
  warnings: list[str] = []
21
21
 
22
- required_dirs = ["tests"]
22
+ required_dirs: list[str] = []
23
23
  required_files = ["requirements.txt", "pyproject.toml"]
24
24
 
25
25
  compose_candidates = [root / "docker-compose.yml"]
@@ -65,9 +65,21 @@ def run_doctor(service_root: Path) -> DoctorReport:
65
65
  marker_files: dict[str, str] = {}
66
66
  if pkg_dir is not None:
67
67
  marker_files[f"{pkg_dir.name}/settings.py"] = "# [INSERT: fields]"
68
- marker_files["tests/conftest.py"] = "# [INSERT: fixtures]"
68
+ workspace_root = None
69
+ if root.parent.name == "src" and (root.parent.parent / ".csrd-workspace").is_file():
70
+ workspace_root = root.parent.parent
71
+
72
+ if workspace_root is not None and pkg_dir is not None:
73
+ marker_files[f"tests/acceptance/{pkg_dir.name}/conftest.py"] = "# [INSERT: fixtures]"
74
+ else:
75
+ marker_files["tests/conftest.py"] = "# [INSERT: fixtures]"
76
+
69
77
  for rel, marker in marker_files.items():
70
- path = root / rel
78
+ path = (
79
+ (workspace_root / rel)
80
+ if workspace_root is not None and rel.startswith("tests/")
81
+ else (root / rel)
82
+ )
71
83
  if path.exists():
72
84
  text = path.read_text(encoding="utf-8")
73
85
  if marker not in text:
@@ -9,6 +9,61 @@ from cookiecutter.main import cookiecutter
9
9
 
10
10
  from .resources import templates_path
11
11
 
12
+ _WORKSPACE_PYTEST_TOML = """\
13
+ # Workspace-level tool configuration.
14
+ # Add [project] and [build-system] sections if you need to publish this workspace as a package.
15
+
16
+ [tool.pytest.ini_options]
17
+ asyncio_mode = "auto"
18
+ testpaths = ["tests"]
19
+ pythonpath = ["src"]
20
+ """
21
+
22
+ _WORKSPACE_README = """\
23
+ # Workspace
24
+
25
+ Generated workspace with one or more microservices.
26
+
27
+ ## Run tests
28
+
29
+ From the workspace root, run all tests:
30
+
31
+ ```bash
32
+ pytest
33
+ ```
34
+
35
+ Run tests for a specific service:
36
+
37
+ ```bash
38
+ pytest tests/unit/{service_name}
39
+ pytest tests/acceptance/{service_name}
40
+ ```
41
+
42
+ Run tests in watch mode (requires `pytest-watch`):
43
+
44
+ ```bash
45
+ ptw
46
+ ```
47
+
48
+ ## Run services
49
+
50
+ See individual service README files under `src/{service_name}/README.md` for instructions on running each service.
51
+
52
+ ## Docker Compose
53
+
54
+ Run all services and infrastructure:
55
+
56
+ ```bash
57
+ docker compose up --build
58
+ ```
59
+
60
+ Check service health:
61
+
62
+ ```bash
63
+ ./smoke.sh
64
+ ```
65
+ """
66
+
12
67
 
13
68
  def _build_context(
14
69
  *,
@@ -121,6 +176,13 @@ def generate_service(
121
176
 
122
177
  # Move docker files to workspace root and update Dockerfile
123
178
  _move_docker_files_to_workspace_root(generated_path, output_root, name)
179
+ _move_tests_to_workspace_root(generated_path, output_root, name)
180
+
181
+ # Ensure workspace root has a pyproject.toml with pytest config.
182
+ _write_workspace_pyproject(output_root)
183
+
184
+ # Ensure workspace root has a README with test running instructions.
185
+ _write_workspace_readme(output_root)
124
186
 
125
187
  return generated_path
126
188
 
@@ -154,3 +216,74 @@ def _move_docker_files_to_workspace_root(
154
216
  )
155
217
  dockerfile_dst.write_text(dockerfile_content)
156
218
  dockerfile_src.unlink()
219
+
220
+
221
+ def _move_tests_to_workspace_root(
222
+ service_path: Path, workspace_root: Path, service_name: str
223
+ ) -> None:
224
+ """Move generated tests into workspace-level namespaced suites."""
225
+ service_name_snake = service_name.replace("-", "_")
226
+ tests_src = service_path / "tests"
227
+ if not tests_src.is_dir():
228
+ return
229
+
230
+ tests_root = workspace_root / "tests"
231
+ unit_dst = tests_root / "unit" / service_name_snake
232
+ acceptance_dst = tests_root / "acceptance" / service_name_snake
233
+ unit_dst.mkdir(parents=True, exist_ok=True)
234
+ acceptance_dst.mkdir(parents=True, exist_ok=True)
235
+
236
+ def _copy_tree(src_dir: Path, dst_dir: Path) -> None:
237
+ if not src_dir.is_dir():
238
+ return
239
+ for src_file in src_dir.rglob("*"):
240
+ if src_file.is_file():
241
+ rel = src_file.relative_to(src_dir)
242
+ dst_file = dst_dir / rel
243
+ dst_file.parent.mkdir(parents=True, exist_ok=True)
244
+ shutil.copy2(src_file, dst_file)
245
+
246
+ _copy_tree(tests_src / "unit", unit_dst)
247
+ _copy_tree(tests_src / "acceptance", acceptance_dst)
248
+
249
+ # Service-level API tests belong under acceptance for workspace execution.
250
+ top_level_acceptance = ["test_items.py", "conftest.py"]
251
+ for filename in top_level_acceptance:
252
+ src_file = tests_src / filename
253
+ if src_file.is_file():
254
+ shutil.copy2(src_file, acceptance_dst / filename)
255
+
256
+ shutil.rmtree(tests_src, ignore_errors=True)
257
+
258
+
259
+ def _write_workspace_pyproject(workspace_root: Path) -> None:
260
+ """Write or update workspace-level pyproject.toml with pytest configuration.
261
+
262
+ If the file does not exist it is created from scratch. If it already exists
263
+ and already contains a ``[tool.pytest.ini_options]`` section it is left
264
+ untouched (the user may have customised it). Otherwise the pytest block is
265
+ appended so that a second ``generate_service`` call on the same workspace
266
+ gains the config without clobbering prior edits.
267
+ """
268
+ dst = workspace_root / "pyproject.toml"
269
+ if dst.exists():
270
+ existing = dst.read_text(encoding="utf-8")
271
+ if "[tool.pytest.ini_options]" in existing:
272
+ return
273
+ dst.write_text(
274
+ existing.rstrip() + "\n\n" + _WORKSPACE_PYTEST_TOML,
275
+ encoding="utf-8",
276
+ )
277
+ else:
278
+ dst.write_text(_WORKSPACE_PYTEST_TOML, encoding="utf-8")
279
+
280
+
281
+ def _write_workspace_readme(workspace_root: Path) -> None:
282
+ """Write workspace-level README.md with test running and setup instructions.
283
+
284
+ If the file does not exist it is created from scratch. If it already exists
285
+ it is left untouched (the user may have customised it).
286
+ """
287
+ dst = workspace_root / "README.md"
288
+ if not dst.exists():
289
+ dst.write_text(_WORKSPACE_README, encoding="utf-8")
@@ -38,9 +38,6 @@ dev = [
38
38
  [tool.hatch.build.targets.wheel]
39
39
  packages = ["{{cookiecutter.__service_name_snake}}"]
40
40
 
41
- [tool.pytest.ini_options]
42
- asyncio_mode = "auto"
43
- testpaths = ["tests"]
44
41
 
45
42
  [tool.mypy]
46
43
  python_version = "3.12"
@@ -1,15 +1,11 @@
1
1
  """Shared test fixtures for {{ cookiecutter.service_name }}."""
2
2
 
3
3
  import os
4
- import sys
5
- from pathlib import Path
6
4
 
7
5
  import pytest
8
6
  from fastapi.testclient import TestClient
9
7
 
10
- sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
11
-
12
- from app import build_app
8
+ from {{cookiecutter.__service_name_snake}} import build_app
13
9
 
14
10
  HAS_DATABASE = {{"True" if cookiecutter.has_database else "False"}}
15
11
  DB_KIND = "{{ cookiecutter.database }}"
@@ -111,7 +111,7 @@ def test_delegate_create_and_list(monkeypatch, client):
111
111
  return {"id": "d1", "name": payload["name"], "created_at": "2026-01-01T00:00:00Z"}
112
112
  return {"{{ cookiecutter.model_name_plural | lower | replace(' ', '_') }}": [{"id": "d1", "name": "X", "created_at": "t"}]}
113
113
 
114
- monkeypatch.setattr("app.views.unversioned.items_view._delegate_request", fake_delegate_request)
114
+ monkeypatch.setattr("{{cookiecutter.__service_name_snake}}.views.unversioned.items_view._delegate_request", fake_delegate_request)
115
115
 
116
116
  created = client.post(
117
117
  "/api/{{ cookiecutter.model_name_plural | lower | replace(' ', '_') }}", json={"name": "X"}
@@ -130,7 +130,7 @@ def test_delegate_error_mapping(monkeypatch, client):
130
130
  async def fake_delegate_request(_method: str, _path: str, payload=None):
131
131
  raise HTTPException(status_code=404, detail="not found")
132
132
 
133
- monkeypatch.setattr("app.views.unversioned.items_view._delegate_request", fake_delegate_request)
133
+ monkeypatch.setattr("{{cookiecutter.__service_name_snake}}.views.unversioned.items_view._delegate_request", fake_delegate_request)
134
134
  response = client.get("/api/{{ cookiecutter.model_name_plural | lower | replace(' ', '_') }}/missing")
135
135
  assert response.status_code == 404
136
136
 
@@ -147,7 +147,7 @@ def test_delegate_get_update_upsert_delete(monkeypatch, client):
147
147
  return {"id": "d2", "deleted": True}
148
148
  return {}
149
149
 
150
- monkeypatch.setattr("app.views.unversioned.items_view._delegate_request", fake_delegate_request)
150
+ monkeypatch.setattr("{{cookiecutter.__service_name_snake}}.views.unversioned.items_view._delegate_request", fake_delegate_request)
151
151
 
152
152
  fetched = client.get("/api/{{ cookiecutter.model_name_plural | lower | replace(' ', '_') }}/d2")
153
153
  assert fetched.status_code == 200
@@ -176,7 +176,7 @@ def test_delegate_list_invalid_payload_returns_502(monkeypatch, client):
176
176
  async def fake_delegate_request(_method: str, _path: str, payload=None):
177
177
  return {"{{ cookiecutter.model_name_plural | lower | replace(' ', '_') }}": "bad"}
178
178
 
179
- monkeypatch.setattr("app.views.unversioned.items_view._delegate_request", fake_delegate_request)
179
+ monkeypatch.setattr("{{cookiecutter.__service_name_snake}}.views.unversioned.items_view._delegate_request", fake_delegate_request)
180
180
  response = client.get("/api/{{ cookiecutter.model_name_plural | lower | replace(' ', '_') }}")
181
181
  assert response.status_code == 502
182
182
  {%- endif %}
@@ -17,7 +17,7 @@ def test_cache_client_uses_decode_responses() -> None:
17
17
 
18
18
 
19
19
  def test_cache_client_uses_runtime_cache_url() -> None:
20
- settings_mod = importlib.import_module("app.settings")
20
+ settings_mod = importlib.import_module("{{cookiecutter.__service_name_snake}}.settings")
21
21
  original = settings_mod.settings.cache_url
22
22
  try:
23
23
  settings_mod.settings.cache_url = "redis://cache-host:6381/2"
@@ -73,11 +73,11 @@ def test_validate_db_credentials_rejects_weak_defaults(
73
73
 
74
74
  def test_app_package_init_modules_expose_tuple_all() -> None:
75
75
  for module_name in [
76
- "app.delegates",
77
- "app.dependencies",
78
- "app.middleware",
79
- "app.repositories",
80
- "app.workers",
76
+ "{{cookiecutter.__service_name_snake}}.delegates",
77
+ "{{cookiecutter.__service_name_snake}}.dependencies",
78
+ "{{cookiecutter.__service_name_snake}}.middleware",
79
+ "{{cookiecutter.__service_name_snake}}.repositories",
80
+ "{{cookiecutter.__service_name_snake}}.workers",
81
81
  ]:
82
82
  module = importlib.import_module(module_name)
83
83
  exports = getattr(module, "__all__", ())
@@ -16,7 +16,7 @@ def test_ping_task_returns_pong() -> None:
16
16
 
17
17
 
18
18
  def test_worker_broker_url_reflects_runtime_settings() -> None:
19
- settings_mod = importlib.import_module("app.settings")
19
+ settings_mod = importlib.import_module("{{cookiecutter.__service_name_snake}}.settings")
20
20
  original = settings_mod.settings.worker_broker_url
21
21
  try:
22
22
  settings_mod.settings.worker_broker_url = "amqp://runtime-test"
File without changes
File without changes