csrd-utils 0.3.13__tar.gz → 0.3.14__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.
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/PKG-INFO +30 -16
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/README.md +29 -15
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/pyproject.toml +1 -1
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/__main__.py +371 -1
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/.gitignore +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/__init__.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/augmentor.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/cluster.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/doctor.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/features/workers/conftest.fragment.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/features/workers/docker-compose.fragment.yaml +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/features/workers/manifest.yaml +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/features/workers/requirements.fragment +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/features/workers/settings.fragment.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/features/workers/src/app/workers/__init__.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/features/workers/src/app/workers/celery_app.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/features/workers/src/app/workers/tasks.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/features/workers/tests/test_workers.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/fragments.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/generator.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/resources.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/README.md +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/cookiecutter.json +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/hooks/post_gen_project.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.dockerignore +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.env.example +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/Dockerfile +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/README.md +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/docker-compose.yml +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/requirements.txt +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/__init__.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/cache.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/delegates/__init__.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/delegates/upstreams.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/__init__.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/delegates.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/item_repository.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/worker_broker.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/middleware/__init__.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/middleware/logging.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/repositories/__init__.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/repositories/item_repository.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/settings.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/__init__.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/__init__.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/health_view.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/items_view.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/workers/__init__.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/workers/tasks.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/__init__.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/acceptance/test_logging_middleware.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/conftest.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/test_items.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/unit/test_cache.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/unit/test_database_settings.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/unit/test_delegates.py +0 -0
- {csrd_utils-0.3.13 → csrd_utils-0.3.14}/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.
|
|
3
|
+
Version: 0.3.14
|
|
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
|
|
@@ -26,11 +26,13 @@ pip install "csrd-utils @ git+https://github.com/csrd-api/fastapi-common.git#sub
|
|
|
26
26
|
```bash
|
|
27
27
|
csrd --help
|
|
28
28
|
csrd --version
|
|
29
|
-
csrd new
|
|
29
|
+
csrd new workspace # create workspace, then choose none/service/cluster
|
|
30
|
+
csrd new service # requires workspace; prompts to create one if missing
|
|
30
31
|
csrd new service --interactive --name items # pre-fills name prompt with 'items'
|
|
31
|
-
csrd new service --name inventory --no-interactive --output
|
|
32
|
-
csrd new
|
|
33
|
-
csrd new cluster
|
|
32
|
+
csrd new service --name inventory --no-interactive --output ./my-workspace
|
|
33
|
+
csrd new service --name pricing --output ./my-workspace --no-interactive
|
|
34
|
+
csrd new cluster --spec ./cluster.yaml --output ./my-workspace
|
|
35
|
+
csrd new cluster # requires workspace; interactive mode can offer to create one
|
|
34
36
|
csrd feature list
|
|
35
37
|
csrd feature plan workers --service .
|
|
36
38
|
csrd feature plan workers --service . --json
|
|
@@ -42,7 +44,14 @@ csrd doctor --service .
|
|
|
42
44
|
|
|
43
45
|
### Interactive generation behavior
|
|
44
46
|
|
|
47
|
+
- `csrd new service` and `csrd new cluster` are workspace-first commands.
|
|
48
|
+
- If a workspace marker is missing in the selected output path, interactive flows ask to create one and continue.
|
|
49
|
+
- Non-interactive generation errors when no workspace marker is present.
|
|
50
|
+
- Workspace cluster source of truth is persisted at `.csrd/cluster.yaml`.
|
|
51
|
+
- 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.
|
|
52
|
+
- 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.
|
|
45
53
|
- `csrd new service` runs a compact interactive flow by default (core prompts 1-6), then asks whether to show advanced options.
|
|
54
|
+
- `csrd new workspace` creates a workspace directory and prompts whether to generate `none`, `service`, or `cluster` immediately.
|
|
46
55
|
- In compact mode, advanced options are hidden by default.
|
|
47
56
|
- `csrd new service --interactive` runs the full verbose interactive flow.
|
|
48
57
|
- Passing `--name <value>` in interactive mode uses that value as the name prompt default.
|
|
@@ -54,20 +63,24 @@ csrd doctor --service .
|
|
|
54
63
|
## Typical workflow
|
|
55
64
|
|
|
56
65
|
```bash
|
|
57
|
-
# 0)
|
|
58
|
-
csrd new
|
|
66
|
+
# 0) Create a workspace and generate a service inside it
|
|
67
|
+
csrd new workspace --name local-dev --output . --generate none
|
|
68
|
+
csrd new service --name inventory --output ./local-dev --database postgres --workers
|
|
59
69
|
|
|
60
70
|
# 1) Verify service compatibility
|
|
61
|
-
csrd doctor --service ./inventory
|
|
71
|
+
csrd doctor --service ./local-dev/inventory
|
|
62
72
|
|
|
63
73
|
# 2) Inspect available bundled features
|
|
64
74
|
csrd feature list
|
|
65
75
|
|
|
66
76
|
# 3) Dry-run a feature
|
|
67
|
-
csrd feature plan workers --service ./inventory
|
|
77
|
+
csrd feature plan workers --service ./local-dev/inventory
|
|
68
78
|
|
|
69
79
|
# 4) Apply feature files/merges
|
|
70
|
-
csrd feature add workers --service ./inventory
|
|
80
|
+
csrd feature add workers --service ./local-dev/inventory
|
|
81
|
+
|
|
82
|
+
# 5) Add a second service — workspace auto-upgrades to cluster
|
|
83
|
+
csrd new service --name pricing --output ./local-dev --database postgres
|
|
71
84
|
```
|
|
72
85
|
|
|
73
86
|
## Bundled assets
|
|
@@ -102,6 +115,8 @@ If an agent can only see the installed CLI (not source files), use this explorat
|
|
|
102
115
|
```bash
|
|
103
116
|
csrd --help
|
|
104
117
|
csrd new --help
|
|
118
|
+
csrd new workspace --help
|
|
119
|
+
csrd new service --help
|
|
105
120
|
csrd new cluster --help
|
|
106
121
|
csrd feature --help
|
|
107
122
|
csrd doctor --help
|
|
@@ -112,10 +127,9 @@ Then run a clean smoke flow:
|
|
|
112
127
|
|
|
113
128
|
```bash
|
|
114
129
|
mkdir -p /tmp/csrd-smoke
|
|
115
|
-
|
|
116
|
-
csrd new service --name demo-svc --output
|
|
117
|
-
|
|
118
|
-
csrd
|
|
119
|
-
csrd feature
|
|
120
|
-
csrd feature add workers
|
|
130
|
+
csrd new workspace --name ws --output /tmp/csrd-smoke --generate none
|
|
131
|
+
csrd new service --name demo-svc --output /tmp/csrd-smoke/ws --no-interactive
|
|
132
|
+
csrd doctor --service /tmp/csrd-smoke/ws/demo-svc
|
|
133
|
+
csrd feature plan workers --service /tmp/csrd-smoke/ws/demo-svc
|
|
134
|
+
csrd feature add workers --service /tmp/csrd-smoke/ws/demo-svc
|
|
121
135
|
```
|
|
@@ -13,11 +13,13 @@ pip install "csrd-utils @ git+https://github.com/csrd-api/fastapi-common.git#sub
|
|
|
13
13
|
```bash
|
|
14
14
|
csrd --help
|
|
15
15
|
csrd --version
|
|
16
|
-
csrd new
|
|
16
|
+
csrd new workspace # create workspace, then choose none/service/cluster
|
|
17
|
+
csrd new service # requires workspace; prompts to create one if missing
|
|
17
18
|
csrd new service --interactive --name items # pre-fills name prompt with 'items'
|
|
18
|
-
csrd new service --name inventory --no-interactive --output
|
|
19
|
-
csrd new
|
|
20
|
-
csrd new cluster
|
|
19
|
+
csrd new service --name inventory --no-interactive --output ./my-workspace
|
|
20
|
+
csrd new service --name pricing --output ./my-workspace --no-interactive
|
|
21
|
+
csrd new cluster --spec ./cluster.yaml --output ./my-workspace
|
|
22
|
+
csrd new cluster # requires workspace; interactive mode can offer to create one
|
|
21
23
|
csrd feature list
|
|
22
24
|
csrd feature plan workers --service .
|
|
23
25
|
csrd feature plan workers --service . --json
|
|
@@ -29,7 +31,14 @@ csrd doctor --service .
|
|
|
29
31
|
|
|
30
32
|
### Interactive generation behavior
|
|
31
33
|
|
|
34
|
+
- `csrd new service` and `csrd new cluster` are workspace-first commands.
|
|
35
|
+
- If a workspace marker is missing in the selected output path, interactive flows ask to create one and continue.
|
|
36
|
+
- Non-interactive generation errors when no workspace marker is present.
|
|
37
|
+
- Workspace cluster source of truth is persisted at `.csrd/cluster.yaml`.
|
|
38
|
+
- 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.
|
|
39
|
+
- 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.
|
|
32
40
|
- `csrd new service` runs a compact interactive flow by default (core prompts 1-6), then asks whether to show advanced options.
|
|
41
|
+
- `csrd new workspace` creates a workspace directory and prompts whether to generate `none`, `service`, or `cluster` immediately.
|
|
33
42
|
- In compact mode, advanced options are hidden by default.
|
|
34
43
|
- `csrd new service --interactive` runs the full verbose interactive flow.
|
|
35
44
|
- Passing `--name <value>` in interactive mode uses that value as the name prompt default.
|
|
@@ -41,20 +50,24 @@ csrd doctor --service .
|
|
|
41
50
|
## Typical workflow
|
|
42
51
|
|
|
43
52
|
```bash
|
|
44
|
-
# 0)
|
|
45
|
-
csrd new
|
|
53
|
+
# 0) Create a workspace and generate a service inside it
|
|
54
|
+
csrd new workspace --name local-dev --output . --generate none
|
|
55
|
+
csrd new service --name inventory --output ./local-dev --database postgres --workers
|
|
46
56
|
|
|
47
57
|
# 1) Verify service compatibility
|
|
48
|
-
csrd doctor --service ./inventory
|
|
58
|
+
csrd doctor --service ./local-dev/inventory
|
|
49
59
|
|
|
50
60
|
# 2) Inspect available bundled features
|
|
51
61
|
csrd feature list
|
|
52
62
|
|
|
53
63
|
# 3) Dry-run a feature
|
|
54
|
-
csrd feature plan workers --service ./inventory
|
|
64
|
+
csrd feature plan workers --service ./local-dev/inventory
|
|
55
65
|
|
|
56
66
|
# 4) Apply feature files/merges
|
|
57
|
-
csrd feature add workers --service ./inventory
|
|
67
|
+
csrd feature add workers --service ./local-dev/inventory
|
|
68
|
+
|
|
69
|
+
# 5) Add a second service — workspace auto-upgrades to cluster
|
|
70
|
+
csrd new service --name pricing --output ./local-dev --database postgres
|
|
58
71
|
```
|
|
59
72
|
|
|
60
73
|
## Bundled assets
|
|
@@ -89,6 +102,8 @@ If an agent can only see the installed CLI (not source files), use this explorat
|
|
|
89
102
|
```bash
|
|
90
103
|
csrd --help
|
|
91
104
|
csrd new --help
|
|
105
|
+
csrd new workspace --help
|
|
106
|
+
csrd new service --help
|
|
92
107
|
csrd new cluster --help
|
|
93
108
|
csrd feature --help
|
|
94
109
|
csrd doctor --help
|
|
@@ -99,10 +114,9 @@ Then run a clean smoke flow:
|
|
|
99
114
|
|
|
100
115
|
```bash
|
|
101
116
|
mkdir -p /tmp/csrd-smoke
|
|
102
|
-
|
|
103
|
-
csrd new service --name demo-svc --output
|
|
104
|
-
|
|
105
|
-
csrd
|
|
106
|
-
csrd feature
|
|
107
|
-
csrd feature add workers
|
|
117
|
+
csrd new workspace --name ws --output /tmp/csrd-smoke --generate none
|
|
118
|
+
csrd new service --name demo-svc --output /tmp/csrd-smoke/ws --no-interactive
|
|
119
|
+
csrd doctor --service /tmp/csrd-smoke/ws/demo-svc
|
|
120
|
+
csrd feature plan workers --service /tmp/csrd-smoke/ws/demo-svc
|
|
121
|
+
csrd feature add workers --service /tmp/csrd-smoke/ws/demo-svc
|
|
108
122
|
```
|
|
@@ -8,9 +8,19 @@ from contextlib import suppress
|
|
|
8
8
|
from importlib.metadata import version
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import TypedDict
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
import yaml # type: ignore[import-untyped]
|
|
11
14
|
|
|
12
15
|
from .augmentor import ServiceAugmentor
|
|
13
|
-
from .cluster import
|
|
16
|
+
from .cluster import (
|
|
17
|
+
PRESETS,
|
|
18
|
+
ClusterSpec,
|
|
19
|
+
ServiceSpec,
|
|
20
|
+
generate_cluster,
|
|
21
|
+
load_cluster_spec,
|
|
22
|
+
normalize_cluster_spec,
|
|
23
|
+
)
|
|
14
24
|
from .doctor import run_doctor
|
|
15
25
|
from .generator import generate_service
|
|
16
26
|
from .resources import features_path
|
|
@@ -41,6 +51,17 @@ class GenerateClusterArgs(TypedDict):
|
|
|
41
51
|
overwrite_if_exists: bool
|
|
42
52
|
|
|
43
53
|
|
|
54
|
+
class GenerateWorkspaceArgs(TypedDict):
|
|
55
|
+
name: str
|
|
56
|
+
output_dir: Path
|
|
57
|
+
overwrite_if_exists: bool
|
|
58
|
+
generate: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
WORKSPACE_MARKER = ".csrd-workspace"
|
|
62
|
+
WORKSPACE_CLUSTER_SPEC = Path(".csrd") / "cluster.yaml"
|
|
63
|
+
|
|
64
|
+
|
|
44
65
|
def _prompt_text(label: str, default: str) -> str:
|
|
45
66
|
value = input(f"{label} [{default}]: ").strip()
|
|
46
67
|
return value or default
|
|
@@ -263,6 +284,298 @@ def _collect_new_cluster_config(args: argparse.Namespace) -> GenerateClusterArgs
|
|
|
263
284
|
)
|
|
264
285
|
|
|
265
286
|
|
|
287
|
+
def _collect_new_workspace_config(args: argparse.Namespace) -> GenerateWorkspaceArgs:
|
|
288
|
+
name = args.name or _prompt_text("Workspace name", "my-workspace")
|
|
289
|
+
output_dir = (
|
|
290
|
+
args.output
|
|
291
|
+
if args.name is not None
|
|
292
|
+
else Path(_prompt_text("Output directory", str(args.output)))
|
|
293
|
+
)
|
|
294
|
+
generate = args.generate or _prompt_choice(
|
|
295
|
+
"Generate in workspace now", ["none", "service", "cluster"], "none"
|
|
296
|
+
)
|
|
297
|
+
return GenerateWorkspaceArgs(
|
|
298
|
+
{
|
|
299
|
+
"name": name,
|
|
300
|
+
"output_dir": output_dir,
|
|
301
|
+
"overwrite_if_exists": args.force,
|
|
302
|
+
"generate": generate,
|
|
303
|
+
}
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _create_workspace(config: GenerateWorkspaceArgs) -> Path:
|
|
308
|
+
workspace_root = config["output_dir"].resolve() / config["name"]
|
|
309
|
+
if workspace_root.exists() and not workspace_root.is_dir():
|
|
310
|
+
raise FileExistsError(f"Target path exists and is not a directory: {workspace_root}")
|
|
311
|
+
|
|
312
|
+
workspace_root.mkdir(parents=True, exist_ok=True)
|
|
313
|
+
if any(workspace_root.iterdir()) and not config["overwrite_if_exists"]:
|
|
314
|
+
raise FileExistsError(
|
|
315
|
+
f"Target directory already exists and is not empty: {workspace_root}. Use --force to overwrite."
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
_write_workspace_marker(workspace_root)
|
|
319
|
+
return workspace_root
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _default_service_namespace(output_dir: Path) -> argparse.Namespace:
|
|
323
|
+
return argparse.Namespace(
|
|
324
|
+
interactive=True,
|
|
325
|
+
name=None,
|
|
326
|
+
output=output_dir,
|
|
327
|
+
description="A microservice using the csrd library stack",
|
|
328
|
+
port=8080,
|
|
329
|
+
model_name="Item",
|
|
330
|
+
model_name_plural="items",
|
|
331
|
+
database="none",
|
|
332
|
+
delegates=False,
|
|
333
|
+
upstreams="",
|
|
334
|
+
workers=False,
|
|
335
|
+
broker="redis",
|
|
336
|
+
caching=False,
|
|
337
|
+
cache_backend="redis",
|
|
338
|
+
structured_logging=False,
|
|
339
|
+
force=False,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _default_cluster_namespace(output_dir: Path) -> argparse.Namespace:
|
|
344
|
+
return argparse.Namespace(
|
|
345
|
+
spec=None,
|
|
346
|
+
name=None,
|
|
347
|
+
output=output_dir,
|
|
348
|
+
preset="none",
|
|
349
|
+
force=False,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _write_workspace_marker(path: Path) -> None:
|
|
354
|
+
(path / WORKSPACE_MARKER).write_text("workspace: csrd\n", encoding="utf-8")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _is_workspace(path: Path) -> bool:
|
|
358
|
+
return (path / WORKSPACE_MARKER).is_file()
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _service_is_interactive(args: argparse.Namespace) -> bool:
|
|
362
|
+
explicit_interactive = getattr(args, "interactive", None)
|
|
363
|
+
return bool(explicit_interactive) if explicit_interactive is not None else args.name is None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _ensure_workspace_for_output(output_dir: Path, allow_prompt: bool) -> Path:
|
|
367
|
+
workspace_root = output_dir.resolve()
|
|
368
|
+
if _is_workspace(workspace_root):
|
|
369
|
+
return workspace_root
|
|
370
|
+
|
|
371
|
+
if workspace_root.exists() and not workspace_root.is_dir():
|
|
372
|
+
raise ValueError(f"Output path is not a directory: {workspace_root}")
|
|
373
|
+
|
|
374
|
+
if allow_prompt:
|
|
375
|
+
create_workspace = _prompt_bool(
|
|
376
|
+
f"No workspace found at {workspace_root}. Create workspace marker here", True
|
|
377
|
+
)
|
|
378
|
+
if create_workspace:
|
|
379
|
+
workspace_root.mkdir(parents=True, exist_ok=True)
|
|
380
|
+
_write_workspace_marker(workspace_root)
|
|
381
|
+
print(f"Generated workspace at: {workspace_root}")
|
|
382
|
+
return workspace_root
|
|
383
|
+
|
|
384
|
+
raise ValueError(
|
|
385
|
+
"This command requires a csrd workspace. Run 'csrd new workspace' first or use a workspace output path."
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _workspace_cluster_spec_path(workspace_root: Path) -> Path:
|
|
390
|
+
return workspace_root / WORKSPACE_CLUSTER_SPEC
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _load_workspace_cluster_spec(workspace_root: Path) -> ClusterSpec:
|
|
394
|
+
spec_path = _workspace_cluster_spec_path(workspace_root)
|
|
395
|
+
if spec_path.is_file():
|
|
396
|
+
return load_cluster_spec(spec_path)
|
|
397
|
+
return {
|
|
398
|
+
"cluster": {"name": workspace_root.name, "preset": "none"},
|
|
399
|
+
"services": [],
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _save_workspace_cluster_spec(workspace_root: Path, spec: ClusterSpec) -> None:
|
|
404
|
+
spec_path = _workspace_cluster_spec_path(workspace_root)
|
|
405
|
+
spec_path.parent.mkdir(parents=True, exist_ok=True)
|
|
406
|
+
normalized = normalize_cluster_spec(spec)
|
|
407
|
+
spec_path.write_text(yaml.safe_dump(normalized, sort_keys=False), encoding="utf-8")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _delegate_targets_from_upstreams(raw_upstreams: str) -> list[str]:
|
|
411
|
+
targets: list[str] = []
|
|
412
|
+
for raw in [entry.strip() for entry in raw_upstreams.split(",") if entry.strip()]:
|
|
413
|
+
parsed = urlparse(raw)
|
|
414
|
+
if not parsed.hostname:
|
|
415
|
+
raise ValueError(
|
|
416
|
+
f"Unable to infer delegate target from upstream URL: {raw}. Use full URLs with hostnames."
|
|
417
|
+
)
|
|
418
|
+
targets.append(parsed.hostname)
|
|
419
|
+
return targets
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _service_config_to_cluster_service(config: GenerateServiceArgs) -> ServiceSpec:
|
|
423
|
+
has_database = config["has_database"]
|
|
424
|
+
has_workers = config["has_workers"]
|
|
425
|
+
has_delegates = config["has_delegates"]
|
|
426
|
+
selected_roles = sum(1 for enabled in (has_database, has_workers, has_delegates) if enabled)
|
|
427
|
+
if selected_roles > 1:
|
|
428
|
+
raise ValueError(
|
|
429
|
+
"Workspace cluster mapping supports one core role per generated service (database or workers or delegates)."
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
service: ServiceSpec = {
|
|
433
|
+
"name": config["name"],
|
|
434
|
+
"port": config["port"],
|
|
435
|
+
"features": [],
|
|
436
|
+
"requires": [],
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if has_database:
|
|
440
|
+
service["type"] = "db"
|
|
441
|
+
service["database"] = config["database"]
|
|
442
|
+
elif has_workers:
|
|
443
|
+
service["type"] = "worker"
|
|
444
|
+
service["broker"] = config["broker"]
|
|
445
|
+
service["requires"] = ["redis" if config["broker"] == "redis" else "rabbitmq"]
|
|
446
|
+
elif has_delegates:
|
|
447
|
+
targets = _delegate_targets_from_upstreams(config["upstreams"])
|
|
448
|
+
service["type"] = "delegate"
|
|
449
|
+
service["delegates"] = targets
|
|
450
|
+
service["requires"] = list(targets)
|
|
451
|
+
else:
|
|
452
|
+
service["type"] = "platform"
|
|
453
|
+
service["role"] = "custom"
|
|
454
|
+
|
|
455
|
+
if config["has_caching"]:
|
|
456
|
+
service["features"].append("caching")
|
|
457
|
+
if config["has_structured_logging"]:
|
|
458
|
+
service["features"].append("logging")
|
|
459
|
+
|
|
460
|
+
return service
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _append_service_to_workspace_cluster(
|
|
464
|
+
workspace_root: Path, config: GenerateServiceArgs
|
|
465
|
+
) -> ClusterSpec:
|
|
466
|
+
spec = _load_workspace_cluster_spec(workspace_root)
|
|
467
|
+
service_names = {str(service.get("name", "")).strip() for service in spec.get("services", [])}
|
|
468
|
+
if config["name"] in service_names:
|
|
469
|
+
raise ValueError(f"Service '{config['name']}' already exists in workspace cluster spec")
|
|
470
|
+
|
|
471
|
+
spec["services"].append(_service_config_to_cluster_service(config))
|
|
472
|
+
normalized = normalize_cluster_spec(spec)
|
|
473
|
+
_save_workspace_cluster_spec(workspace_root, normalized)
|
|
474
|
+
return normalized
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _workspace_service_roots(workspace_root: Path) -> list[Path]:
|
|
478
|
+
roots: list[Path] = []
|
|
479
|
+
for child in workspace_root.iterdir():
|
|
480
|
+
if not child.is_dir() or child.name.startswith("."):
|
|
481
|
+
continue
|
|
482
|
+
if (
|
|
483
|
+
(child / "src" / "app").is_dir()
|
|
484
|
+
and (child / "tests").is_dir()
|
|
485
|
+
and (child / "requirements.txt").is_file()
|
|
486
|
+
and (child / "docker-compose.yml").is_file()
|
|
487
|
+
):
|
|
488
|
+
roots.append(child)
|
|
489
|
+
return sorted(roots)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _infer_port_from_service_root(service_root: Path) -> int:
|
|
493
|
+
try:
|
|
494
|
+
payload = yaml.safe_load((service_root / "docker-compose.yml").read_text(encoding="utf-8"))
|
|
495
|
+
except (OSError, yaml.YAMLError):
|
|
496
|
+
return 8080
|
|
497
|
+
|
|
498
|
+
if not isinstance(payload, dict):
|
|
499
|
+
return 8080
|
|
500
|
+
services = payload.get("services")
|
|
501
|
+
if not isinstance(services, dict) or not services:
|
|
502
|
+
return 8080
|
|
503
|
+
|
|
504
|
+
first_service = next(iter(services.values()))
|
|
505
|
+
if not isinstance(first_service, dict):
|
|
506
|
+
return 8080
|
|
507
|
+
ports = first_service.get("ports")
|
|
508
|
+
if not isinstance(ports, list) or not ports:
|
|
509
|
+
return 8080
|
|
510
|
+
|
|
511
|
+
first_port = ports[0]
|
|
512
|
+
if isinstance(first_port, int):
|
|
513
|
+
return first_port
|
|
514
|
+
if isinstance(first_port, str):
|
|
515
|
+
host_port = first_port.split(":", maxsplit=1)[0].strip()
|
|
516
|
+
if host_port.isdigit():
|
|
517
|
+
return int(host_port)
|
|
518
|
+
return 8080
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _infer_existing_workspace_service(service_root: Path) -> ServiceSpec:
|
|
522
|
+
return {
|
|
523
|
+
"name": service_root.name,
|
|
524
|
+
"type": "platform",
|
|
525
|
+
"role": "custom",
|
|
526
|
+
"port": _infer_port_from_service_root(service_root),
|
|
527
|
+
"features": [],
|
|
528
|
+
"requires": [],
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _ensure_unique_ports(services: list[ServiceSpec], preserve_name: str) -> None:
|
|
533
|
+
preserve = next((service for service in services if service["name"] == preserve_name), None)
|
|
534
|
+
used: set[int] = set()
|
|
535
|
+
if preserve is not None:
|
|
536
|
+
used.add(int(preserve["port"]))
|
|
537
|
+
|
|
538
|
+
for service in services:
|
|
539
|
+
if service["name"] == preserve_name:
|
|
540
|
+
continue
|
|
541
|
+
port = int(service["port"])
|
|
542
|
+
while port in used:
|
|
543
|
+
port += 1
|
|
544
|
+
service["port"] = port
|
|
545
|
+
used.add(port)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _maybe_update_workspace_cluster(
|
|
549
|
+
workspace_root: Path,
|
|
550
|
+
service_config: GenerateServiceArgs,
|
|
551
|
+
generated_service: Path,
|
|
552
|
+
) -> Path | None:
|
|
553
|
+
spec_path = _workspace_cluster_spec_path(workspace_root)
|
|
554
|
+
if spec_path.is_file():
|
|
555
|
+
updated_spec = _append_service_to_workspace_cluster(workspace_root, service_config)
|
|
556
|
+
return generate_cluster(updated_spec, workspace_root, force=True)
|
|
557
|
+
|
|
558
|
+
service_roots = _workspace_service_roots(workspace_root)
|
|
559
|
+
if len(service_roots) < 2:
|
|
560
|
+
return None
|
|
561
|
+
|
|
562
|
+
generated_name = generated_service.name
|
|
563
|
+
services: list[ServiceSpec] = [_service_config_to_cluster_service(service_config)]
|
|
564
|
+
for service_root in service_roots:
|
|
565
|
+
if service_root.name != generated_name:
|
|
566
|
+
services.append(_infer_existing_workspace_service(service_root))
|
|
567
|
+
|
|
568
|
+
_ensure_unique_ports(services, preserve_name=generated_name)
|
|
569
|
+
|
|
570
|
+
bootstrap_spec: ClusterSpec = {
|
|
571
|
+
"cluster": {"name": workspace_root.name, "preset": "none"},
|
|
572
|
+
"services": services,
|
|
573
|
+
}
|
|
574
|
+
normalized = normalize_cluster_spec(bootstrap_spec)
|
|
575
|
+
_save_workspace_cluster_spec(workspace_root, normalized)
|
|
576
|
+
return generate_cluster(normalized, workspace_root, force=True)
|
|
577
|
+
|
|
578
|
+
|
|
266
579
|
def _build_parser() -> argparse.ArgumentParser:
|
|
267
580
|
parser = argparse.ArgumentParser(prog="csrd", description="csrd utilities")
|
|
268
581
|
parser.add_argument(
|
|
@@ -342,6 +655,18 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
342
655
|
)
|
|
343
656
|
cluster.add_argument("--force", action="store_true", help="Overwrite existing generated path")
|
|
344
657
|
|
|
658
|
+
workspace = new_sub.add_parser(
|
|
659
|
+
"workspace", help="Create an empty workspace and optionally generate service/cluster"
|
|
660
|
+
)
|
|
661
|
+
workspace.add_argument("--name", help="Workspace directory name")
|
|
662
|
+
workspace.add_argument("--output", type=Path, default=Path.cwd(), help="Output directory")
|
|
663
|
+
workspace.add_argument(
|
|
664
|
+
"--generate",
|
|
665
|
+
choices=["none", "service", "cluster"],
|
|
666
|
+
help="Generate an asset inside the workspace after creation",
|
|
667
|
+
)
|
|
668
|
+
workspace.add_argument("--force", action="store_true", help="Overwrite existing workspace path")
|
|
669
|
+
|
|
345
670
|
return parser
|
|
346
671
|
|
|
347
672
|
|
|
@@ -388,7 +713,15 @@ def main() -> int:
|
|
|
388
713
|
if args.command == "new" and args.new_command == "service":
|
|
389
714
|
try:
|
|
390
715
|
service_config = _collect_new_service_config(args)
|
|
716
|
+
service_config["output_dir"] = _ensure_workspace_for_output(
|
|
717
|
+
service_config["output_dir"], allow_prompt=_service_is_interactive(args)
|
|
718
|
+
)
|
|
391
719
|
generated = generate_service(**service_config)
|
|
720
|
+
generated_cluster = _maybe_update_workspace_cluster(
|
|
721
|
+
service_config["output_dir"], service_config, generated
|
|
722
|
+
)
|
|
723
|
+
if generated_cluster is not None:
|
|
724
|
+
print(f"Refreshed cluster scaffold at: {generated_cluster}")
|
|
392
725
|
except (FileExistsError, ValueError) as exc:
|
|
393
726
|
print(f"ERROR: {exc}")
|
|
394
727
|
return 1
|
|
@@ -401,11 +734,15 @@ def main() -> int:
|
|
|
401
734
|
if args.command == "new" and args.new_command == "cluster":
|
|
402
735
|
try:
|
|
403
736
|
cluster_config = _collect_new_cluster_config(args)
|
|
737
|
+
cluster_config["output_dir"] = _ensure_workspace_for_output(
|
|
738
|
+
cluster_config["output_dir"], allow_prompt=args.spec is None
|
|
739
|
+
)
|
|
404
740
|
generated = generate_cluster(
|
|
405
741
|
cluster_config["spec"],
|
|
406
742
|
cluster_config["output_dir"],
|
|
407
743
|
force=cluster_config["overwrite_if_exists"],
|
|
408
744
|
)
|
|
745
|
+
_save_workspace_cluster_spec(cluster_config["output_dir"], cluster_config["spec"])
|
|
409
746
|
except (FileExistsError, FileNotFoundError, ValueError) as exc:
|
|
410
747
|
print(f"ERROR: {exc}")
|
|
411
748
|
return 1
|
|
@@ -415,6 +752,39 @@ def main() -> int:
|
|
|
415
752
|
print(f"Generated cluster at: {generated}")
|
|
416
753
|
return 0
|
|
417
754
|
|
|
755
|
+
if args.command == "new" and args.new_command == "workspace":
|
|
756
|
+
try:
|
|
757
|
+
workspace_config = _collect_new_workspace_config(args)
|
|
758
|
+
workspace_root = _create_workspace(workspace_config)
|
|
759
|
+
print(f"Generated workspace at: {workspace_root}")
|
|
760
|
+
|
|
761
|
+
generate_mode = workspace_config["generate"]
|
|
762
|
+
if generate_mode == "service":
|
|
763
|
+
service_config = _collect_new_service_config(
|
|
764
|
+
_default_service_namespace(workspace_root)
|
|
765
|
+
)
|
|
766
|
+
service_config["output_dir"] = workspace_root
|
|
767
|
+
generated_service = generate_service(**service_config)
|
|
768
|
+
print(f"Generated service at: {generated_service}")
|
|
769
|
+
elif generate_mode == "cluster":
|
|
770
|
+
cluster_config = _collect_new_cluster_config(
|
|
771
|
+
_default_cluster_namespace(workspace_root)
|
|
772
|
+
)
|
|
773
|
+
cluster_config["output_dir"] = workspace_root
|
|
774
|
+
generated_cluster = generate_cluster(
|
|
775
|
+
cluster_config["spec"],
|
|
776
|
+
cluster_config["output_dir"],
|
|
777
|
+
force=cluster_config["overwrite_if_exists"],
|
|
778
|
+
)
|
|
779
|
+
print(f"Generated cluster at: {generated_cluster}")
|
|
780
|
+
except (FileExistsError, FileNotFoundError, ValueError) as exc:
|
|
781
|
+
print(f"ERROR: {exc}")
|
|
782
|
+
return 1
|
|
783
|
+
except KeyboardInterrupt:
|
|
784
|
+
_print_cancelled()
|
|
785
|
+
return 1
|
|
786
|
+
return 0
|
|
787
|
+
|
|
418
788
|
if args.command == "doctor":
|
|
419
789
|
return _run_doctor(args.service, args.json)
|
|
420
790
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/features/workers/conftest.fragment.py
RENAMED
|
File without changes
|
{csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/features/workers/docker-compose.fragment.yaml
RENAMED
|
File without changes
|
|
File without changes
|
{csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/features/workers/requirements.fragment
RENAMED
|
File without changes
|
{csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/features/workers/settings.fragment.py
RENAMED
|
File without changes
|
{csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/features/workers/src/app/workers/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/features/workers/src/app/workers/tasks.py
RENAMED
|
File without changes
|
{csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/features/workers/tests/test_workers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{csrd_utils-0.3.13 → csrd_utils-0.3.14}/src/csrd_utils/templates/cookiecutter-service/README.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|