csrd-utils 0.3.52__tar.gz → 0.3.55__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.52 → csrd_utils-0.3.55}/PKG-INFO +1 -1
  2. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/pyproject.toml +1 -1
  3. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/__main__.py +71 -2
  4. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/augmentor.py +25 -4
  5. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/cluster.py +13 -4
  6. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/generator.py +14 -0
  7. csrd_utils-0.3.55/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/unit/__init__.py +0 -0
  8. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/.gitignore +0 -0
  9. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/README.md +0 -0
  10. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/__init__.py +0 -0
  11. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/audit.py +0 -0
  12. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/doctor.py +0 -0
  13. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/caching/conftest.fragment.py +0 -0
  14. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/caching/docker-compose.fragment.yaml +0 -0
  15. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/caching/manifest.yaml +0 -0
  16. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/caching/requirements.fragment +0 -0
  17. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/caching/settings.fragment.py +0 -0
  18. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/caching/src/app/cache.py +0 -0
  19. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/caching/tests/test_cache.py +0 -0
  20. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/logging/conftest.fragment.py +0 -0
  21. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/logging/manifest.yaml +0 -0
  22. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/logging/requirements.fragment +0 -0
  23. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/logging/settings.fragment.py +0 -0
  24. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/logging/src/app/middleware/logging.py +0 -0
  25. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/logging/tests/test_logging_middleware.py +0 -0
  26. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/metrics/conftest.fragment.py +0 -0
  27. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/metrics/docker-compose.fragment.yaml +0 -0
  28. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/metrics/manifest.yaml +0 -0
  29. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/metrics/requirements.fragment +0 -0
  30. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/metrics/settings.fragment.py +0 -0
  31. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/metrics/src/app/middleware/metrics.py +0 -0
  32. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/metrics/tests/test_metrics_middleware.py +0 -0
  33. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/tracing/conftest.fragment.py +0 -0
  34. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/tracing/manifest.yaml +0 -0
  35. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/tracing/requirements.fragment +0 -0
  36. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/tracing/settings.fragment.py +0 -0
  37. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/tracing/src/app/middleware/tracing.py +0 -0
  38. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/tracing/tests/test_tracing_middleware.py +0 -0
  39. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/workers/conftest.fragment.py +0 -0
  40. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/workers/docker-compose.fragment.yaml +0 -0
  41. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/workers/manifest.yaml +0 -0
  42. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/workers/requirements.fragment +0 -0
  43. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/workers/settings.fragment.py +0 -0
  44. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/workers/src/app/workers/__init__.py +0 -0
  45. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/workers/src/app/workers/celery_app.py +0 -0
  46. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/workers/src/app/workers/tasks.py +0 -0
  47. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/features/workers/tests/test_workers.py +0 -0
  48. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/fragments.py +0 -0
  49. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/resources.py +0 -0
  50. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/README.md +0 -0
  51. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/cookiecutter.json +0 -0
  52. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/hooks/post_gen_project.py +0 -0
  53. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/.cookiecutterignore +0 -0
  54. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/.dockerignore +0 -0
  55. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/Dockerfile +0 -0
  56. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/README.md +0 -0
  57. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/__init__.py +0 -0
  58. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/cache.py +0 -0
  59. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/delegates/__init__.py +0 -0
  60. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/delegates/upstreams.py +0 -0
  61. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/dependencies/__init__.py +0 -0
  62. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/dependencies/delegates.py +0 -0
  63. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/dependencies/item_repository.py +0 -0
  64. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/dependencies/worker_broker.py +0 -0
  65. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/docker-compose.yml +0 -0
  66. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/middleware/__init__.py +0 -0
  67. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/middleware/logging.py +0 -0
  68. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/repositories/__init__.py +0 -0
  69. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/repositories/item_repository.py +0 -0
  70. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/requirements.txt +0 -0
  71. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/settings.py +0 -0
  72. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/__init__.py +0 -0
  73. /csrd_utils-0.3.52/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/py.typed → /csrd_utils-0.3.55/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/acceptance/__init__.py +0 -0
  74. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/acceptance/test_logging_middleware.py +0 -0
  75. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/conftest.py +0 -0
  76. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/test_items.py +0 -0
  77. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/unit/test_cache.py +0 -0
  78. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/unit/test_database_settings.py +0 -0
  79. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/unit/test_delegates.py +0 -0
  80. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/unit/test_settings_contracts.py +0 -0
  81. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/tests/unit/test_worker_broker.py +0 -0
  82. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/views/__init__.py +0 -0
  83. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/views/unversioned/__init__.py +0 -0
  84. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/views/unversioned/health_view.py +0 -0
  85. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/views/unversioned/items_view.py +0 -0
  86. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.__service_name_snake}}/workers/__init__.py +0 -0
  87. {csrd_utils-0.3.52 → csrd_utils-0.3.55}/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.52
3
+ Version: 0.3.55
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.52"
3
+ version = "0.3.55"
4
4
  description = "CLI utilities for csrd service generation and feature augmentation"
5
5
  license = { text = "MIT" }
6
6
  requires-python = ">=3.12"
@@ -213,12 +213,19 @@ def _collect_new_service_config(args: argparse.Namespace) -> GenerateServiceArgs
213
213
  has_database = args.database != "none"
214
214
  selected_database = "sqlite" if not has_database else args.database
215
215
  normalized_name = _normalize_service_name(args.name)
216
+ # Auto-advance port when default (8080) is already taken in the workspace spec.
217
+ output_root = args.output.resolve()
218
+ effective_port = (
219
+ _next_free_port(output_root, args.port)
220
+ if _is_workspace(output_root) and args.port == 8080
221
+ else args.port
222
+ )
216
223
  return GenerateServiceArgs(
217
224
  {
218
225
  "name": normalized_name,
219
226
  "output_dir": args.output,
220
227
  "description": args.description,
221
- "port": args.port,
228
+ "port": effective_port,
222
229
  "model_name": args.model_name,
223
230
  "model_name_plural": args.model_name_plural,
224
231
  "has_database": has_database,
@@ -242,7 +249,15 @@ def _collect_new_service_config(args: argparse.Namespace) -> GenerateServiceArgs
242
249
  else:
243
250
  output_dir = Path(_prompt_text("Output directory", str(args.output)))
244
251
  description = _prompt_text("Service description", args.description)
245
- port = _prompt_int("HTTP port", args.port)
252
+
253
+ # Suggest the next available port based on what's already in the workspace spec.
254
+ workspace_root_candidate = output_dir.resolve()
255
+ default_port = (
256
+ _next_free_port(workspace_root_candidate, args.port)
257
+ if _is_workspace(workspace_root_candidate)
258
+ else args.port
259
+ )
260
+ port = _prompt_int("HTTP port", default_port)
246
261
  model_name = _prompt_text("Model name", args.model_name)
247
262
 
248
263
  plural_default = args.model_name_plural
@@ -461,6 +476,22 @@ def _is_workspace(path: Path) -> bool:
461
476
  return (path / WORKSPACE_MARKER).is_file()
462
477
 
463
478
 
479
+ def _next_free_port(workspace_root: Path, start: int = 8080) -> int:
480
+ """Return the lowest port >= start that is not already used in the workspace cluster spec."""
481
+ spec_path = workspace_root / WORKSPACE_CLUSTER_SPEC
482
+ if not spec_path.is_file():
483
+ return start
484
+ try:
485
+ raw = yaml.safe_load(spec_path.read_text(encoding="utf-8")) or {}
486
+ used = {int(svc["port"]) for svc in raw.get("services", []) if "port" in svc}
487
+ except Exception:
488
+ return start
489
+ port = start
490
+ while port in used:
491
+ port += 1
492
+ return port
493
+
494
+
464
495
  def _service_is_interactive(args: argparse.Namespace) -> bool:
465
496
  explicit_interactive = getattr(args, "interactive", None)
466
497
  return bool(explicit_interactive) if explicit_interactive is not None else args.name is None
@@ -881,6 +912,37 @@ def _ensure_unique_ports(services: list[ServiceSpec], preserve_name: str) -> Non
881
912
  used.add(port)
882
913
 
883
914
 
915
+ def _sync_workspace_service_feature(
916
+ workspace_root: Path, service_name: str, feature_name: str
917
+ ) -> Path | None:
918
+ spec = _load_workspace_cluster_spec(workspace_root)
919
+ services = spec.get("services", [])
920
+ target = next(
921
+ (
922
+ cast(ServiceSpec, dict(service))
923
+ for service in services
924
+ if str(service.get("name", "")).strip() == service_name
925
+ ),
926
+ None,
927
+ )
928
+ if target is None:
929
+ return None
930
+
931
+ features = list(target.get("features", []))
932
+ if feature_name not in features:
933
+ features.append(feature_name)
934
+ target["features"] = features
935
+
936
+ requires = list(target.get("requires", []))
937
+ if feature_name in {"caching", "workers"} and "redis" not in requires:
938
+ requires.append("redis")
939
+ target["requires"] = requires
940
+
941
+ updated_spec = _upsert_workspace_service(spec, target, workspace_root.name)
942
+ _save_workspace_cluster_spec(workspace_root, updated_spec)
943
+ return generate_cluster(updated_spec, workspace_root, force=True)
944
+
945
+
884
946
  def _maybe_update_workspace_cluster(
885
947
  workspace_root: Path,
886
948
  service_config: GenerateServiceArgs,
@@ -1315,6 +1377,13 @@ def main() -> int:
1315
1377
  if args.plan:
1316
1378
  print("Plan validated.")
1317
1379
  else:
1380
+ feature_workspace_root = _workspace_root_for_service(resolved_service)
1381
+ if feature_workspace_root is not None:
1382
+ refreshed_cluster = _sync_workspace_service_feature(
1383
+ feature_workspace_root, resolved_service.name, args.feature
1384
+ )
1385
+ if refreshed_cluster is not None:
1386
+ print(f"Refreshed cluster scaffold at: {refreshed_cluster}")
1318
1387
  print(f"Feature '{args.feature}' added.")
1319
1388
  if changes:
1320
1389
  for change in changes:
@@ -80,6 +80,15 @@ class ServiceAugmentor:
80
80
  return workspace_root / "tests"
81
81
  return self.service_root / "tests"
82
82
 
83
+ @property
84
+ def _pkg_import_name(self) -> str:
85
+ return self._pkg_dir.name
86
+
87
+ def _rewrite_src_app_refs(self, content: str) -> str:
88
+ """Map legacy feature fragment imports to the concrete service package name."""
89
+ pkg = self._pkg_import_name
90
+ return content.replace("src.app.", f"{pkg}.").replace("src.app", pkg)
91
+
83
92
  def load_feature(self, feature_name: str) -> bool:
84
93
  """
85
94
  Load and validate feature manifest.
@@ -247,6 +256,11 @@ class ServiceAugmentor:
247
256
  if not fragment_path.exists():
248
257
  return True # Not required
249
258
 
259
+ if self._workspace_root is not None:
260
+ # Workspace compose is generated from cluster spec, not merged via legacy fragments.
261
+ print("✓ Skipped docker-compose.yml fragment merge (workspace-managed compose)")
262
+ return True
263
+
250
264
  try:
251
265
  dc_path = self._docker_compose_path()
252
266
  self.backup_file(dc_path)
@@ -343,7 +357,7 @@ class ServiceAugmentor:
343
357
  self.backup_file(conftest_path)
344
358
 
345
359
  base = conftest_path.read_text()
346
- fragment = fragment_path.read_text()
360
+ fragment = self._rewrite_src_app_refs(fragment_path.read_text())
347
361
  merged = ConfTestMerger.merge(base, fragment)
348
362
 
349
363
  conftest_path.write_text(merged)
@@ -387,8 +401,11 @@ class ServiceAugmentor:
387
401
  # Create parent dirs
388
402
  dest_file.parent.mkdir(parents=True, exist_ok=True)
389
403
 
390
- # Copy file
391
- shutil.copy2(src_file, dest_file)
404
+ if src_file.suffix == ".py":
405
+ rendered = self._rewrite_src_app_refs(src_file.read_text())
406
+ dest_file.write_text(rendered)
407
+ else:
408
+ shutil.copy2(src_file, dest_file)
392
409
  self.applied_changes.append(f"Created {rel_path}")
393
410
 
394
411
  print("✓ Created feature files")
@@ -433,7 +450,11 @@ class ServiceAugmentor:
433
450
  dest_file = self._tests_dir / rel_path
434
451
 
435
452
  dest_file.parent.mkdir(parents=True, exist_ok=True)
436
- shutil.copy2(src_file, dest_file)
453
+ if src_file.suffix == ".py":
454
+ rendered = self._rewrite_src_app_refs(src_file.read_text())
455
+ dest_file.write_text(rendered)
456
+ else:
457
+ shutil.copy2(src_file, dest_file)
437
458
  self.applied_changes.append(f"Created {rel_path}")
438
459
 
439
460
  print("✓ Created test files")
@@ -232,8 +232,14 @@ def _infer_infra(services: Iterable[ServiceSpec]) -> set[str]:
232
232
  if service_type == "worker":
233
233
  broker = service.get("broker", "redis")
234
234
  infra.add("redis" if broker == "redis" else "rabbitmq")
235
+ if "workers" in service.get("features", []):
236
+ broker = service.get("broker", "redis")
237
+ infra.add("redis" if broker == "redis" else "rabbitmq")
235
238
  if "caching" in service.get("features", []):
236
239
  infra.add("redis")
240
+ for required in service.get("requires", []):
241
+ if required in INFRA_NAMES:
242
+ infra.add(required)
237
243
  return infra
238
244
 
239
245
 
@@ -258,7 +264,7 @@ def _service_environment(service: ServiceSpec, services: list[ServiceSpec]) -> d
258
264
  env["DB_PASSWORD"] = "${DB_PASSWORD:-change_me}"
259
265
  env["DB_NAME"] = "${DB_NAME:-service_db}"
260
266
 
261
- if service["type"] == "worker":
267
+ if service["type"] == "worker" or "workers" in service.get("features", []):
262
268
  broker = service.get("broker", "redis")
263
269
  if broker == "redis":
264
270
  env["CELERY_BROKER_URL"] = "redis://redis:6379/0"
@@ -306,6 +312,9 @@ def render_compose(spec: ClusterSpec) -> dict[str, Any]:
306
312
  if service["type"] == "worker":
307
313
  requires.add("redis" if service.get("broker", "redis") == "redis" else "rabbitmq")
308
314
 
315
+ if "workers" in service.get("features", []):
316
+ requires.add("redis" if service.get("broker", "redis") == "redis" else "rabbitmq")
317
+
309
318
  if "caching" in service.get("features", []):
310
319
  requires.add("redis")
311
320
 
@@ -432,14 +441,14 @@ def _build_readme(spec: ClusterSpec) -> str:
432
441
  "From the workspace root, run all tests:",
433
442
  "",
434
443
  "```bash",
435
- "pytest",
444
+ "uv run pytest",
436
445
  "```",
437
446
  "",
438
447
  "Run tests for a specific service:",
439
448
  "",
440
449
  "```bash",
441
- "pytest tests/unit/{service_name}",
442
- "pytest tests/acceptance/{service_name}",
450
+ "uv run pytest tests/unit/{service_name}",
451
+ "uv run pytest tests/acceptance/{service_name}",
443
452
  "```",
444
453
  "",
445
454
  "## Run",
@@ -49,6 +49,7 @@ dev = [
49
49
  asyncio_mode = "auto"
50
50
  testpaths = ["tests"]
51
51
  pythonpath = ["src"]
52
+ consider_namespace_packages = true
52
53
 
53
54
  [tool.mypy]
54
55
  python_version = "3.12"
@@ -289,6 +290,19 @@ def _move_tests_to_workspace_root(
289
290
  unit_dst.mkdir(parents=True, exist_ok=True)
290
291
  acceptance_dst.mkdir(parents=True, exist_ok=True)
291
292
 
293
+ # Ensure every test directory level has an __init__.py so pytest uses
294
+ # package-based import paths and avoids module-name collisions across services.
295
+ def _ensure_init(dir_path: Path) -> None:
296
+ init = dir_path / "__init__.py"
297
+ if not init.exists():
298
+ init.write_text("", encoding="utf-8")
299
+
300
+ _ensure_init(tests_root)
301
+ _ensure_init(tests_root / "unit")
302
+ _ensure_init(tests_root / "acceptance")
303
+ _ensure_init(unit_dst)
304
+ _ensure_init(acceptance_dst)
305
+
292
306
  def _copy_tree(src_dir: Path, dst_dir: Path) -> None:
293
307
  if not src_dir.is_dir():
294
308
  return
File without changes
File without changes