django-angular3 0.1.0__py3-none-any.whl

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 (54) hide show
  1. django_angular3/__init__.py +5 -0
  2. django_angular3/__main__.py +4 -0
  3. django_angular3/admin.py +4 -0
  4. django_angular3/angular.py +335 -0
  5. django_angular3/apps.py +7 -0
  6. django_angular3/build.py +66 -0
  7. django_angular3/cli.py +313 -0
  8. django_angular3/config.py +110 -0
  9. django_angular3/documents.py +52 -0
  10. django_angular3/examples/01_simple_crm/django-angular3.json +10 -0
  11. django_angular3/examples/01_simple_crm/manage.py +23 -0
  12. django_angular3/examples/01_simple_crm/shop/__init__.py +0 -0
  13. django_angular3/examples/01_simple_crm/shop/admin.py +17 -0
  14. django_angular3/examples/01_simple_crm/shop/apps.py +6 -0
  15. django_angular3/examples/01_simple_crm/shop/models.py +20 -0
  16. django_angular3/examples/01_simple_crm/shop/serializers.py +15 -0
  17. django_angular3/examples/01_simple_crm/shop/tests.py +1 -0
  18. django_angular3/examples/01_simple_crm/shop/views.py +24 -0
  19. django_angular3/examples/01_simple_crm/simple_crm/__init__.py +0 -0
  20. django_angular3/examples/01_simple_crm/simple_crm/asgi.py +7 -0
  21. django_angular3/examples/01_simple_crm/simple_crm/settings.py +107 -0
  22. django_angular3/examples/01_simple_crm/simple_crm/urls.py +14 -0
  23. django_angular3/examples/01_simple_crm/simple_crm/wsgi.py +7 -0
  24. django_angular3/examples/01_simple_crm/ui.json +13 -0
  25. django_angular3/examples/__init__.py +0 -0
  26. django_angular3/management/__init__.py +1 -0
  27. django_angular3/management/commands/__init__.py +1 -0
  28. django_angular3/management/commands/_base.py +57 -0
  29. django_angular3/management/commands/build_app.py +483 -0
  30. django_angular3/management/commands/export_schema.py +106 -0
  31. django_angular3/management/commands/ng_add.py +19 -0
  32. django_angular3/management/commands/ng_build.py +6 -0
  33. django_angular3/management/commands/ng_config.py +6 -0
  34. django_angular3/management/commands/ng_gen_app.py +22 -0
  35. django_angular3/management/commands/ng_new.py +6 -0
  36. django_angular3/management/commands/ng_openapi_gen.py +6 -0
  37. django_angular3/management/commands/ng_workspace.py +6 -0
  38. django_angular3/management/commands/ng_workspace_delete.py +6 -0
  39. django_angular3/management/commands/ng_workspace_modify.py +6 -0
  40. django_angular3/migrations/__init__.py +2 -0
  41. django_angular3/models.py +2 -0
  42. django_angular3/settings.py +123 -0
  43. django_angular3/static/django_angular3/.gitkeep +0 -0
  44. django_angular3/templates/django_angular3/.gitkeep +0 -0
  45. django_angular3/tools.py +134 -0
  46. django_angular3/urls.py +6 -0
  47. django_angular3/validation.py +169 -0
  48. django_angular3/views.py +2 -0
  49. django_angular3-0.1.0.dist-info/METADATA +296 -0
  50. django_angular3-0.1.0.dist-info/RECORD +54 -0
  51. django_angular3-0.1.0.dist-info/WHEEL +5 -0
  52. django_angular3-0.1.0.dist-info/entry_points.txt +2 -0
  53. django_angular3-0.1.0.dist-info/licenses/LICENSE +21 -0
  54. django_angular3-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,107 @@
1
+ """
2
+ Django settings for simple_crm project.
3
+ """
4
+
5
+ from pathlib import Path
6
+
7
+ BASE_DIR = Path(__file__).resolve().parent.parent
8
+
9
+ # SECURITY WARNING: keep the secret key used in production secret!
10
+ SECRET_KEY = "django-insecure-tutorial-only-do-not-use-in-production"
11
+
12
+ # SECURITY WARNING: don't run with debug turned on in production!
13
+ DEBUG = True
14
+
15
+ ALLOWED_HOSTS = []
16
+
17
+ INSTALLED_APPS = [
18
+ "django.contrib.admin",
19
+ "django.contrib.auth",
20
+ "django.contrib.contenttypes",
21
+ "django.contrib.sessions",
22
+ "django.contrib.messages",
23
+ "django.contrib.staticfiles",
24
+ "rest_framework",
25
+ "drf_spectacular",
26
+ "django_angular3",
27
+ "shop",
28
+ ]
29
+
30
+ MIDDLEWARE = [
31
+ "django.middleware.security.SecurityMiddleware",
32
+ "django.contrib.sessions.middleware.SessionMiddleware",
33
+ "django.middleware.common.CommonMiddleware",
34
+ "django.middleware.csrf.CsrfViewMiddleware",
35
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
36
+ "django.contrib.messages.middleware.MessageMiddleware",
37
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
38
+ ]
39
+
40
+ ROOT_URLCONF = "simple_crm.urls"
41
+
42
+ TEMPLATES = [
43
+ {
44
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
45
+ "DIRS": [],
46
+ "APP_DIRS": True,
47
+ "OPTIONS": {
48
+ "context_processors": [
49
+ "django.template.context_processors.request",
50
+ "django.contrib.auth.context_processors.auth",
51
+ "django.contrib.messages.context_processors.messages",
52
+ ],
53
+ },
54
+ },
55
+ ]
56
+
57
+ WSGI_APPLICATION = "simple_crm.wsgi.application"
58
+
59
+ DATABASES = {
60
+ "default": {
61
+ "ENGINE": "django.db.backends.sqlite3",
62
+ "NAME": BASE_DIR / "db.sqlite3",
63
+ }
64
+ }
65
+
66
+ AUTH_PASSWORD_VALIDATORS = [
67
+ {
68
+ "NAME": (
69
+ "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
70
+ )
71
+ },
72
+ {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
73
+ {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
74
+ {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
75
+ ]
76
+
77
+ LANGUAGE_CODE = "en-us"
78
+ TIME_ZONE = "UTC"
79
+ USE_I18N = True
80
+ USE_TZ = True
81
+
82
+ STATIC_URL = "static/"
83
+
84
+ REST_FRAMEWORK = {
85
+ "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
86
+ "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
87
+ "PAGE_SIZE": 10,
88
+ }
89
+
90
+ SPECTACULAR_SETTINGS = {
91
+ "TITLE": "Simple CRM API",
92
+ "VERSION": "1.0.0",
93
+ "SERVE_INCLUDE_SCHEMA": False,
94
+ }
95
+
96
+ DJANGO_ANGULAR3 = {
97
+ "command_allowlist": (
98
+ "ng_new",
99
+ "ng_add",
100
+ "ng_config",
101
+ "ng_gen_app",
102
+ "ng_openapi_gen",
103
+ "ng_build",
104
+ "ng_workspace_modify",
105
+ "ng_workspace_delete",
106
+ ),
107
+ }
@@ -0,0 +1,14 @@
1
+ from django.contrib import admin
2
+ from django.urls import include, path
3
+ from rest_framework import routers
4
+ from shop import views
5
+
6
+ router = routers.DefaultRouter()
7
+ router.register(r"customers", views.CustomerViewSet, basename="customer")
8
+ router.register(r"products", views.ProductViewSet, basename="product")
9
+
10
+ urlpatterns = [
11
+ path("admin/", admin.site.urls),
12
+ path("api/v1/", include(router.urls)),
13
+ path("api-auth/", include("rest_framework.urls", namespace="rest_framework")),
14
+ ]
@@ -0,0 +1,7 @@
1
+ import os
2
+
3
+ from django.core.wsgi import get_wsgi_application
4
+
5
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "simple_crm.settings")
6
+
7
+ application = get_wsgi_application()
@@ -0,0 +1,13 @@
1
+ {
2
+ "pages": [
3
+ { "name": "customer-list", "resource": "Customer", "type": "list" },
4
+ { "name": "customer-detail", "resource": "Customer", "type": "detail" },
5
+ { "name": "product-list", "resource": "Product", "type": "list" }
6
+ ],
7
+ "site": {
8
+ "nav": [
9
+ { "label": "Customers", "route": "/customers" },
10
+ { "label": "Products", "route": "/products" }
11
+ ]
12
+ }
13
+ }
File without changes
@@ -0,0 +1 @@
1
+ """Django management integration for django-angular3."""
@@ -0,0 +1 @@
1
+ """Management commands for django-angular3."""
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+
5
+ from django.core.management.base import BaseCommand, CommandError
6
+
7
+ from ...angular import (
8
+ AngularCommandError,
9
+ execute_invocations,
10
+ format_invocations,
11
+ resolve_angular_command,
12
+ )
13
+ from ...config import ConfigError
14
+
15
+
16
+ class AngularBaseCommand(BaseCommand):
17
+ """Base class for django-angular3 management commands that wrap
18
+ Angular tooling."""
19
+
20
+ angular_command_name = ""
21
+
22
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
23
+ parser.add_argument(
24
+ "path", nargs="?", default=None, help="Path to the project config."
25
+ )
26
+ parser.add_argument(
27
+ "--dry-run",
28
+ action="store_true",
29
+ help=(
30
+ "Print the resolved subprocess call list instead of invoking "
31
+ "Angular tooling."
32
+ ),
33
+ )
34
+
35
+ def get_invocation_options(self, _options: dict[str, object]) -> dict[str, object]:
36
+ return {}
37
+
38
+ def handle(self, *args, **options) -> None:
39
+ try:
40
+ invocations = resolve_angular_command(
41
+ self.angular_command_name,
42
+ options.get("path"),
43
+ **self.get_invocation_options(options),
44
+ )
45
+ except (AngularCommandError, ConfigError, TypeError, ValueError) as exc:
46
+ raise CommandError(str(exc)) from exc
47
+
48
+ if options["dry_run"]:
49
+ self.stdout.write(format_invocations(invocations))
50
+ return
51
+
52
+ try:
53
+ execute_invocations(invocations)
54
+ except AngularCommandError as exc:
55
+ raise CommandError(str(exc)) from exc
56
+
57
+ self.stdout.write(f"Executed {len(invocations)} command(s).")
@@ -0,0 +1,483 @@
1
+ import argparse
2
+ import datetime
3
+ import json
4
+ import subprocess
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from django.core.management.base import BaseCommand, CommandError
9
+
10
+ from ...config import ConfigError, get_previous_schema_path, load_project_config
11
+ from ...tools import ensure_oasdiff
12
+
13
+
14
+ def _command_for_skill(skill: str, mode: str) -> str:
15
+ """Maps a skill + mode pair to the corresponding management command name."""
16
+ overrides = {
17
+ ("angular-workspace-foundation", "create"): "ng_workspace",
18
+ ("angular-workspace-foundation", "modify"): "ng_workspace_modify",
19
+ ("angular-workspace-foundation", "delete"): "ng_workspace_delete",
20
+ ("angular-app-composition", "create"): "ng_gen_app",
21
+ ("angular-app-composition", "modify"): "ng_gen_app",
22
+ ("angular-api-integration", "create"): "ng_openapi_gen",
23
+ ("angular-api-integration", "modify"): "ng_openapi_gen",
24
+ }
25
+ return overrides.get((skill, mode), skill.replace("-", "_"))
26
+
27
+
28
+ class Command(BaseCommand):
29
+ help = "Generates a deterministic build plan based on OpenAPI and config changes."
30
+
31
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
32
+ parser.add_argument(
33
+ "config", help="Path to the django-angular3.json config file."
34
+ )
35
+ parser.add_argument(
36
+ "--previous-schema", help="Path to previous OpenAPI schema."
37
+ )
38
+ parser.add_argument(
39
+ "--previous-config", help="Path to previous django-angular3.json."
40
+ )
41
+ parser.add_argument(
42
+ "--output-format",
43
+ choices=["json", "yaml", "text"],
44
+ default="json",
45
+ help="Format of the emitted build plan.",
46
+ )
47
+ parser.add_argument(
48
+ "--dry-run", action="store_true", help="Print plan without writing to disk."
49
+ )
50
+ parser.add_argument(
51
+ "--output",
52
+ default="build",
53
+ help="Directory to write the plan (build-plan.ext).",
54
+ )
55
+ parser.add_argument(
56
+ "--force",
57
+ choices=["start-from-scratch"],
58
+ help="Override change detection; treat as start-from-scratch.",
59
+ )
60
+ parser.add_argument(
61
+ "--acknowledge-breaking",
62
+ action="store_true",
63
+ help="Proceed even if breaking schema changes are detected.",
64
+ )
65
+
66
+ def _build_step(
67
+ self,
68
+ step_num: int,
69
+ skill: str,
70
+ mode: str,
71
+ reason: str,
72
+ config_path: str,
73
+ resource_name: str | None = None,
74
+ ) -> dict[str, object]:
75
+ cmd_name = _command_for_skill(skill, mode)
76
+ base_cmd = f"django-admin {cmd_name} {config_path}"
77
+ if resource_name:
78
+ base_cmd += f" --resource {resource_name}"
79
+ step: dict[str, Any] = {
80
+ "step": step_num,
81
+ "skill": skill,
82
+ "mode": mode,
83
+ "reason": reason,
84
+ "command": base_cmd,
85
+ "dry_run_command": base_cmd + " --dry-run",
86
+ }
87
+ if resource_name:
88
+ step["resource_name"] = resource_name
89
+ return step
90
+
91
+ def _diff_schemas(
92
+ self, previous_schema: str, current_schema: str
93
+ ) -> dict[str, Any]:
94
+ oasdiff_exe = ensure_oasdiff()
95
+
96
+ cmd: list[str] = [
97
+ oasdiff_exe,
98
+ "diff",
99
+ previous_schema,
100
+ current_schema,
101
+ "--format",
102
+ "json",
103
+ ]
104
+
105
+ try:
106
+ result: subprocess.CompletedProcess[str] = subprocess.run(
107
+ cmd, capture_output=True, text=True, check=True
108
+ )
109
+ if not result.stdout.strip():
110
+ return {} # No changes
111
+ return json.loads(result.stdout)
112
+ except subprocess.CalledProcessError as e:
113
+ # oasdiff might return non-zero exit code if it finds changes
114
+ # or breaking changes, depending on flags. Usually 'diff'
115
+ # returns 0, but if there's an error parsing the spec, it
116
+ # might fail.
117
+ try:
118
+ if e.stdout.strip():
119
+ return json.loads(e.stdout)
120
+ except json.JSONDecodeError:
121
+ pass
122
+ raise CommandError(f"oasdiff failed: {e.stderr}") from e
123
+
124
+ def _extract_resources(
125
+ self, path_list: list[str], path_dict: dict[str, Any]
126
+ ) -> set[str]:
127
+ """Extract base resource names from OpenAPI paths like
128
+ '/api/v1/customers/' -> 'customers'."""
129
+ resources: set[str] = set()
130
+
131
+ # Handle lists (added/deleted)
132
+ for p in path_list:
133
+ parts: list[str] = [
134
+ part for part in p.split("/") if part and not part.startswith("{")
135
+ ]
136
+ if parts:
137
+ resources.add(parts[-1]) # Rough heuristic for resource name
138
+
139
+ # Handle dicts (modified)
140
+ for p in path_dict.keys():
141
+ parts: list[str] = [
142
+ part for part in p.split("/") if part and not part.startswith("{")
143
+ ]
144
+ if parts:
145
+ resources.add(parts[-1])
146
+
147
+ return resources
148
+
149
+ def _evaluate_schema_changes(self, diff_result: dict[str, Any]) -> dict[str, Any]:
150
+ paths_diff = diff_result.get("paths", {})
151
+ added_paths = paths_diff.get("added", [])
152
+ deleted_paths = paths_diff.get("deleted", [])
153
+ modified_paths = paths_diff.get("modified", {})
154
+
155
+ added = len(added_paths) > 0
156
+ deleted = len(deleted_paths) > 0
157
+ modified = len(modified_paths) > 0
158
+
159
+ added_resources = self._extract_resources(added_paths, {})
160
+ removed_resources = self._extract_resources(deleted_paths, {})
161
+ modified_resources = self._extract_resources([], modified_paths)
162
+ affected_resources = added_resources | removed_resources | modified_resources
163
+
164
+ if added and not deleted and not modified:
165
+ change_type = "add-things"
166
+ elif deleted and not added and not modified:
167
+ change_type = "remove-things"
168
+ elif added or deleted or modified:
169
+ change_type = "replace-things"
170
+ else:
171
+ change_type = "no-change"
172
+
173
+ return {
174
+ "type": change_type,
175
+ "affected_resources": sorted(affected_resources),
176
+ "added_resources": sorted(added_resources),
177
+ "removed_resources": sorted(removed_resources),
178
+ "breaking": False,
179
+ "oasdiff_report": diff_result,
180
+ }
181
+
182
+ def _diff_config(
183
+ self, previous_config_path: str, current_config_path: str
184
+ ) -> dict[str, Any]:
185
+ try:
186
+ prev_cfg = load_project_config(previous_config_path)
187
+ curr_cfg = load_project_config(current_config_path)
188
+ except ConfigError as e:
189
+ raise CommandError(f"Config load failed: {e}") from e
190
+
191
+ if prev_cfg.project_name != curr_cfg.project_name:
192
+ return {"type": "replace-things"} # project rename implies scratch
193
+
194
+ return {
195
+ "type": "no-change",
196
+ "affected_pages": [],
197
+ "affected_components": [],
198
+ "affected_forms": [],
199
+ }
200
+
201
+ def handle(self, *args: list[str], **options: dict[str, Any]) -> None:
202
+ config_path: str | Any = options["config"]
203
+
204
+ try:
205
+ current_config = load_project_config(config_path)
206
+ except ConfigError as exc:
207
+ raise CommandError(str(exc)) from exc
208
+
209
+ current_schema_path: Path = current_config.openapi_source
210
+ if not current_schema_path:
211
+ raise CommandError("Config missing openapi.source")
212
+
213
+ # Resolve previous schema: if not provided via --previous-schema, auto-discover
214
+ # the conventional .previous artifact written by export_schema.
215
+ prev_schema_path: str | Any = options["previous_schema"]
216
+ if not prev_schema_path:
217
+ auto_previous = get_previous_schema_path(current_config.openapi_source)
218
+ if auto_previous.exists():
219
+ prev_schema_path = str(auto_previous)
220
+ self.stdout.write(f"Auto-detected previous schema: {prev_schema_path}")
221
+
222
+ # Ensure we have oasdiff installed via JIT
223
+ try:
224
+ ensure_oasdiff()
225
+ except RuntimeError as e:
226
+ raise CommandError(str(e)) from e
227
+
228
+ change_set: dict[str, Any] = {
229
+ "schema": {
230
+ "type": "start-from-scratch",
231
+ "affected_resources": [],
232
+ "breaking": False,
233
+ "oasdiff_report": None,
234
+ },
235
+ "config": {
236
+ "type": "no-change",
237
+ "affected_pages": [],
238
+ "affected_components": [],
239
+ "affected_forms": [],
240
+ },
241
+ }
242
+
243
+ # 1. Schema Change Detection
244
+ if (
245
+ str(options["force"]) == "start-from-scratch"
246
+ or not prev_schema_path
247
+ or not Path(prev_schema_path).exists()
248
+ ):
249
+ change_set["schema"]["type"] = "start-from-scratch"
250
+
251
+ # To extract resources for start-from-scratch, we diff against an empty spec
252
+ import tempfile
253
+
254
+ with tempfile.NamedTemporaryFile("w+", suffix=".json", delete=False) as f:
255
+ json.dump(
256
+ {
257
+ "openapi": "3.0.0",
258
+ "info": {"title": "empty", "version": "1.0.0"},
259
+ "paths": {},
260
+ },
261
+ f,
262
+ )
263
+ empty_schema_path = f.name
264
+
265
+ try:
266
+ diff_result: dict[str, Any] = self._diff_schemas(
267
+ empty_schema_path, current_schema_path
268
+ )
269
+ schema_changes = self._evaluate_schema_changes(diff_result)
270
+ # Keep type as start-from-scratch, but inherit the affected resources
271
+ change_set["schema"]["affected_resources"] = schema_changes[
272
+ "affected_resources"
273
+ ]
274
+ change_set["schema"]["oasdiff_report"] = diff_result
275
+ finally:
276
+ Path(empty_schema_path).unlink(missing_ok=True)
277
+
278
+ else:
279
+ self.stdout.write("Running oasdiff for schema changes...")
280
+
281
+ # Detect structural diffs
282
+ diff_result = self._diff_schemas(prev_schema_path, current_schema_path)
283
+ schema_changes = self._evaluate_schema_changes(diff_result)
284
+
285
+ # Detect breaking changes using `oasdiff breaking`
286
+ cmd_break: list[str] = [
287
+ ensure_oasdiff(),
288
+ "breaking",
289
+ prev_schema_path,
290
+ str(current_schema_path),
291
+ "--format",
292
+ "json",
293
+ ]
294
+ try:
295
+ break_result = subprocess.run(
296
+ cmd_break, capture_output=True, text=True, check=False
297
+ )
298
+ break_json: list[dict[str, Any]] = (
299
+ json.loads(break_result.stdout)
300
+ if break_result.stdout.strip()
301
+ else []
302
+ )
303
+ if break_json:
304
+ schema_changes["breaking"] = True
305
+ except json.JSONDecodeError:
306
+ pass
307
+
308
+ if schema_changes["breaking"] and not options["acknowledge_breaking"]:
309
+ if callable(getattr(self.style, "ERROR", None)):
310
+ self.stderr.write(
311
+ self.style.ERROR(
312
+ "Breaking schema changes detected. Review "
313
+ "the oasdiff report before proceeding.\n"
314
+ "Re-run with --acknowledge-breaking to continue."
315
+ )
316
+ )
317
+ else:
318
+ self.stderr.write(
319
+ "Breaking schema changes detected. Review "
320
+ "the oasdiff report before proceeding.\n"
321
+ "Re-run with --acknowledge-breaking to continue."
322
+ )
323
+ raise SystemExit(2)
324
+
325
+ change_set["schema"] = schema_changes
326
+
327
+ # 2. Config Change Detection
328
+ prev_config_path: str | Any = options["previous_config"]
329
+ if prev_config_path and Path(prev_config_path).exists():
330
+ change_set["config"] = self._diff_config(prev_config_path, config_path)
331
+
332
+ # 3. Build ordered steps
333
+ schema_type = change_set["schema"]["type"]
334
+ resources = change_set["schema"]["affected_resources"]
335
+ steps: list[dict[str, object]] = []
336
+
337
+ if schema_type == "start-from-scratch":
338
+ steps.append(
339
+ self._build_step(
340
+ len(steps) + 1,
341
+ "ng-workspace",
342
+ "create",
343
+ "Start from scratch: create Angular workspace",
344
+ config_path,
345
+ )
346
+ )
347
+ steps.append(
348
+ self._build_step(
349
+ len(steps) + 1,
350
+ "ng-app",
351
+ "create",
352
+ "Start from scratch: generate Angular application",
353
+ config_path,
354
+ )
355
+ )
356
+ steps.append(
357
+ self._build_step(
358
+ len(steps) + 1,
359
+ "ng-api",
360
+ "create",
361
+ "Start from scratch: generate API client from OpenAPI schema",
362
+ config_path,
363
+ )
364
+ )
365
+ for resource in resources:
366
+ steps.append(
367
+ self._build_step(
368
+ len(steps) + 1,
369
+ "ng-data-service",
370
+ "create",
371
+ f"Start from scratch: generate data service for '{resource}'",
372
+ config_path,
373
+ resource_name=resource,
374
+ )
375
+ )
376
+
377
+ elif schema_type == "add-things":
378
+ steps.append(
379
+ self._build_step(
380
+ len(steps) + 1,
381
+ "ng-api",
382
+ "modify",
383
+ "Schema changed (add-things): regenerate API client",
384
+ config_path,
385
+ )
386
+ )
387
+ for resource in resources:
388
+ steps.append(
389
+ self._build_step(
390
+ len(steps) + 1,
391
+ "ng-data-service",
392
+ "modify",
393
+ (
394
+ "Schema changed (add-things): update data "
395
+ f"service for '{resource}'"
396
+ ),
397
+ config_path,
398
+ resource_name=resource,
399
+ )
400
+ )
401
+
402
+ elif schema_type == "replace-things":
403
+ removed = change_set["schema"].get("removed_resources", [])
404
+ added = change_set["schema"].get("added_resources", [])
405
+ # Delete removed resources first, then regenerate the API, then add new ones
406
+ for resource in removed:
407
+ steps.append(
408
+ self._build_step(
409
+ len(steps) + 1,
410
+ "ng-data-service",
411
+ "delete",
412
+ f"Resource '{resource}' removed: delete data service",
413
+ config_path,
414
+ resource_name=resource,
415
+ )
416
+ )
417
+ steps.append(
418
+ self._build_step(
419
+ len(steps) + 1,
420
+ "ng-api",
421
+ "modify",
422
+ "Schema changed (replace-things): regenerate API client",
423
+ config_path,
424
+ )
425
+ )
426
+ for resource in added:
427
+ steps.append(
428
+ self._build_step(
429
+ len(steps) + 1,
430
+ "ng-data-service",
431
+ "modify",
432
+ f"Resource '{resource}' added: create or update data service",
433
+ config_path,
434
+ resource_name=resource,
435
+ )
436
+ )
437
+
438
+ elif schema_type == "remove-things":
439
+ steps.append(
440
+ self._build_step(
441
+ len(steps) + 1,
442
+ "ng-api",
443
+ "modify",
444
+ "Schema changed (remove-things): regenerate API client",
445
+ config_path,
446
+ )
447
+ )
448
+ for resource in resources:
449
+ steps.append(
450
+ self._build_step(
451
+ len(steps) + 1,
452
+ "ng-data-service",
453
+ "delete",
454
+ f"Resource '{resource}' removed: delete data service",
455
+ config_path,
456
+ resource_name=resource,
457
+ )
458
+ )
459
+
460
+ build_plan: dict[str, Any] = {
461
+ "generated_at": datetime.datetime.now(datetime.UTC).isoformat(),
462
+ "config": config_path,
463
+ "change_set": change_set,
464
+ "steps": steps,
465
+ }
466
+
467
+ self._emit_plan(build_plan, options)
468
+
469
+ def _emit_plan(self, build_plan: dict[str, Any], options: dict[str, Any]) -> None:
470
+ plan_str: str = json.dumps(build_plan, indent=2)
471
+
472
+ if options["dry_run"]:
473
+ self.stdout.write("--- DRY RUN: Build Plan ---")
474
+ self.stdout.write(plan_str)
475
+ return
476
+
477
+ out_dir = Path(options["output"])
478
+ out_dir.mkdir(parents=True, exist_ok=True)
479
+ ext = options["output_format"]
480
+
481
+ out_file = out_dir / f"build-plan.{ext}"
482
+ out_file.write_text(plan_str, encoding="utf-8")
483
+ self.stdout.write(f"Build plan written to {out_file}")