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,106 @@
1
+ """Management command: export_schema
2
+
3
+ Export the OpenAPI schema from DRF using drf-spectacular and persist it as a
4
+ durable, versioned artifact at the path configured in ``openapi.source``.
5
+
6
+ Before writing, the command rotates the existing schema to its ``.previous``
7
+ counterpart (e.g. ``api.json`` → ``api.previous.json``) so that ``build_app``
8
+ can compare the two versions for change detection without requiring the caller
9
+ to manage file paths explicitly.
10
+
11
+ Usage::
12
+
13
+ django-admin export_schema django-angular3.json
14
+ django-admin export_schema django-angular3.json --format yaml
15
+ django-admin export_schema django-angular3.json --dry-run
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+
22
+ from django.core.management.base import BaseCommand, CommandError
23
+
24
+ from ...config import ConfigError, get_previous_schema_path, load_project_config
25
+
26
+
27
+ class Command(BaseCommand):
28
+ help = (
29
+ "Export the OpenAPI schema from DRF (via drf-spectacular) and persist "
30
+ "it as a versioned artifact. The previous schema is archived alongside "
31
+ "the current one for use by build_app change detection."
32
+ )
33
+
34
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
35
+ parser.add_argument(
36
+ "config",
37
+ help="Path to the django-angular3.json project config file.",
38
+ )
39
+ parser.add_argument(
40
+ "--format",
41
+ dest="format",
42
+ choices=["json", "yaml"],
43
+ default="json",
44
+ help="Serialization format for the exported schema (default: json).",
45
+ )
46
+ parser.add_argument(
47
+ "--dry-run",
48
+ action="store_true",
49
+ help=(
50
+ "Generate the schema and display the artifact paths that would be "
51
+ "written, but do not modify any files on disk."
52
+ ),
53
+ )
54
+
55
+ def handle(self, *args, **options) -> None:
56
+ try:
57
+ config = load_project_config(options["config"])
58
+ except ConfigError as exc:
59
+ raise CommandError(str(exc)) from exc
60
+
61
+ destination = config.openapi_source
62
+ previous_path = get_previous_schema_path(destination)
63
+
64
+ if options["dry_run"]:
65
+ self.stdout.write("--- DRY RUN: export_schema ---")
66
+ self.stdout.write(f" destination : {destination}")
67
+ if destination.exists():
68
+ self.stdout.write(
69
+ f" previous : {previous_path} (would archive current)"
70
+ )
71
+ self.stdout.write(" (no files written)")
72
+ return
73
+
74
+ # Generate the schema via drf-spectacular.
75
+ try:
76
+ from drf_spectacular.generators import (
77
+ SchemaGenerator, # type: ignore[import-untyped]
78
+ )
79
+ from drf_spectacular.renderers import ( # type: ignore[import-untyped]
80
+ OpenApiJsonRenderer,
81
+ OpenApiYamlRenderer,
82
+ )
83
+ except ImportError as exc: # pragma: no cover
84
+ raise CommandError(
85
+ "drf-spectacular is required for schema export. "
86
+ "Install it with: pip install drf-spectacular"
87
+ ) from exc
88
+
89
+ generator = SchemaGenerator()
90
+ schema = generator.get_schema(request=None, public=True)
91
+
92
+ renderer = (
93
+ OpenApiYamlRenderer()
94
+ if options["format"] == "yaml"
95
+ else OpenApiJsonRenderer()
96
+ )
97
+ schema_bytes: bytes = renderer.render(schema, renderer_context={})
98
+
99
+ # Rotate current → previous before writing the new schema.
100
+ if destination.exists():
101
+ destination.replace(previous_path)
102
+ self.stdout.write(f"Previous schema archived to: {previous_path}")
103
+
104
+ destination.parent.mkdir(parents=True, exist_ok=True)
105
+ destination.write_bytes(schema_bytes)
106
+ self.stdout.write(self.style.SUCCESS(f"Schema exported to: {destination}"))
@@ -0,0 +1,19 @@
1
+ import argparse
2
+
3
+ from ._base import AngularBaseCommand
4
+
5
+
6
+ class Command(AngularBaseCommand):
7
+ angular_command_name = "ng_add"
8
+ help = "Run ng add for an Angular package in the configured workspace."
9
+
10
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
11
+ super().add_arguments(parser)
12
+ parser.add_argument(
13
+ "--package",
14
+ default=None,
15
+ help="Package to add (defaults to setting: ng_add_package).",
16
+ )
17
+
18
+ def get_invocation_options(self, options: dict[str, object]) -> dict[str, object]:
19
+ return {"package": options.get("package")}
@@ -0,0 +1,6 @@
1
+ from ._base import AngularBaseCommand
2
+
3
+
4
+ class Command(AngularBaseCommand):
5
+ angular_command_name = "ng_build"
6
+ help = "Build the configured Angular application."
@@ -0,0 +1,6 @@
1
+ from ._base import AngularBaseCommand
2
+
3
+
4
+ class Command(AngularBaseCommand):
5
+ angular_command_name = "ng_config"
6
+ help = "Apply django-angular3 Angular workspace defaults."
@@ -0,0 +1,22 @@
1
+ import argparse
2
+
3
+ from ._base import AngularBaseCommand
4
+
5
+
6
+ class Command(AngularBaseCommand):
7
+ angular_command_name = "ng_gen_app"
8
+ help = "Generate an Angular application inside the configured workspace."
9
+
10
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
11
+ super().add_arguments(parser)
12
+ parser.add_argument(
13
+ "--app-name",
14
+ default=None,
15
+ help=(
16
+ "Optional Angular application name. Defaults to "
17
+ "project.name from config."
18
+ ),
19
+ )
20
+
21
+ def get_invocation_options(self, options: dict[str, object]) -> dict[str, object]:
22
+ return {"app_name": options.get("app_name")}
@@ -0,0 +1,6 @@
1
+ from ._base import AngularBaseCommand
2
+
3
+
4
+ class Command(AngularBaseCommand):
5
+ angular_command_name = "ng_new"
6
+ help = "Create an empty Angular workspace for django-angular3."
@@ -0,0 +1,6 @@
1
+ from ._base import AngularBaseCommand
2
+
3
+
4
+ class Command(AngularBaseCommand):
5
+ angular_command_name = "ng_openapi_gen"
6
+ help = "Run ng-openapi-gen for the configured OpenAPI source."
@@ -0,0 +1,6 @@
1
+ from ._base import AngularBaseCommand
2
+
3
+
4
+ class Command(AngularBaseCommand):
5
+ angular_command_name = "ng_workspace"
6
+ help = "Create and bootstrap an Angular workspace with angular-django2."
@@ -0,0 +1,6 @@
1
+ from ._base import AngularBaseCommand
2
+
3
+
4
+ class Command(AngularBaseCommand):
5
+ angular_command_name = "ng_workspace_delete"
6
+ help = "Delete the generated Angular workspace entirely."
@@ -0,0 +1,6 @@
1
+ from ._base import AngularBaseCommand
2
+
3
+
4
+ class Command(AngularBaseCommand):
5
+ angular_command_name = "ng_workspace_modify"
6
+ help = "Reapply angular-django2 workspace bootstrap and django-angular3 defaults."
@@ -0,0 +1,2 @@
1
+ # This Django app does not currently define any models,
2
+ # so there are no migrations to apply.
@@ -0,0 +1,2 @@
1
+ # This Django app does not currently define any models.
2
+ # Models may be added in the future if database-backed features are needed.
@@ -0,0 +1,123 @@
1
+ import os
2
+ from collections.abc import Mapping, Sequence
3
+ from types import SimpleNamespace
4
+ from typing import Any
5
+
6
+
7
+ class AngularCommandError(RuntimeError):
8
+ """Raised when an Angular command cannot be resolved or executed."""
9
+
10
+
11
+ _is_win = os.name == "nt"
12
+
13
+ DEFAULT_ANGULAR_SETTINGS: dict[str, Any] = {
14
+ "config_path": "django-angular3.json",
15
+ "node_executable": "node.exe" if _is_win else "node",
16
+ "pnpm_executable": "pnpm.cmd" if _is_win else "pnpm",
17
+ "ng_executable": "ng.cmd" if _is_win else "ng",
18
+ "command_allowlist": ("ng_openapi_gen",),
19
+ "package_manager": "pnpm",
20
+ "build_configuration": "production",
21
+ "style": "scss",
22
+ "routing": True,
23
+ "ng_add_package": "angular-django2",
24
+ }
25
+
26
+
27
+ class AngularSettings(SimpleNamespace):
28
+ """Configuration values used to resolve and run Angular-related commands.
29
+
30
+ Attributes:
31
+ config_path (str): Default project config path.
32
+ node_executable (str): Node executable name or path.
33
+ pnpm_executable (str): pnpm executable name or path.
34
+ ng_executable (str): Angular CLI executable name or path.
35
+ command_allowlist (tuple[str, ...]): Allowed resolved
36
+ django-angular3 command names.
37
+ package_manager (str): Angular package manager setting.
38
+ build_configuration (str): Angular build configuration name.
39
+ style (str): Default Angular stylesheet format.
40
+ routing (bool): Whether generated applications enable routing.
41
+ ng_add_package (str): The default package name or path to install for ng_add.
42
+ """
43
+
44
+
45
+ def load_angular_settings(
46
+ overrides: Mapping[str, object] | None = None,
47
+ ) -> AngularSettings:
48
+ data = DEFAULT_ANGULAR_SETTINGS.copy()
49
+ data.update(_normalize_legacy_settings(_load_django_settings()))
50
+ if overrides:
51
+ data.update(_normalize_legacy_settings(overrides))
52
+ data["command_allowlist"] = _normalize_command_allowlist(
53
+ data.get("command_allowlist")
54
+ )
55
+ return AngularSettings(**data)
56
+
57
+
58
+ def _load_django_settings() -> dict[str, object]:
59
+ try:
60
+ from django.conf import settings as django_settings
61
+ from django.core.exceptions import ImproperlyConfigured
62
+ except ImportError:
63
+ return {}
64
+
65
+ try:
66
+ if not getattr(django_settings, "configured", False):
67
+ return {}
68
+
69
+ value = getattr(django_settings, "DJANGO_ANGULAR3", {})
70
+ except ImproperlyConfigured:
71
+ return {}
72
+
73
+ if not isinstance(value, Mapping):
74
+ raise AngularCommandError(
75
+ "DJANGO_ANGULAR3 must be a dictionary-like mapping, got "
76
+ f"{type(value).__name__}."
77
+ )
78
+
79
+ return dict(value)
80
+
81
+
82
+ def _normalize_legacy_settings(data: Mapping[str, object]) -> dict[str, object]:
83
+ normalized = dict(data)
84
+ if "pnpm_executable" not in normalized:
85
+ for legacy_key in ("npx_executable", "npm_executable"):
86
+ if legacy_key in normalized:
87
+ normalized["pnpm_executable"] = normalized[legacy_key]
88
+ break
89
+
90
+ normalized.pop("npm_executable", None)
91
+ normalized.pop("npx_executable", None)
92
+ return normalized
93
+
94
+
95
+ def _normalize_command_allowlist(value: object) -> tuple[str, ...]:
96
+ if isinstance(value, str):
97
+ commands = (value,)
98
+ elif isinstance(value, Sequence):
99
+ commands = tuple(value)
100
+ else:
101
+ raise AngularCommandError(
102
+ "DJANGO_ANGULAR3['command_allowlist'] must be a string "
103
+ "or a sequence of strings."
104
+ )
105
+
106
+ normalized_commands: list[str] = []
107
+ for command in commands:
108
+ if not isinstance(command, str):
109
+ raise AngularCommandError(
110
+ "DJANGO_ANGULAR3['command_allowlist'] must only contain strings."
111
+ )
112
+
113
+ normalized_command = command.strip().lower()
114
+ if not normalized_command:
115
+ raise AngularCommandError(
116
+ "DJANGO_ANGULAR3['command_allowlist'] cannot contain "
117
+ "empty command names."
118
+ )
119
+
120
+ if normalized_command not in normalized_commands:
121
+ normalized_commands.append(normalized_command)
122
+
123
+ return tuple(normalized_commands)
File without changes
File without changes
@@ -0,0 +1,134 @@
1
+ import json
2
+ import os
3
+ import platform
4
+ import tarfile
5
+ import urllib.request
6
+ import zipfile
7
+ from pathlib import Path
8
+
9
+ # Base directory for storing downloaded tools relative to this package
10
+ PKG_DIR = Path(__file__).resolve().parent
11
+ BIN_DIR = PKG_DIR / ".bin"
12
+
13
+
14
+ def get_system_info():
15
+ """Returns normalized OS and architecture strings."""
16
+ os_name = platform.system().lower()
17
+ if os_name == "darwin":
18
+ os_name = "macos"
19
+
20
+ arch = platform.machine().lower()
21
+ if arch in ["x86_64", "amd64"]:
22
+ arch = "amd64"
23
+ elif arch in ["arm64", "aarch64"]:
24
+ arch = "arm64"
25
+ elif arch in ["i386", "i686", "x86"]:
26
+ arch = "386"
27
+
28
+ return os_name, arch
29
+
30
+
31
+ def get_latest_oasdiff_release():
32
+ """Fetches the latest release info from oasdiff GitHub repository."""
33
+ url = "https://api.github.com/repos/Tufin/oasdiff/releases/latest"
34
+ req = urllib.request.Request(url, headers={"User-Agent": "django-angular3"})
35
+ try:
36
+ with urllib.request.urlopen(req) as response:
37
+ data = json.loads(response.read().decode("utf-8"))
38
+ return data
39
+ except Exception as e:
40
+ raise RuntimeError(f"Failed to fetch latest oasdiff version: {e}")
41
+
42
+
43
+ def get_download_url(release_data, os_name, arch):
44
+ """Finds the correct asset URL for the current OS and architecture."""
45
+ # oasdiff release naming pattern: oasdiff_<version>_<os>_<arch>.tar.gz/zip
46
+ # e.g., oasdiff_1.21.3_linux_amd64.tar.gz
47
+ # e.g., oasdiff_1.21.3_windows_amd64.zip
48
+
49
+ # Map our normalized OS name to oasdiff's naming conventions
50
+ # sometimes they use darwin, sometimes macos.
51
+ search_os = "darwin" if os_name == "macos" else os_name
52
+
53
+ # In recent versions, windows is windows, linux is linux.
54
+
55
+ for asset in release_data.get("assets", []):
56
+ name = asset["name"].lower()
57
+ if search_os in name and arch in name:
58
+ if name.endswith(".tar.gz") or name.endswith(".zip"):
59
+ return asset["browser_download_url"], asset["name"]
60
+
61
+ raise RuntimeError(
62
+ f"Could not find a suitable oasdiff binary for {os_name} {arch}."
63
+ )
64
+
65
+
66
+ def extract_archive(archive_path, extract_to):
67
+ """Extracts a .zip or .tar.gz archive."""
68
+ if archive_path.name.endswith(".zip"):
69
+ with zipfile.ZipFile(archive_path, "r") as zip_ref:
70
+ zip_ref.extractall(extract_to)
71
+ elif archive_path.name.endswith(".tar.gz"):
72
+ with tarfile.open(archive_path, "r:gz") as tar_ref:
73
+ if hasattr(tarfile, "data_filter"):
74
+ tar_ref.extractall(extract_to, filter="data")
75
+ else:
76
+ tar_ref.extractall(extract_to)
77
+ else:
78
+ raise ValueError(f"Unsupported archive format: {archive_path.name}")
79
+
80
+
81
+ def ensure_oasdiff():
82
+ """
83
+ Ensures oasdiff is installed and available.
84
+ Returns the absolute path to the oasdiff executable.
85
+ """
86
+ BIN_DIR.mkdir(parents=True, exist_ok=True)
87
+
88
+ exe_name = "oasdiff.exe" if platform.system().lower() == "windows" else "oasdiff"
89
+ oasdiff_path = BIN_DIR / exe_name
90
+
91
+ if oasdiff_path.exists():
92
+ # Check if it's executable
93
+ if not os.access(oasdiff_path, os.X_OK):
94
+ oasdiff_path.chmod(0o755)
95
+ return str(oasdiff_path)
96
+
97
+ print(f"oasdiff not found. Downloading to {BIN_DIR}...")
98
+
99
+ try:
100
+ os_name, arch = get_system_info()
101
+ release_data = get_latest_oasdiff_release()
102
+ url, asset_name = get_download_url(release_data, os_name, arch)
103
+
104
+ archive_path = BIN_DIR / asset_name
105
+
106
+ print(f"Downloading from {url}...")
107
+ urllib.request.urlretrieve(url, archive_path)
108
+
109
+ print("Extracting...")
110
+ extract_archive(archive_path, BIN_DIR)
111
+
112
+ # Clean up archive
113
+ archive_path.unlink()
114
+
115
+ # Verify it was extracted properly
116
+ if not oasdiff_path.exists():
117
+ raise RuntimeError(
118
+ f"Extraction completed, but {exe_name} was not found in {BIN_DIR}."
119
+ )
120
+
121
+ if os_name != "windows":
122
+ oasdiff_path.chmod(0o755)
123
+
124
+ print("oasdiff downloaded and ready.")
125
+ return str(oasdiff_path)
126
+
127
+ except Exception as e:
128
+ raise RuntimeError(f"Failed to install oasdiff: {e}")
129
+
130
+
131
+ if __name__ == "__main__":
132
+ # Test the downloader
133
+ path = ensure_oasdiff()
134
+ print(f"oasdiff is located at: {path}")
@@ -0,0 +1,6 @@
1
+ # This Django app does not currently define any URL patterns.
2
+ # URL patterns may be added in the future if web endpoints are needed.
3
+
4
+
5
+ app_name = "django_angular3"
6
+ urlpatterns = []
@@ -0,0 +1,169 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from .config import ProjectConfig
7
+ from .documents import DocumentError, load_document
8
+
9
+
10
+ class ValidationError(ValueError):
11
+ """Raised when validation cannot continue."""
12
+
13
+
14
+ def validate_openapi_document(document: Any) -> list[str]:
15
+ errors: list[str] = []
16
+ if not isinstance(document, dict):
17
+ return ["OpenAPI document must be a mapping."]
18
+
19
+ if not any(key in document for key in ("openapi", "swagger")):
20
+ errors.append("OpenAPI document must declare either 'openapi' or 'swagger'.")
21
+
22
+ paths = document.get("paths")
23
+ if not isinstance(paths, dict):
24
+ errors.append("OpenAPI document must contain a 'paths' mapping.")
25
+ return errors
26
+
27
+ if not paths:
28
+ errors.append("OpenAPI document must define at least one path.")
29
+ return errors
30
+
31
+ allowed_methods = {
32
+ "get",
33
+ "put",
34
+ "post",
35
+ "delete",
36
+ "options",
37
+ "head",
38
+ "patch",
39
+ "trace",
40
+ }
41
+
42
+ for path_name, path_item in paths.items():
43
+ if not isinstance(path_name, str) or not path_name.startswith("/"):
44
+ errors.append(
45
+ f"OpenAPI path '{path_name}' must be a string starting with '/'."
46
+ )
47
+ continue
48
+ if not isinstance(path_item, dict):
49
+ errors.append(f"OpenAPI path '{path_name}' must map to an object.")
50
+ continue
51
+
52
+ operations = [name for name in path_item if name in allowed_methods]
53
+ if not operations:
54
+ errors.append(
55
+ f"OpenAPI path '{path_name}' must define at least one HTTP operation."
56
+ )
57
+ continue
58
+
59
+ for operation_name in operations:
60
+ operation = path_item[operation_name]
61
+ if not isinstance(operation, dict):
62
+ errors.append(
63
+ f"Operation '{operation_name}' on path "
64
+ f"'{path_name}' must be an object."
65
+ )
66
+
67
+ return errors
68
+
69
+
70
+ def validate_ui_document(document: Any) -> list[str]:
71
+ errors: list[str] = []
72
+ if not isinstance(document, dict):
73
+ return ["UI definition document must be a mapping."]
74
+
75
+ pages = document.get("pages", [])
76
+ forms = document.get("forms", [])
77
+
78
+ if not isinstance(pages, list):
79
+ errors.append("'pages' must be a list when provided.")
80
+ else:
81
+ for index, page in enumerate(pages):
82
+ if not isinstance(page, dict):
83
+ errors.append(f"pages[{index}] must be an object.")
84
+ continue
85
+ route = page.get("route")
86
+ kind = page.get("kind")
87
+ if not isinstance(route, str) or not route.startswith("/"):
88
+ errors.append(
89
+ f"pages[{index}].route must be a string starting with '/'."
90
+ )
91
+ if not isinstance(kind, str) or not kind.strip():
92
+ errors.append(f"pages[{index}].kind must be a non-empty string.")
93
+
94
+ if not isinstance(forms, list):
95
+ errors.append("'forms' must be a list when provided.")
96
+ else:
97
+ for index, form in enumerate(forms):
98
+ if not isinstance(form, dict):
99
+ errors.append(f"forms[{index}] must be an object.")
100
+ continue
101
+ form_id = form.get("id")
102
+ mode = form.get("mode")
103
+ submit = form.get("submit")
104
+ if not isinstance(form_id, str) or not form_id.strip():
105
+ errors.append(f"forms[{index}].id must be a non-empty string.")
106
+ if not isinstance(mode, str) or not mode.strip():
107
+ errors.append(f"forms[{index}].mode must be a non-empty string.")
108
+ if submit is not None:
109
+ if not isinstance(submit, dict):
110
+ errors.append(
111
+ f"forms[{index}].submit must be an object when provided."
112
+ )
113
+ else:
114
+ action = submit.get("action")
115
+ if not isinstance(action, str) or not action.strip():
116
+ errors.append(
117
+ f"forms[{index}].submit.action must be a non-empty string."
118
+ )
119
+
120
+ return errors
121
+
122
+
123
+ def validate_openapi_file(path: str | Path) -> list[str]:
124
+ try:
125
+ document = load_document(path)
126
+ except DocumentError as exc:
127
+ return [str(exc)]
128
+ return validate_openapi_document(document)
129
+
130
+
131
+ def validate_ui_file(path: str | Path) -> list[str]:
132
+ try:
133
+ document = load_document(path)
134
+ except DocumentError as exc:
135
+ return [str(exc)]
136
+ return validate_ui_document(document)
137
+
138
+
139
+ def validate_project_config(config: ProjectConfig) -> list[str]:
140
+ errors: list[str] = []
141
+
142
+ if not config.openapi_source.exists():
143
+ errors.append(f"OpenAPI source does not exist: {config.openapi_source}")
144
+ else:
145
+ errors.extend(validate_openapi_file(config.openapi_source))
146
+
147
+ if not config.ui_source.exists():
148
+ errors.append(f"UI source does not exist: {config.ui_source}")
149
+ else:
150
+ errors.extend(validate_ui_file(config.ui_source))
151
+
152
+ if config.angular_output.exists() and not config.angular_output.is_dir():
153
+ errors.append(
154
+ "Angular output path must be a directory when it exists: "
155
+ f"{config.angular_output}"
156
+ )
157
+
158
+ if config.openapi_generator_config and not config.openapi_generator_config.exists():
159
+ errors.append(
160
+ "OpenAPI Generator config does not exist: "
161
+ f"{config.openapi_generator_config}"
162
+ )
163
+
164
+ if config.ng_openapi_gen_config and not config.ng_openapi_gen_config.exists():
165
+ errors.append(
166
+ f"ng-openapi-gen config does not exist: {config.ng_openapi_gen_config}"
167
+ )
168
+
169
+ return errors
@@ -0,0 +1,2 @@
1
+ # This Django app does not currently define any views.
2
+ # Views may be added in the future if web endpoints are needed.