csrd-utils 0.3.2__tar.gz → 0.3.5__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.5/PKG-INFO +114 -0
  2. csrd_utils-0.3.5/README.md +101 -0
  3. {csrd_utils-0.3.2 → csrd_utils-0.3.5}/pyproject.toml +2 -1
  4. csrd_utils-0.3.5/src/csrd_utils/__init__.py +7 -0
  5. csrd_utils-0.3.5/src/csrd_utils/__main__.py +337 -0
  6. {csrd_utils-0.3.2 → csrd_utils-0.3.5}/src/csrd_utils/augmentor.py +13 -0
  7. csrd_utils-0.3.5/src/csrd_utils/doctor.py +46 -0
  8. csrd_utils-0.3.5/src/csrd_utils/features/workers/settings.fragment.py +3 -0
  9. {csrd_utils-0.3.2 → csrd_utils-0.3.5}/src/csrd_utils/fragments.py +2 -0
  10. csrd_utils-0.3.5/src/csrd_utils/generator.py +107 -0
  11. {csrd_utils-0.3.2 → csrd_utils-0.3.5}/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.5}/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.5}/.gitignore +0 -0
  19. {csrd_utils-0.3.2 → csrd_utils-0.3.5}/src/csrd_utils/features/workers/conftest.fragment.py +0 -0
  20. {csrd_utils-0.3.2 → csrd_utils-0.3.5}/src/csrd_utils/features/workers/docker-compose.fragment.yaml +0 -0
  21. {csrd_utils-0.3.2 → csrd_utils-0.3.5}/src/csrd_utils/features/workers/manifest.yaml +0 -0
  22. {csrd_utils-0.3.2 → csrd_utils-0.3.5}/src/csrd_utils/features/workers/requirements.fragment +0 -0
  23. {csrd_utils-0.3.2 → csrd_utils-0.3.5}/src/csrd_utils/features/workers/src/app/workers/__init__.py +0 -0
  24. {csrd_utils-0.3.2 → csrd_utils-0.3.5}/src/csrd_utils/features/workers/src/app/workers/celery_app.py +0 -0
  25. {csrd_utils-0.3.2 → csrd_utils-0.3.5}/src/csrd_utils/features/workers/src/app/workers/tasks.py +0 -0
  26. {csrd_utils-0.3.2 → csrd_utils-0.3.5}/src/csrd_utils/features/workers/tests/test_workers.py +0 -0
  27. {csrd_utils-0.3.2 → csrd_utils-0.3.5}/src/csrd_utils/resources.py +0 -0
  28. {csrd_utils-0.3.2 → csrd_utils-0.3.5}/src/csrd_utils/templates/cookiecutter-service/README.md +0 -0
  29. {csrd_utils-0.3.2 → csrd_utils-0.3.5}/src/csrd_utils/templates/cookiecutter-service/cookiecutter.json +0 -0
  30. {csrd_utils-0.3.2 → csrd_utils-0.3.5}/src/csrd_utils/templates/cookiecutter-service/hooks/post_gen_project.py +0 -0
  31. {csrd_utils-0.3.2 → csrd_utils-0.3.5}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.dockerignore +0 -0
  32. {csrd_utils-0.3.2 → csrd_utils-0.3.5}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.env.example +0 -0
  33. {csrd_utils-0.3.2 → csrd_utils-0.3.5}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/Dockerfile +0 -0
  34. {csrd_utils-0.3.2 → csrd_utils-0.3.5}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/README.md +0 -0
  35. {csrd_utils-0.3.2 → csrd_utils-0.3.5}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/docker-compose.yml +0 -0
  36. {csrd_utils-0.3.2 → csrd_utils-0.3.5}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/requirements.txt +0 -0
  37. {csrd_utils-0.3.2 → csrd_utils-0.3.5}/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.5}/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.5}/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.5}/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.5}/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.5}/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.5}/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.5}/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.5}/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.5}/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.5}/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.5}/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.5}/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.5}/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.5}/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.5}/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.5}/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.5}/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.5}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/__init__.py +0 -0
  56. {csrd_utils-0.3.2 → csrd_utils-0.3.5}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/test_items.py +0 -0
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: csrd-utils
3
+ Version: 0.3.5
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 --version
29
+ csrd new service # interactive prompts
30
+ csrd new service --interactive --name items # pre-fills name prompt with 'items'
31
+ csrd new service --name inventory --no-interactive --output .
32
+ csrd feature list
33
+ csrd feature plan workers --service .
34
+ csrd feature plan workers --service . --json
35
+ csrd feature add workers --service .
36
+ csrd doctor --service .
37
+ ```
38
+
39
+ `feature plan --json` is useful for CI or tooling wrappers.
40
+
41
+ ### Interactive generation behavior
42
+
43
+ - `csrd new service` runs interactive prompts by default.
44
+ - Passing `--name <value>` in interactive mode uses that value as the name prompt default.
45
+ - You can still overwrite the default by entering a different value at the prompt.
46
+ - Use `--no-interactive` for fully flag-driven generation.
47
+
48
+ ## Typical workflow
49
+
50
+ ```bash
51
+ # 0) Generate a new service from bundled template
52
+ csrd new service --name inventory --output . --database postgres --workers
53
+
54
+ # 1) Verify service compatibility
55
+ csrd doctor --service ./inventory
56
+
57
+ # 2) Inspect available bundled features
58
+ csrd feature list
59
+
60
+ # 3) Dry-run a feature
61
+ csrd feature plan workers --service ./inventory
62
+
63
+ # 4) Apply feature files/merges
64
+ csrd feature add workers --service ./inventory
65
+ ```
66
+
67
+ ## Bundled assets
68
+
69
+ - `features/workers/` feature manifests and fragments
70
+ - `templates/cookiecutter-service/` template starter
71
+
72
+ ## Python API
73
+
74
+ ```python
75
+ from pathlib import Path
76
+ from csrd_utils import ServiceAugmentor
77
+ from csrd_utils.resources import features_path
78
+
79
+ with features_path() as feature_lib:
80
+ augmentor = ServiceAugmentor(Path("."), feature_lib)
81
+ ok, changes = augmentor.add_feature("workers", plan=True)
82
+ ```
83
+
84
+ ## Documentation
85
+
86
+ - `docs/CLI_REFERENCE.md` - full command/flag reference
87
+ - `docs/FEATURES.md` - bundled feature catalog and behavior
88
+ - `docs/AGENT_SMOKE_TESTS.md` - copy/paste validation flow for clean workspaces
89
+ - `docs/COMMAND_MATRIX.yaml` - machine-readable command matrix for automation
90
+ - `AGENTS.md` - package-local rules for autonomous agents
91
+
92
+ ## CLI-only agent mode
93
+
94
+ If an agent can only see the installed CLI (not source files), use this exploration sequence:
95
+
96
+ ```bash
97
+ csrd --help
98
+ csrd new --help
99
+ csrd feature --help
100
+ csrd doctor --help
101
+ csrd feature list
102
+ ```
103
+
104
+ Then run a clean smoke flow:
105
+
106
+ ```bash
107
+ mkdir -p /tmp/csrd-smoke
108
+ cd /tmp/csrd-smoke
109
+ csrd new service --name demo-svc --output . --no-interactive
110
+ cd demo-svc
111
+ csrd doctor
112
+ csrd feature plan workers
113
+ csrd feature add workers
114
+ ```
@@ -0,0 +1,101 @@
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 --version
16
+ csrd new service # interactive prompts
17
+ csrd new service --interactive --name items # pre-fills name prompt with 'items'
18
+ csrd new service --name inventory --no-interactive --output .
19
+ csrd feature list
20
+ csrd feature plan workers --service .
21
+ csrd feature plan workers --service . --json
22
+ csrd feature add workers --service .
23
+ csrd doctor --service .
24
+ ```
25
+
26
+ `feature plan --json` is useful for CI or tooling wrappers.
27
+
28
+ ### Interactive generation behavior
29
+
30
+ - `csrd new service` runs interactive prompts by default.
31
+ - Passing `--name <value>` in interactive mode uses that value as the name prompt default.
32
+ - You can still overwrite the default by entering a different value at the prompt.
33
+ - Use `--no-interactive` for fully flag-driven generation.
34
+
35
+ ## Typical workflow
36
+
37
+ ```bash
38
+ # 0) Generate a new service from bundled template
39
+ csrd new service --name inventory --output . --database postgres --workers
40
+
41
+ # 1) Verify service compatibility
42
+ csrd doctor --service ./inventory
43
+
44
+ # 2) Inspect available bundled features
45
+ csrd feature list
46
+
47
+ # 3) Dry-run a feature
48
+ csrd feature plan workers --service ./inventory
49
+
50
+ # 4) Apply feature files/merges
51
+ csrd feature add workers --service ./inventory
52
+ ```
53
+
54
+ ## Bundled assets
55
+
56
+ - `features/workers/` feature manifests and fragments
57
+ - `templates/cookiecutter-service/` template starter
58
+
59
+ ## Python API
60
+
61
+ ```python
62
+ from pathlib import Path
63
+ from csrd_utils import ServiceAugmentor
64
+ from csrd_utils.resources import features_path
65
+
66
+ with features_path() as feature_lib:
67
+ augmentor = ServiceAugmentor(Path("."), feature_lib)
68
+ ok, changes = augmentor.add_feature("workers", plan=True)
69
+ ```
70
+
71
+ ## Documentation
72
+
73
+ - `docs/CLI_REFERENCE.md` - full command/flag reference
74
+ - `docs/FEATURES.md` - bundled feature catalog and behavior
75
+ - `docs/AGENT_SMOKE_TESTS.md` - copy/paste validation flow for clean workspaces
76
+ - `docs/COMMAND_MATRIX.yaml` - machine-readable command matrix for automation
77
+ - `AGENTS.md` - package-local rules for autonomous agents
78
+
79
+ ## CLI-only agent mode
80
+
81
+ If an agent can only see the installed CLI (not source files), use this exploration sequence:
82
+
83
+ ```bash
84
+ csrd --help
85
+ csrd new --help
86
+ csrd feature --help
87
+ csrd doctor --help
88
+ csrd feature list
89
+ ```
90
+
91
+ Then run a clean smoke flow:
92
+
93
+ ```bash
94
+ mkdir -p /tmp/csrd-smoke
95
+ cd /tmp/csrd-smoke
96
+ csrd new service --name demo-svc --output . --no-interactive
97
+ cd demo-svc
98
+ csrd doctor
99
+ csrd feature plan workers
100
+ csrd feature add workers
101
+ ```
@@ -1,10 +1,11 @@
1
1
  [project]
2
2
  name = "csrd-utils"
3
- version = "0.3.2"
3
+ version = "0.3.5"
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,337 @@
1
+ """Command-line interface for csrd-utils."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from importlib.metadata import version
8
+ from pathlib import Path
9
+ from typing import TypedDict
10
+
11
+ from .augmentor import ServiceAugmentor
12
+ from .doctor import run_doctor
13
+ from .generator import generate_service
14
+ from .resources import features_path
15
+
16
+
17
+ class GenerateServiceArgs(TypedDict):
18
+ name: str
19
+ output_dir: Path
20
+ description: str
21
+ port: int
22
+ model_name: str
23
+ model_name_plural: str
24
+ has_database: bool
25
+ database: str
26
+ has_delegates: bool
27
+ upstreams: str
28
+ has_workers: bool
29
+ broker: str
30
+ has_caching: bool
31
+ cache_backend: str
32
+ has_structured_logging: bool
33
+ overwrite_if_exists: bool
34
+
35
+
36
+ def _prompt_text(label: str, default: str) -> str:
37
+ value = input(f"{label} [{default}]: ").strip()
38
+ return value or default
39
+
40
+
41
+ def _prompt_bool(label: str, default: bool) -> bool:
42
+ suffix = "Y/n" if default else "y/N"
43
+ value = input(f"{label} ({suffix}): ").strip().lower()
44
+ if not value:
45
+ return default
46
+ return value in {"y", "yes", "true", "1"}
47
+
48
+
49
+ def _prompt_choice(label: str, choices: list[str], default: str) -> str:
50
+ rendered = ", ".join(choices)
51
+ while True:
52
+ value = input(f"{label} [{default}] ({rendered}): ").strip().lower()
53
+ if not value:
54
+ return default
55
+ if value in choices:
56
+ return value
57
+ print(f"ERROR: Invalid choice '{value}'. Expected one of: {rendered}")
58
+
59
+
60
+ def _prompt_int(label: str, default: int) -> int:
61
+ while True:
62
+ value = input(f"{label} [{default}]: ").strip()
63
+ if not value:
64
+ return default
65
+ try:
66
+ return int(value)
67
+ except ValueError:
68
+ print("ERROR: Enter a valid integer")
69
+
70
+
71
+ def _collect_new_service_config(args: argparse.Namespace) -> GenerateServiceArgs:
72
+ explicit_interactive = getattr(args, "interactive", None)
73
+ interactive = (
74
+ bool(explicit_interactive) if explicit_interactive is not None else args.name is None
75
+ )
76
+
77
+ # Non-interactive mode keeps current flag-driven behavior.
78
+ if not interactive:
79
+ has_database = args.database != "none"
80
+ selected_database = "sqlite" if not has_database else args.database
81
+ return GenerateServiceArgs(
82
+ {
83
+ "name": args.name,
84
+ "output_dir": args.output,
85
+ "description": args.description,
86
+ "port": args.port,
87
+ "model_name": args.model_name,
88
+ "model_name_plural": args.model_name_plural,
89
+ "has_database": has_database,
90
+ "database": selected_database,
91
+ "has_delegates": args.delegates,
92
+ "upstreams": args.upstreams,
93
+ "has_workers": args.workers,
94
+ "broker": args.broker,
95
+ "has_caching": args.caching,
96
+ "cache_backend": args.cache_backend,
97
+ "has_structured_logging": args.structured_logging,
98
+ "overwrite_if_exists": args.force,
99
+ }
100
+ )
101
+
102
+ print("Interactive mode: press Enter to accept defaults.")
103
+
104
+ name = _prompt_text("Service name", args.name or "my-service")
105
+ output_dir = Path(_prompt_text("Output directory", str(args.output)))
106
+ description = _prompt_text("Service description", args.description)
107
+ port = _prompt_int("HTTP port", args.port)
108
+ model_name = _prompt_text("Model name", args.model_name)
109
+
110
+ plural_default = args.model_name_plural
111
+ if plural_default == "items" and model_name != "Item":
112
+ plural_default = f"{model_name.lower()}s"
113
+ model_name_plural = _prompt_text("Model name plural", plural_default)
114
+
115
+ database = _prompt_choice("Database", ["sqlite", "maria", "postgres", "none"], args.database)
116
+ has_database = database != "none"
117
+ selected_database = "sqlite" if not has_database else database
118
+
119
+ has_delegates = _prompt_bool("Enable delegates", args.delegates)
120
+ upstreams_default = args.upstreams
121
+ if has_delegates and not upstreams_default:
122
+ upstreams_default = "http://service-a:8080"
123
+ upstreams = (
124
+ _prompt_text("Delegates upstreams (comma-separated)", upstreams_default)
125
+ if has_delegates
126
+ else ""
127
+ )
128
+
129
+ has_workers = _prompt_bool("Enable workers", args.workers)
130
+ broker = (
131
+ _prompt_choice("Worker broker", ["redis", "rabbitmq"], args.broker)
132
+ if has_workers
133
+ else args.broker
134
+ )
135
+
136
+ has_caching = _prompt_bool("Enable caching", args.caching)
137
+ cache_backend = (
138
+ _prompt_choice("Cache backend", ["redis"], args.cache_backend)
139
+ if has_caching
140
+ else args.cache_backend
141
+ )
142
+
143
+ has_structured_logging = _prompt_bool("Enable structured logging", args.structured_logging)
144
+ overwrite_if_exists = _prompt_bool("Overwrite if target exists", args.force)
145
+
146
+ return GenerateServiceArgs(
147
+ {
148
+ "name": name,
149
+ "output_dir": output_dir,
150
+ "description": description,
151
+ "port": port,
152
+ "model_name": model_name,
153
+ "model_name_plural": model_name_plural,
154
+ "has_database": has_database,
155
+ "database": selected_database,
156
+ "has_delegates": has_delegates,
157
+ "upstreams": upstreams,
158
+ "has_workers": has_workers,
159
+ "broker": broker,
160
+ "has_caching": has_caching,
161
+ "cache_backend": cache_backend,
162
+ "has_structured_logging": has_structured_logging,
163
+ "overwrite_if_exists": overwrite_if_exists,
164
+ }
165
+ )
166
+
167
+
168
+ def _build_parser() -> argparse.ArgumentParser:
169
+ parser = argparse.ArgumentParser(prog="csrd", description="csrd utilities")
170
+ parser.add_argument(
171
+ "-v",
172
+ "--version",
173
+ action="version",
174
+ version=f"csrd-utils {version('csrd-utils')}",
175
+ )
176
+ sub = parser.add_subparsers(dest="command")
177
+
178
+ feature = sub.add_parser("feature", help="Feature operations")
179
+ feature_sub = feature.add_subparsers(dest="feature_command")
180
+
181
+ list_cmd = feature_sub.add_parser("list", help="List bundled features")
182
+ list_cmd.set_defaults(plan=False)
183
+
184
+ for name, plan in (("add", False), ("plan", True)):
185
+ cmd = feature_sub.add_parser(name, help=f"{name} feature")
186
+ cmd.add_argument("feature", help="Feature name, e.g. workers")
187
+ cmd.add_argument("--service", type=Path, default=Path.cwd(), help="Service root path")
188
+ if name == "plan":
189
+ cmd.add_argument("--json", action="store_true", help="Print plan as JSON")
190
+ cmd.set_defaults(plan=plan)
191
+
192
+ doctor = sub.add_parser("doctor", help="Validate service compatibility")
193
+ doctor.add_argument("--service", type=Path, default=Path.cwd(), help="Service root path")
194
+ doctor.add_argument("--json", action="store_true", help="Print doctor report as JSON")
195
+
196
+ new = sub.add_parser("new", help="Generate new assets")
197
+ new_sub = new.add_subparsers(dest="new_command")
198
+ service = new_sub.add_parser("service", help="Generate a service from bundled template")
199
+ service.add_argument("--name", help="Service name")
200
+ service.add_argument("--output", type=Path, default=Path.cwd(), help="Output directory")
201
+ service.add_argument(
202
+ "--description",
203
+ default="A microservice using the csrd library stack",
204
+ help="Service description",
205
+ )
206
+ service.add_argument("--port", type=int, default=8080, help="HTTP service port")
207
+ service.add_argument("--model-name", default="Item", help="Primary model name")
208
+ service.add_argument("--model-name-plural", default="items", help="Plural model name")
209
+ service.add_argument(
210
+ "--database", choices=["sqlite", "maria", "postgres", "none"], default="sqlite"
211
+ )
212
+ service.add_argument("--delegates", action="store_true", help="Enable delegates")
213
+ service.add_argument("--upstreams", default="", help="Comma-separated upstream URLs")
214
+ service.add_argument("--workers", action="store_true", help="Enable workers")
215
+ service.add_argument("--broker", choices=["redis", "rabbitmq"], default="redis")
216
+ service.add_argument("--caching", action="store_true", help="Enable caching")
217
+ service.add_argument("--cache-backend", choices=["redis"], default="redis")
218
+ service.add_argument(
219
+ "--structured-logging",
220
+ dest="structured_logging",
221
+ action="store_true",
222
+ default=True,
223
+ help="Enable structured logging",
224
+ )
225
+ service.add_argument(
226
+ "--no-structured-logging",
227
+ dest="structured_logging",
228
+ action="store_false",
229
+ help="Disable structured logging",
230
+ )
231
+ service.add_argument("--interactive", dest="interactive", action="store_true", default=None)
232
+ service.add_argument("--no-interactive", dest="interactive", action="store_false")
233
+ service.add_argument("--force", action="store_true", help="Overwrite existing generated path")
234
+
235
+ return parser
236
+
237
+
238
+ def _list_features() -> int:
239
+ with features_path() as feature_lib:
240
+ names = sorted(
241
+ path.name
242
+ for path in feature_lib.iterdir()
243
+ if path.is_dir() and (path / "manifest.yaml").exists()
244
+ )
245
+ if not names:
246
+ print("No bundled features found.")
247
+ return 1
248
+ for name in names:
249
+ print(name)
250
+ return 0
251
+
252
+
253
+ def _run_doctor(service: Path, as_json: bool) -> int:
254
+ report = run_doctor(service)
255
+ payload = {
256
+ "ok": report.ok,
257
+ "errors": report.errors,
258
+ "warnings": report.warnings,
259
+ }
260
+ if as_json:
261
+ print(json.dumps(payload, indent=2, sort_keys=True))
262
+ else:
263
+ if report.ok:
264
+ print("Doctor check passed.")
265
+ else:
266
+ print("Doctor check failed.")
267
+ for err in report.errors:
268
+ print(f"ERROR: {err}")
269
+ for warning in report.warnings:
270
+ print(f"WARN: {warning}")
271
+ return 0 if report.ok else 1
272
+
273
+
274
+ def main() -> int:
275
+ parser = _build_parser()
276
+ args = parser.parse_args()
277
+
278
+ if args.command == "new" and args.new_command == "service":
279
+ try:
280
+ config = _collect_new_service_config(args)
281
+ generated = generate_service(**config)
282
+ except (FileExistsError, ValueError) as exc:
283
+ print(f"ERROR: {exc}")
284
+ return 1
285
+ except KeyboardInterrupt:
286
+ print("\nCancelled.")
287
+ return 1
288
+ print(f"Generated service at: {generated}")
289
+ return 0
290
+
291
+ if args.command == "doctor":
292
+ return _run_doctor(args.service, args.json)
293
+
294
+ if args.command != "feature" or args.feature_command not in {"add", "plan", "list"}:
295
+ parser.print_help()
296
+ return 0
297
+
298
+ if args.feature_command == "list":
299
+ return _list_features()
300
+
301
+ with features_path() as feature_lib:
302
+ augmentor = ServiceAugmentor(args.service, feature_lib)
303
+ if args.feature_command == "plan" and args.json:
304
+ plan = augmentor.build_plan(args.feature)
305
+ if plan is None:
306
+ return 1
307
+ print(
308
+ json.dumps(
309
+ {
310
+ "feature": args.feature,
311
+ "service": str(args.service.resolve()),
312
+ "modifies": plan["modifies"],
313
+ "creates": plan["creates"],
314
+ },
315
+ indent=2,
316
+ sort_keys=True,
317
+ )
318
+ )
319
+ return 0
320
+ success, changes = augmentor.add_feature(args.feature, plan=args.plan)
321
+
322
+ if not success:
323
+ return 1
324
+
325
+ if args.plan:
326
+ print("Plan validated.")
327
+ else:
328
+ print(f"Feature '{args.feature}' added.")
329
+ if changes:
330
+ for change in changes:
331
+ print(f"- {change}")
332
+
333
+ return 0
334
+
335
+
336
+ if __name__ == "__main__":
337
+ 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