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.
- django_angular3/__init__.py +5 -0
- django_angular3/__main__.py +4 -0
- django_angular3/admin.py +4 -0
- django_angular3/angular.py +335 -0
- django_angular3/apps.py +7 -0
- django_angular3/build.py +66 -0
- django_angular3/cli.py +313 -0
- django_angular3/config.py +110 -0
- django_angular3/documents.py +52 -0
- django_angular3/examples/01_simple_crm/django-angular3.json +10 -0
- django_angular3/examples/01_simple_crm/manage.py +23 -0
- django_angular3/examples/01_simple_crm/shop/__init__.py +0 -0
- django_angular3/examples/01_simple_crm/shop/admin.py +17 -0
- django_angular3/examples/01_simple_crm/shop/apps.py +6 -0
- django_angular3/examples/01_simple_crm/shop/models.py +20 -0
- django_angular3/examples/01_simple_crm/shop/serializers.py +15 -0
- django_angular3/examples/01_simple_crm/shop/tests.py +1 -0
- django_angular3/examples/01_simple_crm/shop/views.py +24 -0
- django_angular3/examples/01_simple_crm/simple_crm/__init__.py +0 -0
- django_angular3/examples/01_simple_crm/simple_crm/asgi.py +7 -0
- django_angular3/examples/01_simple_crm/simple_crm/settings.py +107 -0
- django_angular3/examples/01_simple_crm/simple_crm/urls.py +14 -0
- django_angular3/examples/01_simple_crm/simple_crm/wsgi.py +7 -0
- django_angular3/examples/01_simple_crm/ui.json +13 -0
- django_angular3/examples/__init__.py +0 -0
- django_angular3/management/__init__.py +1 -0
- django_angular3/management/commands/__init__.py +1 -0
- django_angular3/management/commands/_base.py +57 -0
- django_angular3/management/commands/build_app.py +483 -0
- django_angular3/management/commands/export_schema.py +106 -0
- django_angular3/management/commands/ng_add.py +19 -0
- django_angular3/management/commands/ng_build.py +6 -0
- django_angular3/management/commands/ng_config.py +6 -0
- django_angular3/management/commands/ng_gen_app.py +22 -0
- django_angular3/management/commands/ng_new.py +6 -0
- django_angular3/management/commands/ng_openapi_gen.py +6 -0
- django_angular3/management/commands/ng_workspace.py +6 -0
- django_angular3/management/commands/ng_workspace_delete.py +6 -0
- django_angular3/management/commands/ng_workspace_modify.py +6 -0
- django_angular3/migrations/__init__.py +2 -0
- django_angular3/models.py +2 -0
- django_angular3/settings.py +123 -0
- django_angular3/static/django_angular3/.gitkeep +0 -0
- django_angular3/templates/django_angular3/.gitkeep +0 -0
- django_angular3/tools.py +134 -0
- django_angular3/urls.py +6 -0
- django_angular3/validation.py +169 -0
- django_angular3/views.py +2 -0
- django_angular3-0.1.0.dist-info/METADATA +296 -0
- django_angular3-0.1.0.dist-info/RECORD +54 -0
- django_angular3-0.1.0.dist-info/WHEEL +5 -0
- django_angular3-0.1.0.dist-info/entry_points.txt +2 -0
- django_angular3-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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,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}")
|