csrd-utils 0.3.2__tar.gz → 0.3.4__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 (56) hide show
  1. csrd_utils-0.3.4/PKG-INFO +113 -0
  2. csrd_utils-0.3.4/README.md +100 -0
  3. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/pyproject.toml +2 -1
  4. csrd_utils-0.3.4/src/csrd_utils/__init__.py +7 -0
  5. csrd_utils-0.3.4/src/csrd_utils/__main__.py +330 -0
  6. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/augmentor.py +13 -0
  7. csrd_utils-0.3.4/src/csrd_utils/doctor.py +46 -0
  8. csrd_utils-0.3.4/src/csrd_utils/features/workers/settings.fragment.py +3 -0
  9. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/fragments.py +2 -0
  10. csrd_utils-0.3.4/src/csrd_utils/generator.py +107 -0
  11. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/settings.py +2 -0
  12. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/conftest.py +3 -0
  13. csrd_utils-0.3.2/PKG-INFO +0 -46
  14. csrd_utils-0.3.2/README.md +0 -34
  15. csrd_utils-0.3.2/src/csrd_utils/__init__.py +0 -5
  16. csrd_utils-0.3.2/src/csrd_utils/__main__.py +0 -55
  17. csrd_utils-0.3.2/src/csrd_utils/features/workers/settings.fragment.py +0 -9
  18. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/.gitignore +0 -0
  19. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/features/workers/conftest.fragment.py +0 -0
  20. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/features/workers/docker-compose.fragment.yaml +0 -0
  21. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/features/workers/manifest.yaml +0 -0
  22. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/features/workers/requirements.fragment +0 -0
  23. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/features/workers/src/app/workers/__init__.py +0 -0
  24. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/features/workers/src/app/workers/celery_app.py +0 -0
  25. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/features/workers/src/app/workers/tasks.py +0 -0
  26. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/features/workers/tests/test_workers.py +0 -0
  27. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/resources.py +0 -0
  28. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/README.md +0 -0
  29. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/cookiecutter.json +0 -0
  30. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/hooks/post_gen_project.py +0 -0
  31. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.dockerignore +0 -0
  32. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.env.example +0 -0
  33. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/Dockerfile +0 -0
  34. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/README.md +0 -0
  35. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/docker-compose.yml +0 -0
  36. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/requirements.txt +0 -0
  37. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/__init__.py +0 -0
  38. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/cache.py +0 -0
  39. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/delegates/__init__.py +0 -0
  40. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/delegates/upstreams.py +0 -0
  41. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/__init__.py +0 -0
  42. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/delegates.py +0 -0
  43. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/item_repository.py +0 -0
  44. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/worker_broker.py +0 -0
  45. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/middleware/__init__.py +0 -0
  46. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/middleware/logging.py +0 -0
  47. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/repositories/__init__.py +0 -0
  48. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/repositories/item_repository.py +0 -0
  49. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/__init__.py +0 -0
  50. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/__init__.py +0 -0
  51. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/health_view.py +0 -0
  52. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/items_view.py +0 -0
  53. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/workers/__init__.py +0 -0
  54. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/workers/tasks.py +0 -0
  55. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/__init__.py +0 -0
  56. {csrd_utils-0.3.2 → csrd_utils-0.3.4}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/test_items.py +0 -0
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: csrd-utils
3
+ Version: 0.3.4
4
+ Summary: CLI utilities for csrd service generation and feature augmentation
5
+ Project-URL: Repository, https://github.com/csrd-api/fastapi-common
6
+ Project-URL: Documentation, https://github.com/csrd-api/fastapi-common/tree/main/packages/utils
7
+ Project-URL: Changelog, https://github.com/csrd-api/fastapi-common/blob/main/CHANGELOG.md
8
+ License: MIT
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: cookiecutter<3,>=2.6
11
+ Requires-Dist: pyyaml<7,>=6
12
+ Description-Content-Type: text/markdown
13
+
14
+ # csrd-utils
15
+
16
+ CLI and runtime helpers for generating csrd services and augmenting existing services with optional features.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install "csrd-utils @ git+https://github.com/csrd-api/fastapi-common.git#subdirectory=packages/utils"
22
+ ```
23
+
24
+ ## CLI
25
+
26
+ ```bash
27
+ csrd --help
28
+ csrd new service # interactive prompts
29
+ csrd new service --interactive --name items # pre-fills name prompt with 'items'
30
+ csrd new service --name inventory --no-interactive --output .
31
+ csrd feature list
32
+ csrd feature plan workers --service .
33
+ csrd feature plan workers --service . --json
34
+ csrd feature add workers --service .
35
+ csrd doctor --service .
36
+ ```
37
+
38
+ `feature plan --json` is useful for CI or tooling wrappers.
39
+
40
+ ### Interactive generation behavior
41
+
42
+ - `csrd new service` runs interactive prompts by default.
43
+ - Passing `--name <value>` in interactive mode uses that value as the name prompt default.
44
+ - You can still overwrite the default by entering a different value at the prompt.
45
+ - Use `--no-interactive` for fully flag-driven generation.
46
+
47
+ ## Typical workflow
48
+
49
+ ```bash
50
+ # 0) Generate a new service from bundled template
51
+ csrd new service --name inventory --output . --database postgres --workers
52
+
53
+ # 1) Verify service compatibility
54
+ csrd doctor --service ./inventory
55
+
56
+ # 2) Inspect available bundled features
57
+ csrd feature list
58
+
59
+ # 3) Dry-run a feature
60
+ csrd feature plan workers --service ./inventory
61
+
62
+ # 4) Apply feature files/merges
63
+ csrd feature add workers --service ./inventory
64
+ ```
65
+
66
+ ## Bundled assets
67
+
68
+ - `features/workers/` feature manifests and fragments
69
+ - `templates/cookiecutter-service/` template starter
70
+
71
+ ## Python API
72
+
73
+ ```python
74
+ from pathlib import Path
75
+ from csrd_utils import ServiceAugmentor
76
+ from csrd_utils.resources import features_path
77
+
78
+ with features_path() as feature_lib:
79
+ augmentor = ServiceAugmentor(Path("."), feature_lib)
80
+ ok, changes = augmentor.add_feature("workers", plan=True)
81
+ ```
82
+
83
+ ## Documentation
84
+
85
+ - `docs/CLI_REFERENCE.md` - full command/flag reference
86
+ - `docs/FEATURES.md` - bundled feature catalog and behavior
87
+ - `docs/AGENT_SMOKE_TESTS.md` - copy/paste validation flow for clean workspaces
88
+ - `docs/COMMAND_MATRIX.yaml` - machine-readable command matrix for automation
89
+ - `AGENTS.md` - package-local rules for autonomous agents
90
+
91
+ ## CLI-only agent mode
92
+
93
+ If an agent can only see the installed CLI (not source files), use this exploration sequence:
94
+
95
+ ```bash
96
+ csrd --help
97
+ csrd new --help
98
+ csrd feature --help
99
+ csrd doctor --help
100
+ csrd feature list
101
+ ```
102
+
103
+ Then run a clean smoke flow:
104
+
105
+ ```bash
106
+ mkdir -p /tmp/csrd-smoke
107
+ cd /tmp/csrd-smoke
108
+ csrd new service --name demo-svc --output . --no-interactive
109
+ cd demo-svc
110
+ csrd doctor
111
+ csrd feature plan workers
112
+ csrd feature add workers
113
+ ```
@@ -0,0 +1,100 @@
1
+ # csrd-utils
2
+
3
+ CLI and runtime helpers for generating csrd services and augmenting existing services with optional features.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install "csrd-utils @ git+https://github.com/csrd-api/fastapi-common.git#subdirectory=packages/utils"
9
+ ```
10
+
11
+ ## CLI
12
+
13
+ ```bash
14
+ csrd --help
15
+ csrd new service # interactive prompts
16
+ csrd new service --interactive --name items # pre-fills name prompt with 'items'
17
+ csrd new service --name inventory --no-interactive --output .
18
+ csrd feature list
19
+ csrd feature plan workers --service .
20
+ csrd feature plan workers --service . --json
21
+ csrd feature add workers --service .
22
+ csrd doctor --service .
23
+ ```
24
+
25
+ `feature plan --json` is useful for CI or tooling wrappers.
26
+
27
+ ### Interactive generation behavior
28
+
29
+ - `csrd new service` runs interactive prompts by default.
30
+ - Passing `--name <value>` in interactive mode uses that value as the name prompt default.
31
+ - You can still overwrite the default by entering a different value at the prompt.
32
+ - Use `--no-interactive` for fully flag-driven generation.
33
+
34
+ ## Typical workflow
35
+
36
+ ```bash
37
+ # 0) Generate a new service from bundled template
38
+ csrd new service --name inventory --output . --database postgres --workers
39
+
40
+ # 1) Verify service compatibility
41
+ csrd doctor --service ./inventory
42
+
43
+ # 2) Inspect available bundled features
44
+ csrd feature list
45
+
46
+ # 3) Dry-run a feature
47
+ csrd feature plan workers --service ./inventory
48
+
49
+ # 4) Apply feature files/merges
50
+ csrd feature add workers --service ./inventory
51
+ ```
52
+
53
+ ## Bundled assets
54
+
55
+ - `features/workers/` feature manifests and fragments
56
+ - `templates/cookiecutter-service/` template starter
57
+
58
+ ## Python API
59
+
60
+ ```python
61
+ from pathlib import Path
62
+ from csrd_utils import ServiceAugmentor
63
+ from csrd_utils.resources import features_path
64
+
65
+ with features_path() as feature_lib:
66
+ augmentor = ServiceAugmentor(Path("."), feature_lib)
67
+ ok, changes = augmentor.add_feature("workers", plan=True)
68
+ ```
69
+
70
+ ## Documentation
71
+
72
+ - `docs/CLI_REFERENCE.md` - full command/flag reference
73
+ - `docs/FEATURES.md` - bundled feature catalog and behavior
74
+ - `docs/AGENT_SMOKE_TESTS.md` - copy/paste validation flow for clean workspaces
75
+ - `docs/COMMAND_MATRIX.yaml` - machine-readable command matrix for automation
76
+ - `AGENTS.md` - package-local rules for autonomous agents
77
+
78
+ ## CLI-only agent mode
79
+
80
+ If an agent can only see the installed CLI (not source files), use this exploration sequence:
81
+
82
+ ```bash
83
+ csrd --help
84
+ csrd new --help
85
+ csrd feature --help
86
+ csrd doctor --help
87
+ csrd feature list
88
+ ```
89
+
90
+ Then run a clean smoke flow:
91
+
92
+ ```bash
93
+ mkdir -p /tmp/csrd-smoke
94
+ cd /tmp/csrd-smoke
95
+ csrd new service --name demo-svc --output . --no-interactive
96
+ cd demo-svc
97
+ csrd doctor
98
+ csrd feature plan workers
99
+ csrd feature add workers
100
+ ```
@@ -1,10 +1,11 @@
1
1
  [project]
2
2
  name = "csrd-utils"
3
- version = "0.3.2"
3
+ version = "0.3.4"
4
4
  description = "CLI utilities for csrd service generation and feature augmentation"
5
5
  license = { text = "MIT" }
6
6
  requires-python = ">=3.12"
7
7
  dependencies = [
8
+ "cookiecutter>=2.6,<3",
8
9
  "PyYAML>=6,<7",
9
10
  ]
10
11
  readme = "README.md"
@@ -0,0 +1,7 @@
1
+ """csrd-utils: template and feature tooling for csrd services."""
2
+
3
+ from .augmentor import ServiceAugmentor
4
+ from .doctor import run_doctor
5
+ from .generator import generate_service
6
+
7
+ __all__ = ["ServiceAugmentor", "generate_service", "run_doctor"]
@@ -0,0 +1,330 @@
1
+ """Command-line interface for csrd-utils."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+ from typing import TypedDict
9
+
10
+ from .augmentor import ServiceAugmentor
11
+ from .doctor import run_doctor
12
+ from .generator import generate_service
13
+ from .resources import features_path
14
+
15
+
16
+ class GenerateServiceArgs(TypedDict):
17
+ name: str
18
+ output_dir: Path
19
+ description: str
20
+ port: int
21
+ model_name: str
22
+ model_name_plural: str
23
+ has_database: bool
24
+ database: str
25
+ has_delegates: bool
26
+ upstreams: str
27
+ has_workers: bool
28
+ broker: str
29
+ has_caching: bool
30
+ cache_backend: str
31
+ has_structured_logging: bool
32
+ overwrite_if_exists: bool
33
+
34
+
35
+ def _prompt_text(label: str, default: str) -> str:
36
+ value = input(f"{label} [{default}]: ").strip()
37
+ return value or default
38
+
39
+
40
+ def _prompt_bool(label: str, default: bool) -> bool:
41
+ suffix = "Y/n" if default else "y/N"
42
+ value = input(f"{label} ({suffix}): ").strip().lower()
43
+ if not value:
44
+ return default
45
+ return value in {"y", "yes", "true", "1"}
46
+
47
+
48
+ def _prompt_choice(label: str, choices: list[str], default: str) -> str:
49
+ rendered = ", ".join(choices)
50
+ while True:
51
+ value = input(f"{label} [{default}] ({rendered}): ").strip().lower()
52
+ if not value:
53
+ return default
54
+ if value in choices:
55
+ return value
56
+ print(f"ERROR: Invalid choice '{value}'. Expected one of: {rendered}")
57
+
58
+
59
+ def _prompt_int(label: str, default: int) -> int:
60
+ while True:
61
+ value = input(f"{label} [{default}]: ").strip()
62
+ if not value:
63
+ return default
64
+ try:
65
+ return int(value)
66
+ except ValueError:
67
+ print("ERROR: Enter a valid integer")
68
+
69
+
70
+ def _collect_new_service_config(args: argparse.Namespace) -> GenerateServiceArgs:
71
+ explicit_interactive = getattr(args, "interactive", None)
72
+ interactive = (
73
+ bool(explicit_interactive) if explicit_interactive is not None else args.name is None
74
+ )
75
+
76
+ # Non-interactive mode keeps current flag-driven behavior.
77
+ if not interactive:
78
+ has_database = args.database != "none"
79
+ selected_database = "sqlite" if not has_database else args.database
80
+ return GenerateServiceArgs(
81
+ {
82
+ "name": args.name,
83
+ "output_dir": args.output,
84
+ "description": args.description,
85
+ "port": args.port,
86
+ "model_name": args.model_name,
87
+ "model_name_plural": args.model_name_plural,
88
+ "has_database": has_database,
89
+ "database": selected_database,
90
+ "has_delegates": args.delegates,
91
+ "upstreams": args.upstreams,
92
+ "has_workers": args.workers,
93
+ "broker": args.broker,
94
+ "has_caching": args.caching,
95
+ "cache_backend": args.cache_backend,
96
+ "has_structured_logging": args.structured_logging,
97
+ "overwrite_if_exists": args.force,
98
+ }
99
+ )
100
+
101
+ print("Interactive mode: press Enter to accept defaults.")
102
+
103
+ name = _prompt_text("Service name", args.name or "my-service")
104
+ output_dir = Path(_prompt_text("Output directory", str(args.output)))
105
+ description = _prompt_text("Service description", args.description)
106
+ port = _prompt_int("HTTP port", args.port)
107
+ model_name = _prompt_text("Model name", args.model_name)
108
+
109
+ plural_default = args.model_name_plural
110
+ if plural_default == "items" and model_name != "Item":
111
+ plural_default = f"{model_name.lower()}s"
112
+ model_name_plural = _prompt_text("Model name plural", plural_default)
113
+
114
+ database = _prompt_choice("Database", ["sqlite", "maria", "postgres", "none"], args.database)
115
+ has_database = database != "none"
116
+ selected_database = "sqlite" if not has_database else database
117
+
118
+ has_delegates = _prompt_bool("Enable delegates", args.delegates)
119
+ upstreams_default = args.upstreams
120
+ if has_delegates and not upstreams_default:
121
+ upstreams_default = "http://service-a:8080"
122
+ upstreams = (
123
+ _prompt_text("Delegates upstreams (comma-separated)", upstreams_default)
124
+ if has_delegates
125
+ else ""
126
+ )
127
+
128
+ has_workers = _prompt_bool("Enable workers", args.workers)
129
+ broker = (
130
+ _prompt_choice("Worker broker", ["redis", "rabbitmq"], args.broker)
131
+ if has_workers
132
+ else args.broker
133
+ )
134
+
135
+ has_caching = _prompt_bool("Enable caching", args.caching)
136
+ cache_backend = (
137
+ _prompt_choice("Cache backend", ["redis"], args.cache_backend)
138
+ if has_caching
139
+ else args.cache_backend
140
+ )
141
+
142
+ has_structured_logging = _prompt_bool("Enable structured logging", args.structured_logging)
143
+ overwrite_if_exists = _prompt_bool("Overwrite if target exists", args.force)
144
+
145
+ return GenerateServiceArgs(
146
+ {
147
+ "name": name,
148
+ "output_dir": output_dir,
149
+ "description": description,
150
+ "port": port,
151
+ "model_name": model_name,
152
+ "model_name_plural": model_name_plural,
153
+ "has_database": has_database,
154
+ "database": selected_database,
155
+ "has_delegates": has_delegates,
156
+ "upstreams": upstreams,
157
+ "has_workers": has_workers,
158
+ "broker": broker,
159
+ "has_caching": has_caching,
160
+ "cache_backend": cache_backend,
161
+ "has_structured_logging": has_structured_logging,
162
+ "overwrite_if_exists": overwrite_if_exists,
163
+ }
164
+ )
165
+
166
+
167
+ def _build_parser() -> argparse.ArgumentParser:
168
+ parser = argparse.ArgumentParser(prog="csrd", description="csrd utilities")
169
+ sub = parser.add_subparsers(dest="command")
170
+
171
+ feature = sub.add_parser("feature", help="Feature operations")
172
+ feature_sub = feature.add_subparsers(dest="feature_command")
173
+
174
+ list_cmd = feature_sub.add_parser("list", help="List bundled features")
175
+ list_cmd.set_defaults(plan=False)
176
+
177
+ for name, plan in (("add", False), ("plan", True)):
178
+ cmd = feature_sub.add_parser(name, help=f"{name} feature")
179
+ cmd.add_argument("feature", help="Feature name, e.g. workers")
180
+ cmd.add_argument("--service", type=Path, default=Path.cwd(), help="Service root path")
181
+ if name == "plan":
182
+ cmd.add_argument("--json", action="store_true", help="Print plan as JSON")
183
+ cmd.set_defaults(plan=plan)
184
+
185
+ doctor = sub.add_parser("doctor", help="Validate service compatibility")
186
+ doctor.add_argument("--service", type=Path, default=Path.cwd(), help="Service root path")
187
+ doctor.add_argument("--json", action="store_true", help="Print doctor report as JSON")
188
+
189
+ new = sub.add_parser("new", help="Generate new assets")
190
+ new_sub = new.add_subparsers(dest="new_command")
191
+ service = new_sub.add_parser("service", help="Generate a service from bundled template")
192
+ service.add_argument("--name", help="Service name")
193
+ service.add_argument("--output", type=Path, default=Path.cwd(), help="Output directory")
194
+ service.add_argument(
195
+ "--description",
196
+ default="A microservice using the csrd library stack",
197
+ help="Service description",
198
+ )
199
+ service.add_argument("--port", type=int, default=8080, help="HTTP service port")
200
+ service.add_argument("--model-name", default="Item", help="Primary model name")
201
+ service.add_argument("--model-name-plural", default="items", help="Plural model name")
202
+ service.add_argument(
203
+ "--database", choices=["sqlite", "maria", "postgres", "none"], default="sqlite"
204
+ )
205
+ service.add_argument("--delegates", action="store_true", help="Enable delegates")
206
+ service.add_argument("--upstreams", default="", help="Comma-separated upstream URLs")
207
+ service.add_argument("--workers", action="store_true", help="Enable workers")
208
+ service.add_argument("--broker", choices=["redis", "rabbitmq"], default="redis")
209
+ service.add_argument("--caching", action="store_true", help="Enable caching")
210
+ service.add_argument("--cache-backend", choices=["redis"], default="redis")
211
+ service.add_argument(
212
+ "--structured-logging",
213
+ dest="structured_logging",
214
+ action="store_true",
215
+ default=True,
216
+ help="Enable structured logging",
217
+ )
218
+ service.add_argument(
219
+ "--no-structured-logging",
220
+ dest="structured_logging",
221
+ action="store_false",
222
+ help="Disable structured logging",
223
+ )
224
+ service.add_argument("--interactive", dest="interactive", action="store_true", default=None)
225
+ service.add_argument("--no-interactive", dest="interactive", action="store_false")
226
+ service.add_argument("--force", action="store_true", help="Overwrite existing generated path")
227
+
228
+ return parser
229
+
230
+
231
+ def _list_features() -> int:
232
+ with features_path() as feature_lib:
233
+ names = sorted(
234
+ path.name
235
+ for path in feature_lib.iterdir()
236
+ if path.is_dir() and (path / "manifest.yaml").exists()
237
+ )
238
+ if not names:
239
+ print("No bundled features found.")
240
+ return 1
241
+ for name in names:
242
+ print(name)
243
+ return 0
244
+
245
+
246
+ def _run_doctor(service: Path, as_json: bool) -> int:
247
+ report = run_doctor(service)
248
+ payload = {
249
+ "ok": report.ok,
250
+ "errors": report.errors,
251
+ "warnings": report.warnings,
252
+ }
253
+ if as_json:
254
+ print(json.dumps(payload, indent=2, sort_keys=True))
255
+ else:
256
+ if report.ok:
257
+ print("Doctor check passed.")
258
+ else:
259
+ print("Doctor check failed.")
260
+ for err in report.errors:
261
+ print(f"ERROR: {err}")
262
+ for warning in report.warnings:
263
+ print(f"WARN: {warning}")
264
+ return 0 if report.ok else 1
265
+
266
+
267
+ def main() -> int:
268
+ parser = _build_parser()
269
+ args = parser.parse_args()
270
+
271
+ if args.command == "new" and args.new_command == "service":
272
+ try:
273
+ config = _collect_new_service_config(args)
274
+ generated = generate_service(**config)
275
+ except (FileExistsError, ValueError) as exc:
276
+ print(f"ERROR: {exc}")
277
+ return 1
278
+ except KeyboardInterrupt:
279
+ print("\nCancelled.")
280
+ return 1
281
+ print(f"Generated service at: {generated}")
282
+ return 0
283
+
284
+ if args.command == "doctor":
285
+ return _run_doctor(args.service, args.json)
286
+
287
+ if args.command != "feature" or args.feature_command not in {"add", "plan", "list"}:
288
+ parser.print_help()
289
+ return 0
290
+
291
+ if args.feature_command == "list":
292
+ return _list_features()
293
+
294
+ with features_path() as feature_lib:
295
+ augmentor = ServiceAugmentor(args.service, feature_lib)
296
+ if args.feature_command == "plan" and args.json:
297
+ plan = augmentor.build_plan(args.feature)
298
+ if plan is None:
299
+ return 1
300
+ print(
301
+ json.dumps(
302
+ {
303
+ "feature": args.feature,
304
+ "service": str(args.service.resolve()),
305
+ "modifies": plan["modifies"],
306
+ "creates": plan["creates"],
307
+ },
308
+ indent=2,
309
+ sort_keys=True,
310
+ )
311
+ )
312
+ return 0
313
+ success, changes = augmentor.add_feature(args.feature, plan=args.plan)
314
+
315
+ if not success:
316
+ return 1
317
+
318
+ if args.plan:
319
+ print("Plan validated.")
320
+ else:
321
+ print(f"Feature '{args.feature}' added.")
322
+ if changes:
323
+ for change in changes:
324
+ print(f"- {change}")
325
+
326
+ return 0
327
+
328
+
329
+ if __name__ == "__main__":
330
+ raise SystemExit(main())
@@ -387,6 +387,7 @@ class ServiceAugmentor:
387
387
  tuple[bool, list[str]]
388
388
  (success, list of changes applied)
389
389
  """
390
+ self.applied_changes = []
390
391
  print(f"\n📦 Adding feature: {feature_name}")
391
392
  print()
392
393
 
@@ -430,8 +431,19 @@ class ServiceAugmentor:
430
431
  all_success &= self.create_feature_files(feature_name)
431
432
  all_success &= self.create_test_files(feature_name)
432
433
 
434
+ if not all_success:
435
+ self.rollback()
433
436
  return all_success, self.applied_changes
434
437
 
438
+ def build_plan(self, feature_name: str) -> dict[str, list[str]] | None:
439
+ """Return a structured dry-run plan for feature application."""
440
+ if not self.load_feature(feature_name):
441
+ return None
442
+ return {
443
+ "modifies": list(self.manifest.get("modifies", [])),
444
+ "creates": list(self.manifest.get("creates", [])),
445
+ }
446
+
435
447
  def rollback(self) -> None:
436
448
  """Restore all backups."""
437
449
  print("\n🔄 Rolling back changes...")
@@ -439,3 +451,4 @@ class ServiceAugmentor:
439
451
  shutil.copy2(backup_path, original_path)
440
452
  Path(backup_path).unlink()
441
453
  print(f" Restored {original_path}")
454
+ self.backups.clear()
@@ -0,0 +1,46 @@
1
+ """Preflight checks for services targeted by csrd-utils feature augmentation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass
10
+ class DoctorReport:
11
+ """Structured doctor output."""
12
+
13
+ ok: bool
14
+ errors: list[str] = field(default_factory=list)
15
+ warnings: list[str] = field(default_factory=list)
16
+
17
+
18
+ def run_doctor(service_root: Path) -> DoctorReport:
19
+ """Validate that a service is compatible with feature augmentation."""
20
+ root = service_root.resolve()
21
+ errors: list[str] = []
22
+ warnings: list[str] = []
23
+
24
+ required_dirs = ["src/app", "tests"]
25
+ required_files = ["docker-compose.yml", "requirements.txt"]
26
+
27
+ for rel in required_dirs:
28
+ if not (root / rel).is_dir():
29
+ errors.append(f"Missing required directory: {rel}")
30
+
31
+ for rel in required_files:
32
+ if not (root / rel).is_file():
33
+ errors.append(f"Missing required file: {rel}")
34
+
35
+ marker_files = {
36
+ "src/app/settings.py": "# [INSERT: fields]",
37
+ "tests/conftest.py": "# [INSERT: fixtures]",
38
+ }
39
+ for rel, marker in marker_files.items():
40
+ path = root / rel
41
+ if path.exists():
42
+ text = path.read_text(encoding="utf-8")
43
+ if marker not in text:
44
+ warnings.append(f"Optional insertion marker missing in {rel}: {marker}")
45
+
46
+ return DoctorReport(ok=not errors, errors=errors, warnings=warnings)
@@ -0,0 +1,3 @@
1
+ # [INSERT: fields]
2
+ celery_broker_url: str = "redis://localhost:6379/0"
3
+ celery_result_backend: str = "redis://localhost:6379/0"
@@ -170,6 +170,8 @@ class PythonFileMerger:
170
170
 
171
171
  # Insert fragment after the marker line
172
172
  fragment_lines = fragment_content.strip().split("\n")
173
+ if fragment_lines and re.match(r"^# \[INSERT:\s*\w+\]", fragment_lines[0]):
174
+ fragment_lines = fragment_lines[1:]
173
175
  for line in reversed(fragment_lines):
174
176
  lines.insert(insert_idx + 1, line)
175
177
 
@@ -0,0 +1,107 @@
1
+ """Service generation helpers backed by bundled cookiecutter templates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from cookiecutter.main import cookiecutter
9
+
10
+ from .resources import templates_path
11
+
12
+
13
+ def _build_context(
14
+ *,
15
+ name: str,
16
+ description: str,
17
+ port: int,
18
+ model_name: str,
19
+ model_name_plural: str,
20
+ has_database: bool,
21
+ database: str,
22
+ has_delegates: bool,
23
+ upstreams: str,
24
+ has_workers: bool,
25
+ broker: str,
26
+ has_caching: bool,
27
+ cache_backend: str,
28
+ has_structured_logging: bool,
29
+ ) -> dict[str, Any]:
30
+ return {
31
+ "service_name": name,
32
+ "service_description": description,
33
+ "port": str(port),
34
+ "model_name": model_name,
35
+ "model_name_plural": model_name_plural,
36
+ "has_database": has_database,
37
+ "database": database,
38
+ "has_delegates": has_delegates,
39
+ "upstreams": upstreams,
40
+ "has_workers": has_workers,
41
+ "broker": broker,
42
+ "has_caching": has_caching,
43
+ "cache_backend": cache_backend,
44
+ "has_structured_logging": has_structured_logging,
45
+ }
46
+
47
+
48
+ def generate_service(
49
+ *,
50
+ name: str,
51
+ output_dir: Path,
52
+ description: str,
53
+ port: int,
54
+ model_name: str,
55
+ model_name_plural: str,
56
+ has_database: bool,
57
+ database: str,
58
+ has_delegates: bool,
59
+ upstreams: str,
60
+ has_workers: bool,
61
+ broker: str,
62
+ has_caching: bool,
63
+ cache_backend: str,
64
+ has_structured_logging: bool,
65
+ overwrite_if_exists: bool,
66
+ ) -> Path:
67
+ """Generate a service from the bundled cookiecutter-service template."""
68
+ output_root = output_dir.resolve()
69
+ output_root.mkdir(parents=True, exist_ok=True)
70
+ target = output_root / name
71
+
72
+ if target.exists() and any(target.iterdir()) and not overwrite_if_exists:
73
+ raise FileExistsError(
74
+ f"Target directory already exists and is not empty: {target}. Use --force to overwrite."
75
+ )
76
+
77
+ if has_delegates and not upstreams.strip():
78
+ raise ValueError("--upstreams is required when --delegates is enabled")
79
+
80
+ context = _build_context(
81
+ name=name,
82
+ description=description,
83
+ port=port,
84
+ model_name=model_name,
85
+ model_name_plural=model_name_plural,
86
+ has_database=has_database,
87
+ database=database,
88
+ has_delegates=has_delegates,
89
+ upstreams=upstreams,
90
+ has_workers=has_workers,
91
+ broker=broker,
92
+ has_caching=has_caching,
93
+ cache_backend=cache_backend,
94
+ has_structured_logging=has_structured_logging,
95
+ )
96
+
97
+ with templates_path() as template_root:
98
+ template = template_root / "cookiecutter-service"
99
+ generated = cookiecutter(
100
+ str(template),
101
+ no_input=True,
102
+ extra_context=context,
103
+ output_dir=str(output_root),
104
+ overwrite_if_exists=overwrite_if_exists,
105
+ )
106
+
107
+ return Path(generated).resolve()
@@ -4,6 +4,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
4
4
  class Settings(BaseSettings):
5
5
  model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
6
6
 
7
+ # [INSERT: fields]
8
+
7
9
  app_name: str = "{{ cookiecutter.service_name }}"
8
10
  port: int = {{ cookiecutter.port }}
9
11
  has_structured_logging: bool = {{ "True" if cookiecutter.has_structured_logging else "False" }}
@@ -15,6 +15,9 @@ HAS_DATABASE = {{ "True" if cookiecutter.has_database else "False" }}
15
15
  DB_KIND = "{{ cookiecutter.database }}"
16
16
 
17
17
 
18
+ # [INSERT: fixtures]
19
+
20
+
18
21
  @pytest.fixture
19
22
  def client():
20
23
  if HAS_DATABASE and DB_KIND == "maria":
csrd_utils-0.3.2/PKG-INFO DELETED
@@ -1,46 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: csrd-utils
3
- Version: 0.3.2
4
- Summary: CLI utilities for csrd service generation and feature augmentation
5
- Project-URL: Repository, https://github.com/csrd-api/fastapi-common
6
- Project-URL: Documentation, https://github.com/csrd-api/fastapi-common/tree/main/packages/utils
7
- Project-URL: Changelog, https://github.com/csrd-api/fastapi-common/blob/main/CHANGELOG.md
8
- License: MIT
9
- Requires-Python: >=3.12
10
- Requires-Dist: pyyaml<7,>=6
11
- Description-Content-Type: text/markdown
12
-
13
- # csrd-utils
14
-
15
- CLI and runtime helpers for generating csrd services and augmenting existing services with optional features.
16
-
17
- ## Install
18
-
19
- ```bash
20
- pip install "csrd-utils @ git+https://github.com/csrd-api/fastapi-common.git#subdirectory=packages/utils"
21
- ```
22
-
23
- ## CLI
24
-
25
- ```bash
26
- csrd --help
27
- csrd feature plan workers --service .
28
- csrd feature add workers --service .
29
- ```
30
-
31
- ## Bundled assets
32
-
33
- - `features/workers/` feature manifests and fragments
34
- - `templates/cookiecutter-service/` template starter
35
-
36
- ## Python API
37
-
38
- ```python
39
- from pathlib import Path
40
- from csrd_utils import ServiceAugmentor
41
- from csrd_utils.resources import features_path
42
-
43
- with features_path() as feature_lib:
44
- augmentor = ServiceAugmentor(Path("."), feature_lib)
45
- ok, changes = augmentor.add_feature("workers", plan=True)
46
- ```
@@ -1,34 +0,0 @@
1
- # csrd-utils
2
-
3
- CLI and runtime helpers for generating csrd services and augmenting existing services with optional features.
4
-
5
- ## Install
6
-
7
- ```bash
8
- pip install "csrd-utils @ git+https://github.com/csrd-api/fastapi-common.git#subdirectory=packages/utils"
9
- ```
10
-
11
- ## CLI
12
-
13
- ```bash
14
- csrd --help
15
- csrd feature plan workers --service .
16
- csrd feature add workers --service .
17
- ```
18
-
19
- ## Bundled assets
20
-
21
- - `features/workers/` feature manifests and fragments
22
- - `templates/cookiecutter-service/` template starter
23
-
24
- ## Python API
25
-
26
- ```python
27
- from pathlib import Path
28
- from csrd_utils import ServiceAugmentor
29
- from csrd_utils.resources import features_path
30
-
31
- with features_path() as feature_lib:
32
- augmentor = ServiceAugmentor(Path("."), feature_lib)
33
- ok, changes = augmentor.add_feature("workers", plan=True)
34
- ```
@@ -1,5 +0,0 @@
1
- """csrd-utils: template and feature tooling for csrd services."""
2
-
3
- from .augmentor import ServiceAugmentor
4
-
5
- __all__ = ["ServiceAugmentor"]
@@ -1,55 +0,0 @@
1
- """Command-line interface for csrd-utils."""
2
-
3
- from __future__ import annotations
4
-
5
- import argparse
6
- from pathlib import Path
7
-
8
- from .augmentor import ServiceAugmentor
9
- from .resources import features_path
10
-
11
-
12
- def _build_parser() -> argparse.ArgumentParser:
13
- parser = argparse.ArgumentParser(prog="csrd", description="csrd utilities")
14
- sub = parser.add_subparsers(dest="command")
15
-
16
- feature = sub.add_parser("feature", help="Feature operations")
17
- feature_sub = feature.add_subparsers(dest="feature_command")
18
-
19
- for name, plan in (("add", False), ("plan", True)):
20
- cmd = feature_sub.add_parser(name, help=f"{name} feature")
21
- cmd.add_argument("feature", help="Feature name, e.g. workers")
22
- cmd.add_argument("--service", type=Path, default=Path.cwd(), help="Service root path")
23
- cmd.set_defaults(plan=plan)
24
-
25
- return parser
26
-
27
-
28
- def main() -> int:
29
- parser = _build_parser()
30
- args = parser.parse_args()
31
-
32
- if args.command != "feature" or args.feature_command not in {"add", "plan"}:
33
- parser.print_help()
34
- return 0
35
-
36
- with features_path() as feature_lib:
37
- augmentor = ServiceAugmentor(args.service, feature_lib)
38
- success, changes = augmentor.add_feature(args.feature, plan=args.plan)
39
-
40
- if not success:
41
- return 1
42
-
43
- if args.plan:
44
- print("Plan validated.")
45
- else:
46
- print(f"Feature '{args.feature}' added.")
47
- if changes:
48
- for change in changes:
49
- print(f"- {change}")
50
-
51
- return 0
52
-
53
-
54
- if __name__ == "__main__":
55
- raise SystemExit(main())
@@ -1,9 +0,0 @@
1
- # [INSERT: fields]
2
- celery_broker_url: str = Field( # noqa: F821
3
- default="redis://localhost:6379/0",
4
- description="Celery broker URL",
5
- )
6
- celery_result_backend: str = Field( # noqa: F821
7
- default="redis://localhost:6379/0",
8
- description="Celery result backend URL",
9
- )
File without changes