csrd-utils 0.3.17__tar.gz → 0.3.19__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.17 → csrd_utils-0.3.19}/PKG-INFO +10 -10
  2. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/README.md +9 -9
  3. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/pyproject.toml +1 -1
  4. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/__main__.py +86 -25
  5. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/augmentor.py +18 -2
  6. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/doctor.py +8 -1
  7. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/items_view.py +44 -12
  8. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/.gitignore +0 -0
  9. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/__init__.py +0 -0
  10. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/audit.py +0 -0
  11. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/cluster.py +0 -0
  12. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/caching/conftest.fragment.py +0 -0
  13. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/caching/docker-compose.fragment.yaml +0 -0
  14. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/caching/manifest.yaml +0 -0
  15. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/caching/requirements.fragment +0 -0
  16. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/caching/settings.fragment.py +0 -0
  17. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/caching/src/app/cache.py +0 -0
  18. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/caching/tests/test_cache.py +0 -0
  19. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/logging/conftest.fragment.py +0 -0
  20. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/logging/manifest.yaml +0 -0
  21. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/logging/requirements.fragment +0 -0
  22. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/logging/settings.fragment.py +0 -0
  23. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/logging/src/app/middleware/logging.py +0 -0
  24. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/logging/tests/test_logging_middleware.py +0 -0
  25. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/metrics/conftest.fragment.py +0 -0
  26. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/metrics/docker-compose.fragment.yaml +0 -0
  27. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/metrics/manifest.yaml +0 -0
  28. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/metrics/requirements.fragment +0 -0
  29. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/metrics/settings.fragment.py +0 -0
  30. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/metrics/src/app/middleware/metrics.py +0 -0
  31. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/metrics/tests/test_metrics_middleware.py +0 -0
  32. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/tracing/conftest.fragment.py +0 -0
  33. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/tracing/manifest.yaml +0 -0
  34. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/tracing/requirements.fragment +0 -0
  35. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/tracing/settings.fragment.py +0 -0
  36. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/tracing/src/app/middleware/tracing.py +0 -0
  37. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/tracing/tests/test_tracing_middleware.py +0 -0
  38. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/workers/conftest.fragment.py +0 -0
  39. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/workers/docker-compose.fragment.yaml +0 -0
  40. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/workers/manifest.yaml +0 -0
  41. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/workers/requirements.fragment +0 -0
  42. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/workers/settings.fragment.py +0 -0
  43. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/workers/src/app/workers/__init__.py +0 -0
  44. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/workers/src/app/workers/celery_app.py +0 -0
  45. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/workers/src/app/workers/tasks.py +0 -0
  46. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/features/workers/tests/test_workers.py +0 -0
  47. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/fragments.py +0 -0
  48. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/generator.py +0 -0
  49. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/resources.py +0 -0
  50. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/README.md +0 -0
  51. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/cookiecutter.json +0 -0
  52. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/hooks/post_gen_project.py +0 -0
  53. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.dockerignore +0 -0
  54. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.env.example +0 -0
  55. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.gitignore +0 -0
  56. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/Dockerfile +0 -0
  57. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/README.md +0 -0
  58. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/docker-compose.yml +0 -0
  59. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/pyproject.toml +0 -0
  60. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/requirements.txt +0 -0
  61. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/__init__.py +0 -0
  62. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/cache.py +0 -0
  63. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/delegates/__init__.py +0 -0
  64. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/delegates/upstreams.py +0 -0
  65. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/__init__.py +0 -0
  66. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/delegates.py +0 -0
  67. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/item_repository.py +0 -0
  68. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/worker_broker.py +0 -0
  69. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/middleware/__init__.py +0 -0
  70. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/middleware/logging.py +0 -0
  71. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/py.typed +0 -0
  72. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/repositories/__init__.py +0 -0
  73. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/repositories/item_repository.py +0 -0
  74. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/settings.py +0 -0
  75. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/__init__.py +0 -0
  76. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/__init__.py +0 -0
  77. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/health_view.py +0 -0
  78. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/workers/__init__.py +0 -0
  79. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/workers/tasks.py +0 -0
  80. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/__init__.py +0 -0
  81. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/acceptance/test_logging_middleware.py +0 -0
  82. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/conftest.py +0 -0
  83. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/test_items.py +0 -0
  84. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/unit/test_cache.py +0 -0
  85. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/unit/test_database_settings.py +0 -0
  86. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/unit/test_delegates.py +0 -0
  87. {csrd_utils-0.3.17 → csrd_utils-0.3.19}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/unit/test_worker_broker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: csrd-utils
3
- Version: 0.3.17
3
+ Version: 0.3.19
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
@@ -51,7 +51,7 @@ csrd audit --service . # explicitly audit one servi
51
51
  - Non-interactive generation errors when no workspace marker is present.
52
52
  - Workspace cluster source of truth is persisted at `.csrd/cluster.yaml`.
53
53
  - If `.csrd/cluster.yaml` already exists, `csrd new service` appends the generated service and refreshes scaffold files (`docker-compose.yml`, `.env.example`, `README.md`, `smoke.sh`). Service source directories and running containers are never touched.
54
- - If `.csrd/cluster.yaml` is missing and a second service is added to the workspace, `csrd new service` bootstraps `.csrd/cluster.yaml` and writes initial cluster scaffold files.
54
+ - If `.csrd/cluster.yaml` is missing, `csrd new service` bootstraps `.csrd/cluster.yaml` and writes initial cluster scaffold files.
55
55
  - `csrd new service` runs a compact interactive flow by default (core prompts 1-6), then asks whether to show advanced options.
56
56
  - `csrd new workspace` creates a workspace directory and prompts whether to generate `none`, `service`, or `cluster` immediately.
57
57
  - In compact mode, advanced options are hidden by default.
@@ -70,19 +70,19 @@ csrd new workspace --name local-dev --output . --generate none
70
70
  csrd new service --name inventory --output ./local-dev --database postgres --workers
71
71
 
72
72
  # 1) Verify service compatibility
73
- csrd doctor --service ./local-dev/inventory
73
+ csrd doctor --service ./local-dev/src/inventory
74
74
 
75
75
  # 1b) Audit for weak/insecure defaults
76
- csrd audit --service ./local-dev/inventory
76
+ csrd audit --service ./local-dev/src/inventory
77
77
 
78
78
  # 2) Inspect available bundled features
79
79
  csrd feature list
80
80
 
81
81
  # 3) Dry-run a feature
82
- csrd feature plan workers --service ./local-dev/inventory
82
+ csrd feature plan workers --service ./local-dev/src/inventory
83
83
 
84
84
  # 4) Apply feature files/merges
85
- csrd feature add workers --service ./local-dev/inventory
85
+ csrd feature add workers --service ./local-dev/src/inventory
86
86
 
87
87
  # 5) Add a second service — workspace auto-upgrades to cluster
88
88
  csrd new service --name pricing --output ./local-dev --database postgres
@@ -135,8 +135,8 @@ Then run a clean smoke flow:
135
135
  mkdir -p /tmp/csrd-smoke
136
136
  csrd new workspace --name ws --output /tmp/csrd-smoke --generate none
137
137
  csrd new service --name demo-svc --output /tmp/csrd-smoke/ws --no-interactive
138
- csrd doctor --service /tmp/csrd-smoke/ws/demo-svc
139
- csrd audit --service /tmp/csrd-smoke/ws/demo-svc
140
- csrd feature plan workers --service /tmp/csrd-smoke/ws/demo-svc
141
- csrd feature add workers --service /tmp/csrd-smoke/ws/demo-svc
138
+ csrd doctor --service /tmp/csrd-smoke/ws/src/demo-svc
139
+ csrd audit --service /tmp/csrd-smoke/ws/src/demo-svc
140
+ csrd feature plan workers --service /tmp/csrd-smoke/ws/src/demo-svc
141
+ csrd feature add workers --service /tmp/csrd-smoke/ws/src/demo-svc
142
142
  ```
@@ -38,7 +38,7 @@ csrd audit --service . # explicitly audit one servi
38
38
  - Non-interactive generation errors when no workspace marker is present.
39
39
  - Workspace cluster source of truth is persisted at `.csrd/cluster.yaml`.
40
40
  - If `.csrd/cluster.yaml` already exists, `csrd new service` appends the generated service and refreshes scaffold files (`docker-compose.yml`, `.env.example`, `README.md`, `smoke.sh`). Service source directories and running containers are never touched.
41
- - If `.csrd/cluster.yaml` is missing and a second service is added to the workspace, `csrd new service` bootstraps `.csrd/cluster.yaml` and writes initial cluster scaffold files.
41
+ - If `.csrd/cluster.yaml` is missing, `csrd new service` bootstraps `.csrd/cluster.yaml` and writes initial cluster scaffold files.
42
42
  - `csrd new service` runs a compact interactive flow by default (core prompts 1-6), then asks whether to show advanced options.
43
43
  - `csrd new workspace` creates a workspace directory and prompts whether to generate `none`, `service`, or `cluster` immediately.
44
44
  - In compact mode, advanced options are hidden by default.
@@ -57,19 +57,19 @@ csrd new workspace --name local-dev --output . --generate none
57
57
  csrd new service --name inventory --output ./local-dev --database postgres --workers
58
58
 
59
59
  # 1) Verify service compatibility
60
- csrd doctor --service ./local-dev/inventory
60
+ csrd doctor --service ./local-dev/src/inventory
61
61
 
62
62
  # 1b) Audit for weak/insecure defaults
63
- csrd audit --service ./local-dev/inventory
63
+ csrd audit --service ./local-dev/src/inventory
64
64
 
65
65
  # 2) Inspect available bundled features
66
66
  csrd feature list
67
67
 
68
68
  # 3) Dry-run a feature
69
- csrd feature plan workers --service ./local-dev/inventory
69
+ csrd feature plan workers --service ./local-dev/src/inventory
70
70
 
71
71
  # 4) Apply feature files/merges
72
- csrd feature add workers --service ./local-dev/inventory
72
+ csrd feature add workers --service ./local-dev/src/inventory
73
73
 
74
74
  # 5) Add a second service — workspace auto-upgrades to cluster
75
75
  csrd new service --name pricing --output ./local-dev --database postgres
@@ -122,8 +122,8 @@ Then run a clean smoke flow:
122
122
  mkdir -p /tmp/csrd-smoke
123
123
  csrd new workspace --name ws --output /tmp/csrd-smoke --generate none
124
124
  csrd new service --name demo-svc --output /tmp/csrd-smoke/ws --no-interactive
125
- csrd doctor --service /tmp/csrd-smoke/ws/demo-svc
126
- csrd audit --service /tmp/csrd-smoke/ws/demo-svc
127
- csrd feature plan workers --service /tmp/csrd-smoke/ws/demo-svc
128
- csrd feature add workers --service /tmp/csrd-smoke/ws/demo-svc
125
+ csrd doctor --service /tmp/csrd-smoke/ws/src/demo-svc
126
+ csrd audit --service /tmp/csrd-smoke/ws/src/demo-svc
127
+ csrd feature plan workers --service /tmp/csrd-smoke/ws/src/demo-svc
128
+ csrd feature add workers --service /tmp/csrd-smoke/ws/src/demo-svc
129
129
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "csrd-utils"
3
- version = "0.3.17"
3
+ version = "0.3.19"
4
4
  description = "CLI utilities for csrd service generation and feature augmentation"
5
5
  license = { text = "MIT" }
6
6
  requires-python = ">=3.12"
@@ -61,6 +61,10 @@ WORKSPACE_MARKER = ".csrd-workspace"
61
61
  WORKSPACE_CLUSTER_SPEC = Path(".csrd") / "cluster.yaml"
62
62
 
63
63
 
64
+ def _workspace_services_dir(workspace_root: Path) -> Path:
65
+ return workspace_root / "src"
66
+
67
+
64
68
  def _prompt_text(label: str, default: str) -> str:
65
69
  value = input(f"{label} [{default}]: ").strip()
66
70
  return value or default
@@ -485,23 +489,55 @@ def _append_service_to_workspace_cluster(
485
489
 
486
490
 
487
491
  def _workspace_service_roots(workspace_root: Path) -> list[Path]:
488
- roots: list[Path] = []
489
- for child in workspace_root.iterdir():
490
- if not child.is_dir() or child.name.startswith("."):
491
- continue
492
- if (
493
- (child / "src" / "app").is_dir()
494
- and (child / "tests").is_dir()
495
- and (child / "requirements.txt").is_file()
496
- and (child / "docker-compose.yml").is_file()
497
- ):
498
- roots.append(child)
492
+ roots: set[Path] = set()
493
+
494
+ def _collect_from(base_dir: Path, skip_names: set[str] | None = None) -> None:
495
+ if not base_dir.is_dir():
496
+ return
497
+ for child in base_dir.iterdir():
498
+ if not child.is_dir() or child.name.startswith("."):
499
+ continue
500
+ if skip_names and child.name in skip_names:
501
+ continue
502
+ if (
503
+ (child / "src" / "app").is_dir()
504
+ and (child / "tests").is_dir()
505
+ and (child / "requirements.txt").is_file()
506
+ ):
507
+ roots.add(child.resolve())
508
+
509
+ services_root = _workspace_services_dir(workspace_root)
510
+ _collect_from(services_root)
511
+ # Legacy fallback: older workspaces stored services at workspace root.
512
+ _collect_from(workspace_root, skip_names={"src"})
499
513
  return sorted(roots)
500
514
 
501
515
 
502
- def _infer_port_from_service_root(service_root: Path) -> int:
516
+ def _resolve_feature_service_root(service_arg: Path) -> Path:
517
+ candidate = service_arg.resolve()
518
+ if not _is_workspace(candidate):
519
+ return candidate
520
+
521
+ service_roots = _workspace_service_roots(candidate)
522
+ if len(service_roots) == 1:
523
+ return service_roots[0]
524
+ if not service_roots:
525
+ raise ValueError("No services found in workspace. Pass --service with a service root path.")
526
+ raise ValueError(
527
+ "Multiple services found in workspace. Pass --service with a specific service root path."
528
+ )
529
+
530
+
531
+ def _workspace_root_for_service(service_root: Path) -> Path | None:
532
+ parent = service_root.parent
533
+ if parent.name == "src" and _is_workspace(parent.parent):
534
+ return parent.parent
535
+ return None
536
+
537
+
538
+ def _infer_port_from_compose(compose_path: Path, service_name: str | None = None) -> int:
503
539
  try:
504
- payload = yaml.safe_load((service_root / "docker-compose.yml").read_text(encoding="utf-8"))
540
+ payload = yaml.safe_load(compose_path.read_text(encoding="utf-8"))
505
541
  except (OSError, yaml.YAMLError):
506
542
  return 8080
507
543
 
@@ -511,10 +547,15 @@ def _infer_port_from_service_root(service_root: Path) -> int:
511
547
  if not isinstance(services, dict) or not services:
512
548
  return 8080
513
549
 
514
- first_service = next(iter(services.values()))
515
- if not isinstance(first_service, dict):
550
+ selected: object
551
+ if service_name and service_name in services:
552
+ selected = services[service_name]
553
+ else:
554
+ selected = next(iter(services.values()))
555
+ if not isinstance(selected, dict):
516
556
  return 8080
517
- ports = first_service.get("ports")
557
+
558
+ ports = selected.get("ports")
518
559
  if not isinstance(ports, list) or not ports:
519
560
  return 8080
520
561
 
@@ -528,6 +569,17 @@ def _infer_port_from_service_root(service_root: Path) -> int:
528
569
  return 8080
529
570
 
530
571
 
572
+ def _infer_port_from_service_root(service_root: Path) -> int:
573
+ service_compose = service_root / "docker-compose.yml"
574
+ if service_compose.is_file():
575
+ return _infer_port_from_compose(service_compose)
576
+
577
+ workspace_root = _workspace_root_for_service(service_root)
578
+ if workspace_root is not None:
579
+ return _infer_port_from_compose(workspace_root / "docker-compose.yml", service_root.name)
580
+ return 8080
581
+
582
+
531
583
  def _infer_existing_workspace_service(service_root: Path) -> ServiceSpec:
532
584
  return {
533
585
  "name": service_root.name,
@@ -566,9 +618,6 @@ def _maybe_update_workspace_cluster(
566
618
  return generate_cluster(updated_spec, workspace_root, force=True, workspace_mode=True)
567
619
 
568
620
  service_roots = _workspace_service_roots(workspace_root)
569
- if len(service_roots) < 2:
570
- return None
571
-
572
621
  generated_name = generated_service.name
573
622
  services: list[ServiceSpec] = [_service_config_to_cluster_service(service_config)]
574
623
  for service_root in service_roots:
@@ -816,12 +865,13 @@ def main() -> int:
816
865
  if args.command == "new" and args.new_command == "service":
817
866
  try:
818
867
  service_config = _collect_new_service_config(args)
819
- service_config["output_dir"] = _ensure_workspace_for_output(
868
+ workspace_root = _ensure_workspace_for_output(
820
869
  service_config["output_dir"], allow_prompt=_service_is_interactive(args)
821
870
  )
871
+ service_config["output_dir"] = _workspace_services_dir(workspace_root)
822
872
  generated = generate_service(**service_config)
823
873
  generated_cluster = _maybe_update_workspace_cluster(
824
- service_config["output_dir"], service_config, generated
874
+ workspace_root, service_config, generated
825
875
  )
826
876
  if generated_cluster is not None:
827
877
  print(f"Refreshed cluster scaffold at: {generated_cluster}")
@@ -864,10 +914,15 @@ def main() -> int:
864
914
  generate_mode = workspace_config["generate"]
865
915
  if generate_mode == "service":
866
916
  service_config = _collect_new_service_config(
867
- _default_service_namespace(workspace_root)
917
+ _default_service_namespace(_workspace_services_dir(workspace_root))
868
918
  )
869
- service_config["output_dir"] = workspace_root
919
+ service_config["output_dir"] = _workspace_services_dir(workspace_root)
870
920
  generated_service = generate_service(**service_config)
921
+ generated_cluster = _maybe_update_workspace_cluster(
922
+ workspace_root, service_config, generated_service
923
+ )
924
+ if generated_cluster is not None:
925
+ print(f"Refreshed cluster scaffold at: {generated_cluster}")
871
926
  print(f"Generated service at: {generated_service}")
872
927
  elif generate_mode == "cluster":
873
928
  cluster_config = _collect_new_cluster_config(
@@ -901,8 +956,14 @@ def main() -> int:
901
956
  if args.feature_command == "list":
902
957
  return _list_features()
903
958
 
959
+ try:
960
+ resolved_service = _resolve_feature_service_root(args.service)
961
+ except ValueError as exc:
962
+ print(f"ERROR: {exc}")
963
+ return 1
964
+
904
965
  with features_path() as feature_lib:
905
- augmentor = ServiceAugmentor(args.service, feature_lib)
966
+ augmentor = ServiceAugmentor(resolved_service, feature_lib)
906
967
  if args.feature_command == "plan" and args.json:
907
968
  plan = augmentor.build_plan(args.feature)
908
969
  if plan is None:
@@ -911,7 +972,7 @@ def main() -> int:
911
972
  json.dumps(
912
973
  {
913
974
  "feature": args.feature,
914
- "service": str(args.service.resolve()),
975
+ "service": str(resolved_service),
915
976
  "modifies": plan["modifies"],
916
977
  "creates": plan["creates"],
917
978
  },
@@ -80,7 +80,7 @@ class ServiceAugmentor:
80
80
  True if valid
81
81
  """
82
82
  required_dirs = ["src/app", "tests"]
83
- required_files = ["docker-compose.yml", "requirements.txt"]
83
+ required_files = ["requirements.txt"]
84
84
 
85
85
  for d in required_dirs:
86
86
  if not (self.service_root / d).is_dir():
@@ -92,8 +92,24 @@ class ServiceAugmentor:
92
92
  print(f"❌ Missing required file: {f}")
93
93
  return False
94
94
 
95
+ if not self._docker_compose_path().is_file():
96
+ print("❌ Missing required file: docker-compose.yml")
97
+ return False
98
+
95
99
  return True
96
100
 
101
+ def _docker_compose_path(self) -> Path:
102
+ local = self.service_root / "docker-compose.yml"
103
+ if local.is_file():
104
+ return local
105
+
106
+ parent = self.service_root.parent
107
+ if parent.name == "src" and (parent.parent / ".csrd-workspace").is_file():
108
+ workspace_compose = parent.parent / "docker-compose.yml"
109
+ if workspace_compose.is_file():
110
+ return workspace_compose
111
+ return local
112
+
97
113
  def validate_requirements(self) -> list[str]:
98
114
  """
99
115
  Validate that feature requirements are met.
@@ -189,7 +205,7 @@ class ServiceAugmentor:
189
205
  return True # Not required
190
206
 
191
207
  try:
192
- dc_path = self.service_root / "docker-compose.yml"
208
+ dc_path = self._docker_compose_path()
193
209
  self.backup_file(dc_path)
194
210
 
195
211
  # Load YAMLs
@@ -20,7 +20,11 @@ def run_doctor(service_root: Path) -> DoctorReport:
20
20
  warnings: list[str] = []
21
21
 
22
22
  required_dirs = ["src/app", "tests"]
23
- required_files = ["docker-compose.yml", "requirements.txt"]
23
+ required_files = ["requirements.txt"]
24
+
25
+ compose_candidates = [root / "docker-compose.yml"]
26
+ if root.parent.name == "src" and (root.parent.parent / ".csrd-workspace").is_file():
27
+ compose_candidates.append(root.parent.parent / "docker-compose.yml")
24
28
 
25
29
  for rel in required_dirs:
26
30
  if not (root / rel).is_dir():
@@ -30,6 +34,9 @@ def run_doctor(service_root: Path) -> DoctorReport:
30
34
  if not (root / rel).is_file():
31
35
  errors.append(f"Missing required file: {rel}")
32
36
 
37
+ if not any(path.is_file() for path in compose_candidates):
38
+ errors.append("Missing required file: docker-compose.yml")
39
+
33
40
  marker_files = {
34
41
  "src/app/settings.py": "# [INSERT: fields]",
35
42
  "tests/conftest.py": "# [INSERT: fixtures]",
@@ -31,6 +31,33 @@ class Update{{ cookiecutter.model_name }}Request(BaseModel):
31
31
  name: str
32
32
 
33
33
 
34
+ class {{ cookiecutter.model_name }}Response(BaseModel):
35
+ id: str
36
+ name: str
37
+ created_at: str
38
+
39
+
40
+ class {{ cookiecutter.model_name }}ListResponse(BaseModel):
41
+ {{ cookiecutter.model_name_plural | lower | replace(' ', '_') }}: list[{{ cookiecutter.model_name }}Response]
42
+
43
+
44
+ class {{ cookiecutter.model_name }}UpdateResponse(BaseModel):
45
+ id: str
46
+ name: str
47
+ updated: bool
48
+
49
+
50
+ class {{ cookiecutter.model_name }}UpsertResponse(BaseModel):
51
+ id: str
52
+ name: str
53
+ rows_affected: int
54
+
55
+
56
+ class {{ cookiecutter.model_name }}DeleteResponse(BaseModel):
57
+ id: str
58
+ deleted: bool
59
+
60
+
34
61
  {%- if cookiecutter.has_delegates and not cookiecutter.has_database %}
35
62
  async def _delegate_request(
36
63
  method: str,
@@ -59,13 +86,13 @@ def _dummy_item(record_id: str, name: str, created_at: str) -> dict[str, str]:
59
86
  {%- endif %}
60
87
 
61
88
 
62
- @router.post(ITEMS_ROUTE)
89
+ @router.post(ITEMS_ROUTE, response_model={{ cookiecutter.model_name }}Response)
63
90
  async def create_{{ cookiecutter.model_name | lower }}(
64
91
  payload: Create{{ cookiecutter.model_name }}Request,
65
92
  {%- if cookiecutter.has_database %}
66
93
  repo: RepositoryDep,
67
94
  {%- endif %}
68
- ) -> dict[str, str]:
95
+ ) -> {{ cookiecutter.model_name }}Response:
69
96
  {%- if cookiecutter.has_database %}
70
97
  return await repo.create(payload.name)
71
98
  {%- elif cookiecutter.has_delegates %}
@@ -84,12 +111,12 @@ async def create_{{ cookiecutter.model_name | lower }}(
84
111
  {%- endif %}
85
112
 
86
113
 
87
- @router.get(ITEMS_ROUTE)
114
+ @router.get(ITEMS_ROUTE, response_model={{ cookiecutter.model_name }}ListResponse)
88
115
  async def list_{{ cookiecutter.model_name_plural | lower | replace(' ', '_') }}(
89
116
  {%- if cookiecutter.has_database %}
90
117
  repo: RepositoryDep,
91
118
  {%- endif %}
92
- ) -> dict[str, list[dict[str, str]]]:
119
+ ) -> {{ cookiecutter.model_name }}ListResponse:
93
120
  {%- if cookiecutter.has_database %}
94
121
  return {"{{ cookiecutter.model_name_plural | lower | replace(' ', '_') }}": await repo.list()}
95
122
  {%- elif cookiecutter.has_delegates %}
@@ -104,13 +131,13 @@ async def list_{{ cookiecutter.model_name_plural | lower | replace(' ', '_') }}(
104
131
  {%- endif %}
105
132
 
106
133
 
107
- @router.get(f"{ITEMS_ROUTE}" + "/{entity_id}")
134
+ @router.get(f"{ITEMS_ROUTE}" + "/{entity_id}", response_model={{ cookiecutter.model_name }}Response)
108
135
  async def get_{{ cookiecutter.model_name | lower }}(
109
136
  entity_id: str,
110
137
  {%- if cookiecutter.has_database %}
111
138
  repo: RepositoryDep,
112
139
  {%- endif %}
113
- ) -> dict[str, str]:
140
+ ) -> {{ cookiecutter.model_name }}Response:
114
141
  {%- if cookiecutter.has_database %}
115
142
  item = await repo.get(entity_id)
116
143
  {%- elif cookiecutter.has_delegates %}
@@ -128,14 +155,16 @@ async def get_{{ cookiecutter.model_name | lower }}(
128
155
  return item
129
156
 
130
157
 
131
- @router.put(f"{ITEMS_ROUTE}" + "/{entity_id}")
158
+ @router.put(
159
+ f"{ITEMS_ROUTE}" + "/{entity_id}", response_model={{ cookiecutter.model_name }}UpdateResponse
160
+ )
132
161
  async def update_{{ cookiecutter.model_name | lower }}(
133
162
  entity_id: str,
134
163
  payload: Update{{ cookiecutter.model_name }}Request,
135
164
  {%- if cookiecutter.has_database %}
136
165
  repo: RepositoryDep,
137
166
  {%- endif %}
138
- ) -> dict[str, object]:
167
+ ) -> {{ cookiecutter.model_name }}UpdateResponse:
139
168
  {%- if cookiecutter.has_database %}
140
169
  rows = await repo.update(entity_id, payload.name)
141
170
  {%- elif cookiecutter.has_delegates %}
@@ -161,14 +190,17 @@ async def update_{{ cookiecutter.model_name | lower }}(
161
190
  return {"id": entity_id, "name": payload.name, "updated": True}
162
191
 
163
192
 
164
- @router.put(f"{ITEMS_ROUTE}" + "/{entity_id}/upsert")
193
+ @router.put(
194
+ f"{ITEMS_ROUTE}" + "/{entity_id}/upsert",
195
+ response_model={{ cookiecutter.model_name }}UpsertResponse,
196
+ )
165
197
  async def upsert_{{ cookiecutter.model_name | lower }}(
166
198
  entity_id: str,
167
199
  payload: Update{{ cookiecutter.model_name }}Request,
168
200
  {%- if cookiecutter.has_database %}
169
201
  repo: RepositoryDep,
170
202
  {%- endif %}
171
- ) -> dict[str, object]:
203
+ ) -> {{ cookiecutter.model_name }}UpsertResponse:
172
204
  {%- if cookiecutter.has_database %}
173
205
  rows = await repo.upsert(entity_id, payload.name)
174
206
  {%- elif cookiecutter.has_delegates %}
@@ -194,13 +226,13 @@ async def upsert_{{ cookiecutter.model_name | lower }}(
194
226
  return {"id": entity_id, "name": payload.name, "rows_affected": rows}
195
227
 
196
228
 
197
- @router.delete(f"{ITEMS_ROUTE}" + "/{entity_id}")
229
+ @router.delete(f"{ITEMS_ROUTE}" + "/{entity_id}", response_model={{ cookiecutter.model_name }}DeleteResponse)
198
230
  async def delete_{{ cookiecutter.model_name | lower }}(
199
231
  entity_id: str,
200
232
  {%- if cookiecutter.has_database %}
201
233
  repo: RepositoryDep,
202
234
  {%- endif %}
203
- ) -> dict[str, object]:
235
+ ) -> {{ cookiecutter.model_name }}DeleteResponse:
204
236
  {%- if cookiecutter.has_database %}
205
237
  rows = await repo.delete(entity_id)
206
238
  {%- elif cookiecutter.has_delegates %}
File without changes