ndsdk-cli 1.0.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 (51) hide show
  1. ndsdk/__init__.py +3 -0
  2. ndsdk/config_normalizer.py +306 -0
  3. ndsdk/generators/__init__.py +8 -0
  4. ndsdk/generators/base.py +93 -0
  5. ndsdk/generators/engine.py +715 -0
  6. ndsdk/main.py +536 -0
  7. ndsdk/naming.py +112 -0
  8. ndsdk/packages/client_provider/Program.fragment.cs.j2 +1 -0
  9. ndsdk/packages/client_provider/appsettings.fragment.json.j2 +14 -0
  10. ndsdk/packages/client_provider/package.yaml +13 -0
  11. ndsdk/packages/db_provider/Program.fragment.cs.j2 +3 -0
  12. ndsdk/packages/db_provider/appsettings.fragment.json.j2 +30 -0
  13. ndsdk/packages/db_provider/package.yaml +123 -0
  14. ndsdk/packages/exception_handling/Program.app.fragment.cs.j2 +1 -0
  15. ndsdk/packages/exception_handling/Program.fragment.cs.j2 +1 -0
  16. ndsdk/packages/exception_handling/package.yaml +7 -0
  17. ndsdk/packages/file_storage_azure/Program.fragment.cs.j2 +1 -0
  18. ndsdk/packages/file_storage_azure/appsettings.fragment.json.j2 +6 -0
  19. ndsdk/packages/file_storage_azure/package.yaml +9 -0
  20. ndsdk/packages/observability/Program.fragment.cs.j2 +2 -0
  21. ndsdk/packages/observability/appsettings.fragment.json.j2 +39 -0
  22. ndsdk/packages/observability/package.yaml +16 -0
  23. ndsdk/registry.py +192 -0
  24. ndsdk/templates/_common/BO.cs.j2 +10 -0
  25. ndsdk/templates/_common/Controller.cs.j2 +131 -0
  26. ndsdk/templates/_common/DbProviderBase.cs.j2 +26 -0
  27. ndsdk/templates/_common/Dockerfile.j2 +15 -0
  28. ndsdk/templates/_common/Dto.cs.j2 +16 -0
  29. ndsdk/templates/_common/Entity.cs.j2 +16 -0
  30. ndsdk/templates/_common/IService.cs.j2 +25 -0
  31. ndsdk/templates/_common/Model.cs.j2 +16 -0
  32. ndsdk/templates/_common/Provider.cs.j2 +151 -0
  33. ndsdk/templates/_common/ProviderBase.cs.j2 +10 -0
  34. ndsdk/templates/_common/Service.cs.j2 +49 -0
  35. ndsdk/templates/_common/ServiceManager.cs.j2 +28 -0
  36. ndsdk/templates/_common/Solution.sln.j2 +22 -0
  37. ndsdk/templates/_common/_csproj_refs.inc.j2 +14 -0
  38. ndsdk/templates/_common/launchSettings.json.j2 +17 -0
  39. ndsdk/templates/microservice/clean/Api.csproj.j2 +12 -0
  40. ndsdk/templates/microservice/clean/ClassLib.csproj.j2 +11 -0
  41. ndsdk/templates/microservice/clean/Program.cs.j2 +35 -0
  42. ndsdk/templates/microservice/clean/layout.yaml +64 -0
  43. ndsdk/templates/microservice/layered/Program.cs.j2 +61 -0
  44. ndsdk/templates/microservice/layered/Project.csproj.j2 +12 -0
  45. ndsdk/templates/microservice/layered/layout.yaml +41 -0
  46. ndsdk/validators.py +213 -0
  47. ndsdk_cli-1.0.0.dist-info/METADATA +11 -0
  48. ndsdk_cli-1.0.0.dist-info/RECORD +51 -0
  49. ndsdk_cli-1.0.0.dist-info/WHEEL +5 -0
  50. ndsdk_cli-1.0.0.dist-info/entry_points.txt +2 -0
  51. ndsdk_cli-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,715 @@
1
+ """Manifest-driven generation engine.
2
+
3
+ A single generator reads a ``layout.yaml`` describing a (type, style) variant and
4
+ emits files. New architectures are added by dropping a template folder with a
5
+ manifest under ``templates/<type>/<style>/`` — no Python changes — unless a
6
+ variant needs a brand-new composition step, which is added once as a *hook*.
7
+
8
+ Per-service config can carry:
9
+
10
+ * ``models`` / ``dtos`` / ``bos`` / ``providers`` / ``endpoints``
11
+ * ``integrations`` — external resources such as database, storage, queue, API
12
+ * ``packages`` — capabilities that must be injected into generated projects
13
+ * ``providers[].methods`` — common provider method descriptors. Database methods
14
+ use the same common shape as storage/queue/API methods; database-specific
15
+ details live under method ``settings``.
16
+
17
+ Selective regeneration: ``generate`` accepts a :class:`GenerationFilter` so a
18
+ single service and/or a single layer (model, provider, service, ...) can be
19
+ re-emitted without touching the rest of the solution.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import uuid
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+
30
+ import yaml
31
+
32
+ from .. import naming
33
+ from ..config_normalizer import normalize_config
34
+ from ..registry import PackageRegistry, flatten_appsettings, merge_appsettings,_deep_merge
35
+ from . import base
36
+
37
+ TEMPLATES_DIR = base.TEMPLATES_DIR
38
+
39
+ # Stable namespace for deterministic project GUIDs (keeps the .sln stable across
40
+ # runs and across selective regeneration).
41
+ _GUID_NS = uuid.UUID("6f9619ff-8b86-d011-b42d-00cf4fc964ff")
42
+
43
+ HEALTH_ENDPOINT = {
44
+ "name": "health",
45
+ "http_method": "GET",
46
+ "route": "api/v1/health",
47
+ "produces": "text/plain",
48
+ "summary": "Health check endpoint for service liveness verification.",
49
+ "description": "Returns a simple status response indicating the service is reachable.",
50
+ "is_health": True,
51
+ }
52
+
53
+ _SCOPE_KEYS = {
54
+ "per_model": ("models", "model"),
55
+ "per_dto": ("dtos", "dto"),
56
+ "per_provider": ("providers", "provider"),
57
+ "per_bo": ("bos", "bo"),
58
+ "per_endpoint": ("business_endpoints", "endpoint"),
59
+ "per_entity": ("entities", "entity"),
60
+ }
61
+
62
+ # Default layer label derived from an artifact's scope when it declares none.
63
+ _SCOPE_DEFAULT_LAYER = {
64
+ "per_model": "model",
65
+ "per_dto": "dto",
66
+ "per_provider": "provider",
67
+ "per_bo": "bo",
68
+ "per_endpoint": "endpoint",
69
+ "per_entity": "entity",
70
+ }
71
+
72
+
73
+ # ----------------------------- selective regen ---------------------------- #
74
+ @dataclass
75
+ class GenerationFilter:
76
+ """Restrict a generation run to certain services and/or layers.
77
+
78
+ Empty sets mean "no restriction". When any restriction is active the engine
79
+ leaves the solution file and the config copy untouched, so regenerating a
80
+ single layer never rewrites unrelated solution structure.
81
+ """
82
+
83
+ services: set[str] = field(default_factory=set)
84
+ layers: set[str] = field(default_factory=set)
85
+ force: bool = False
86
+
87
+ @property
88
+ def active(self) -> bool:
89
+ return bool(self.services or self.layers)
90
+
91
+ def allow_service(self, name: str) -> bool:
92
+ return not self.services or name in self.services
93
+
94
+ def allow_layer(self, layer: str) -> bool:
95
+ return not self.layers or layer in self.layers
96
+
97
+
98
+ # ----------------------------- discovery ---------------------------------- #
99
+ def available_variants() -> list[tuple[str, str]]:
100
+ """Return all (type, style) pairs that have a layout.yaml."""
101
+ out = []
102
+ for type_dir in sorted(TEMPLATES_DIR.iterdir()):
103
+ if not type_dir.is_dir() or type_dir.name.startswith("_"):
104
+ continue
105
+ for style_dir in sorted(type_dir.iterdir()):
106
+ if (style_dir / "layout.yaml").exists():
107
+ out.append((type_dir.name, style_dir.name))
108
+ return out
109
+
110
+
111
+ def resolve_variant(config: dict) -> tuple[str, str, Path]:
112
+ project = config.get("project", {})
113
+ ptype = project.get("type", "microservice")
114
+ style = project.get("architecture", "layered")
115
+ variant_dir = TEMPLATES_DIR / ptype / style
116
+ if not (variant_dir / "layout.yaml").exists():
117
+ opts = ", ".join(f"{t}/{s}" for t, s in available_variants()) or "(none)"
118
+ raise ValueError(
119
+ f"Unknown architecture '{ptype}/{style}'. Available: {opts}"
120
+ )
121
+ return ptype, style, variant_dir
122
+
123
+
124
+ def get_generator(config: dict, output_dir: str) -> "Engine":
125
+ return Engine(config, output_dir)
126
+
127
+
128
+ def available_layers() -> list[str]:
129
+ """Distinct layer labels declared across all shipped variants (for --layer)."""
130
+ layers: set[str] = set()
131
+ for ptype, style in available_variants():
132
+ layout = yaml.safe_load((TEMPLATES_DIR / ptype / style / "layout.yaml").read_text())
133
+ for proj in layout.get("projects", []):
134
+ for art in proj.get("artifacts", []):
135
+ layers.add(_artifact_layer(art))
136
+ return sorted(layers)
137
+
138
+
139
+ def _artifact_layer(art: dict) -> str:
140
+ if art.get("layer"):
141
+ return art["layer"]
142
+ if "hook" in art:
143
+ return art["hook"]
144
+ return _SCOPE_DEFAULT_LAYER.get(art.get("scope", "once"), "once")
145
+
146
+
147
+ # ------------------------------- engine ----------------------------------- #
148
+ class Engine:
149
+ def __init__(self, config: dict, output_dir: str):
150
+ self.config = normalize_config(config)
151
+ self.project = self.config["project"]
152
+ self.ptype, self.style, self.variant_dir = resolve_variant(self.config)
153
+ self.layout = yaml.safe_load((self.variant_dir / "layout.yaml").read_text())
154
+
155
+ self.solution_name = self.project["name"]
156
+ self.namespace_root = self.project.get("namespace_root", "")
157
+ self.dotnet = self.project.get("dotnet_version", "net8.0")
158
+ self.version_strategy = self.project.get("nuget_version_strategy", "floating")
159
+ self.config_versions = self.config.get("package_versions", {})
160
+
161
+ self.output_dir = Path(output_dir)
162
+ self.solution_dir = self.output_dir / self.solution_name
163
+ self.env = base.make_env(self.variant_dir)
164
+ self.packages = PackageRegistry()
165
+ self.errors: list[str] = []
166
+
167
+ def _package_versions_for_service(self, svc: dict) -> dict:
168
+ root_versions = self.config.get("package_versions", {}) or {}
169
+ service_versions = svc.get("package_versions", {}) or {}
170
+
171
+ # Service-level version wins over root-level version.
172
+ return {**root_versions, **service_versions}
173
+
174
+ # ---- package + namespace resolution per service --------------------- #
175
+ def _service_integrations(self, svc: dict) -> dict[str, dict]:
176
+ return {
177
+ i.get("name"): i
178
+ for i in svc.get("integrations", []) or []
179
+ if isinstance(i, dict) and i.get("name")
180
+ }
181
+
182
+ def _service_database(self, svc: dict) -> dict | None:
183
+ """Resolve database settings from the common integration/provider model.
184
+
185
+ Public config no longer needs a separate `database:` block. A database is
186
+ just an integration whose kind is `database`, optionally referenced by a
187
+ provider whose kind is also `database`.
188
+ """
189
+ integrations = self._service_integrations(svc)
190
+ selected: dict | None = None
191
+
192
+ for prov in svc.get("providers", []) or []:
193
+ integration = integrations.get(prov.get("integration"))
194
+ kind = str(prov.get("kind") or (integration or {}).get("kind") or "custom").lower()
195
+ if kind == "database":
196
+ selected = integration
197
+ break
198
+
199
+ if selected is None:
200
+ selected = next(
201
+ (i for i in integrations.values() if str(i.get("kind", "")).lower() == "database"),
202
+ None,
203
+ )
204
+
205
+ # Compatibility only: if normalize_config was bypassed and an old block
206
+ # is still present, use it internally without exposing it as the target
207
+ # public config shape.
208
+ legacy_db = svc.get("database") if isinstance(svc.get("database"), dict) else None
209
+ if selected is None and legacy_db is None:
210
+ return None
211
+
212
+ if selected is not None:
213
+ settings = selected.get("settings") if isinstance(selected.get("settings"), dict) else {}
214
+ provider = selected.get("provider") or selected.get("store")
215
+ db_type = settings.get("database_type") or selected.get("database_type") or selected.get("type")
216
+ out = {
217
+ "store": provider,
218
+ "provider": provider,
219
+ "type": db_type or "non_relational",
220
+ "connection_string_name": settings.get("connection_string_name"),
221
+ "integration": selected.get("name"),
222
+ }
223
+ else:
224
+ out = {
225
+ "store": legacy_db.get("store") or legacy_db.get("provider"),
226
+ "provider": legacy_db.get("provider") or legacy_db.get("store"),
227
+ "type": legacy_db.get("type", "non_relational"),
228
+ "connection_string_name": legacy_db.get("connection_string_name"),
229
+ "integration": legacy_db.get("integration"),
230
+ }
231
+ return {k: v for k, v in out.items() if v is not None}
232
+
233
+ def _service_storage(self, svc: dict) -> dict | None:
234
+ """Resolve an Azure blob storage integration, if any.
235
+
236
+ Mirrors :meth:`_service_database`: a storage capability is just an
237
+ integration whose kind is ``storage`` (optionally referenced by a
238
+ provider whose kind is ``storage``). Returns the connection-string name
239
+ and container so the file-storage package can be auto-wired.
240
+ """
241
+ integrations = self._service_integrations(svc)
242
+ selected: dict | None = None
243
+
244
+ for prov in svc.get("providers", []) or []:
245
+ integration = integrations.get(prov.get("integration"))
246
+ kind = str(prov.get("kind") or (integration or {}).get("kind") or "custom").lower()
247
+ if kind == "storage":
248
+ selected = integration
249
+ break
250
+ if selected is None:
251
+ selected = next(
252
+ (i for i in integrations.values() if str(i.get("kind", "")).lower() == "storage"),
253
+ None,
254
+ )
255
+ if selected is None:
256
+ return None
257
+
258
+ settings = selected.get("settings") if isinstance(selected.get("settings"), dict) else {}
259
+ out = {
260
+ "provider": selected.get("provider"),
261
+ "connection_string_name": settings.get("connection_string_name"),
262
+ "container_name": settings.get("container_name"),
263
+ "integration": selected.get("name"),
264
+ }
265
+ return {k: v for k, v in out.items() if v is not None}
266
+
267
+ def _resolve_packages(self, svc: dict):
268
+ defaults = self.config.get("defaults", {}).get("packages", {}) or {}
269
+ service_packages = svc.get("packages", {}) or {}
270
+
271
+ merged = _deep_merge(defaults, service_packages)
272
+
273
+ # A service that declares a database integration/provider implicitly turns
274
+ # on the db_provider package and feeds it connection settings, unless the
275
+ # package is explicitly disabled.
276
+ db = self._service_database(svc)
277
+ if db and "db_provider" in self.packages.available():
278
+ existing = merged.get("db_provider")
279
+ if not isinstance(existing, dict) or existing.get("enabled") is not False:
280
+ pkg_cfg = dict(existing) if isinstance(existing, dict) else {}
281
+ pkg_cfg.setdefault("enabled", True)
282
+ pkg_cfg.setdefault("db_type", db.get("type"))
283
+ if db.get("store"):
284
+ pkg_cfg.setdefault("provider", db["store"])
285
+ if db.get("connection_string_name"):
286
+ pkg_cfg.setdefault("connection_string_name", db["connection_string_name"])
287
+ merged["db_provider"] = pkg_cfg
288
+
289
+ # A service that declares a storage integration/provider implicitly turns
290
+ # on the Azure file-storage package, unless explicitly disabled.
291
+ storage = self._service_storage(svc)
292
+ if storage and "file_storage_azure" in self.packages.available():
293
+ existing = merged.get("file_storage_azure")
294
+ if not isinstance(existing, dict) or existing.get("enabled") is not False:
295
+ pkg_cfg = dict(existing) if isinstance(existing, dict) else {}
296
+ pkg_cfg.setdefault("enabled", True)
297
+ merged["file_storage_azure"] = pkg_cfg
298
+
299
+ return self.packages.resolve_enabled(merged)
300
+
301
+ def _service_base_ns(self, svc: dict) -> str:
302
+ return naming.namespace(self.namespace_root, svc["name"])
303
+
304
+ # ---- database adapter resolution (configurable per store) ----------- #
305
+ _STORE_KEY_ALIASES = {
306
+ "sqlserver": "mssql",
307
+ "sql_server": "mssql",
308
+ "mssql": "mssql",
309
+ "postgres": "postgres",
310
+ "postgresql": "postgres",
311
+ "cassandra": "cassandra",
312
+ }
313
+
314
+ @classmethod
315
+ def _normalize_store_key(cls, store: str) -> str:
316
+ key = str(store or "").strip().lower().replace("-", "_")
317
+ return cls._STORE_KEY_ALIASES.get(key, key)
318
+
319
+ def _resolve_db_adapter(self, db_settings: dict | None, database: dict | None) -> dict:
320
+ """Pick the adapter descriptor for the service's store.
321
+
322
+ Adapters live in the db_provider package config (``adapters:``) so a new
323
+ engine is added by editing config, not Python. Resolution order:
324
+ exact store key -> relational_default / non_relational_default by type.
325
+ """
326
+ adapters = (db_settings or {}).get("adapters", {}) or {}
327
+ store = (database or {}).get("store") or (database or {}).get("provider") \
328
+ or (db_settings or {}).get("provider") or ""
329
+ db_type = (database or {}).get("type") or (db_settings or {}).get("db_type") or "non_relational"
330
+
331
+ key = self._normalize_store_key(store)
332
+ adapter = adapters.get(key)
333
+ if not isinstance(adapter, dict):
334
+ fallback = "relational_default" if db_type == "relational" else "non_relational_default"
335
+ adapter = adapters.get(fallback)
336
+ adapter = dict(adapter) if isinstance(adapter, dict) else {}
337
+ adapter.setdefault(
338
+ "dbtype_enum_value", "Relational" if db_type == "relational" else "NonRelational"
339
+ )
340
+ return adapter
341
+
342
+ def _base_context(self, svc: dict) -> dict:
343
+ resolved = self._resolve_packages(svc)
344
+ obs = next((s for p, s in resolved if p.key == "observability"), None)
345
+ db_pkg, db_settings = next(
346
+ ((p, s) for p, s in resolved if p.key == "db_provider"), (None, None)
347
+ )
348
+ database = self._service_database(svc)
349
+ endpoints = [HEALTH_ENDPOINT] + svc.get("endpoints", [])
350
+ ns = base.resolve_namespaces(self.layout.get("namespaces", {}), self._service_base_ns(svc))
351
+
352
+ # Resolve the database adapter (by store, configurable) and expose the
353
+ # runtime schema-config key + facade response type on db_settings, so the
354
+ # DI fragment and provider templates stay declarative. Mutated in place
355
+ # because the same dict is what the Program.cs fragment receives.
356
+ response_type = "ExternalizationResponse"
357
+ if isinstance(db_settings, dict):
358
+ adapter = self._resolve_db_adapter(db_settings, database)
359
+ db_settings["adapter"] = adapter
360
+ relational_cfg = db_settings.get("relational") if isinstance(db_settings.get("relational"), dict) else {}
361
+ db_settings["schema_config_key"] = (
362
+ relational_cfg.get("schema_name_config_key")
363
+ or "DB_Provider:RelationalDatabase:SqlServer:SchemaName"
364
+ )
365
+ response_type = db_settings.setdefault("response_type", response_type)
366
+
367
+ # Normalise providers so templates have a stable, provider-agnostic
368
+ # shape. Provider kind, not the presence of methods, decides behaviour.
369
+ integrations = self._service_integrations(svc)
370
+ providers = []
371
+ for p in svc.get("providers", []):
372
+ p = dict(p)
373
+ integration = integrations.get(p.get("integration"))
374
+ provider_kind = str(p.get("kind") or (integration or {}).get("kind") or "custom").lower()
375
+ p["kind"] = provider_kind
376
+ if integration and integration.get("provider") and not p.get("provider"):
377
+ p["provider"] = integration["provider"]
378
+ methods = p.get("methods", []) or []
379
+ p["methods"] = [self._normalise_method(m, provider_kind, database) for m in methods]
380
+ # For relational (stored-procedure) methods the first cut returns the
381
+ # facade response verbatim; the configured `returns` is kept for the
382
+ # later mapping layer but does not drive the signature.
383
+ for m in p["methods"]:
384
+ m["return_type_cs"] = (
385
+ response_type if m.get("relational")
386
+ else naming.csharp_type(m.get("returns") or "object")
387
+ )
388
+ p["has_methods"] = bool(p["methods"])
389
+ p["has_relational"] = any(m.get("relational") for m in p["methods"])
390
+ p["is_db"] = provider_kind == "database"
391
+ providers.append(p)
392
+
393
+ return {
394
+ "config": self.config,
395
+ "project": self.project,
396
+ "svc": svc,
397
+ "service_name": svc["name"],
398
+ "service": svc["name"],
399
+ "dotnet": self.dotnet,
400
+ "ns": ns,
401
+ "endpoints": endpoints,
402
+ "business_endpoints": [e for e in endpoints if not e.get("is_health")],
403
+ "models": svc.get("models", []),
404
+ "dtos": svc.get("dtos", []),
405
+ "entities": svc.get("entities", []),
406
+ "bos": svc.get("bos", []),
407
+ "providers": providers,
408
+ "db_providers": [p for p in providers if p["is_db"]],
409
+ "database": database,
410
+ "db_enabled": database is not None,
411
+ "db_settings": db_settings or {},
412
+ "resolved_packages": resolved,
413
+ "obs": obs,
414
+ "obs_enabled": obs is not None,
415
+ "controller_style": self.layout.get("controller_style", "service_manager"),
416
+ "use_service_locator": self.layout.get("controller_style", "service_manager") == "service_manager",
417
+ }
418
+
419
+ @staticmethod
420
+ def _normalise_method(method: dict, provider_kind: str, database: dict | None) -> dict:
421
+ m = dict(method)
422
+ settings = dict(m.get("settings") or {})
423
+ if isinstance(m.get("database"), dict):
424
+ settings = {**m["database"], **settings}
425
+
426
+ # Backward-compatible aliases are consumed into the common method shape.
427
+ for key in ("stored_procedure", "target", "table", "inputs", "parameters", "cursors"):
428
+ if key in m and key not in settings:
429
+ settings[key] = m[key]
430
+
431
+ input_entity = m.get("input_entity") or m.get("entity")
432
+ output_entity = m.get("output_entity")
433
+
434
+ m["input_entity"] = input_entity
435
+ m["entity"] = input_entity # internal template convenience only
436
+ m["output_entity"] = output_entity
437
+ m["settings"] = settings
438
+
439
+ if not m.get("returns"):
440
+ m["returns"] = output_entity or "object"
441
+
442
+ op = str(m.get("operation") or "").strip().replace("-", "_").replace(" ", "_").lower()
443
+ if op in {"storedprocedure", "stored_proc", "sp"}:
444
+ op = "stored_procedure"
445
+ elif op in {"getresults", "get_results"}:
446
+ op = "query"
447
+ elif not op and provider_kind == "database":
448
+ op = "stored_procedure" if settings.get("stored_procedure") else "query"
449
+ m["operation"] = op or m.get("operation") or "execute"
450
+
451
+ if provider_kind == "database":
452
+ default_db_type = (database or {}).get("type", "non_relational")
453
+ method_kind = settings.get("method_type") or m.get("kind")
454
+ if method_kind not in {"relational", "non_relational"}:
455
+ method_kind = "relational" if m["operation"] == "stored_procedure" else default_db_type
456
+ if method_kind not in {"relational", "non_relational"}:
457
+ method_kind = "non_relational"
458
+
459
+ m["kind"] = method_kind
460
+ m["relational"] = method_kind == "relational"
461
+ m["db_type_enum"] = "Relational" if method_kind == "relational" else "NonRelational"
462
+ m["facade_operation"] = "StoredProcedure" if m["operation"] == "stored_procedure" or method_kind == "relational" else "Query"
463
+ m["target"] = settings.get("target") or settings.get("stored_procedure") or m.get("target") or (m.get("name") if method_kind == "relational" else "GetResults")
464
+ m["stored_procedure"] = settings.get("stored_procedure") or (m["target"] if method_kind == "relational" else None)
465
+ m["table"] = settings.get("table")
466
+ m["inputs"] = settings.get("inputs", []) or []
467
+ m["parameters"] = settings.get("parameters", []) or []
468
+ # Non-relational extras (Cassandra-style facade requests).
469
+ # extended_context_action -> emits ExtendedContext = new CassandraExtendedContext { Action = "insert"|"update" }
470
+ m["extended_context_action"] = settings.get("extended_context_action")
471
+ m["extended_context_type"] = settings.get("extended_context_type", "CassandraExtendedContext")
472
+ else:
473
+ m["kind"] = provider_kind
474
+ m["relational"] = False
475
+ m.setdefault("inputs", [])
476
+ m.setdefault("parameters", [])
477
+
478
+ return m
479
+
480
+ # ---- generation ----------------------------------------------------- #
481
+ def generate(self, filter: GenerationFilter | None = None) -> list[Path]:
482
+ flt = filter or GenerationFilter()
483
+ written: list[Path] = []
484
+ self.solution_dir.mkdir(parents=True, exist_ok=True)
485
+ sln_projects: list[dict] = []
486
+ # Collect per-artifact failures instead of letting the first one abort
487
+ # the whole run. A bad provider template must never cost you DTOs,
488
+ # entities or models that are generated independently of it.
489
+ self.errors: list[str] = []
490
+
491
+ for svc in self.config.get("services", []):
492
+ if not flt.allow_service(svc["name"]):
493
+ continue
494
+ ctx = self._base_context(svc)
495
+ for proj in self.layout["projects"]:
496
+ paths, sln_entry = self._generate_project(svc, proj, ctx, flt)
497
+ written += paths
498
+ sln_projects.append(sln_entry)
499
+
500
+ # Don't rewrite solution-level files during a filtered (partial) run.
501
+ if not flt.active:
502
+ written.append(self._write_solution(sln_projects))
503
+ written.append(self._write_config_copy())
504
+
505
+ if self.errors:
506
+ # Everything that could be written has been written; surface the
507
+ # failures together so the user sees which artifacts need attention
508
+ # rather than a single traceback that hid the rest.
509
+ summary = "\n".join(f" - {e}" for e in self.errors)
510
+ raise RuntimeError(
511
+ f"{len(self.errors)} artifact(s) failed to generate "
512
+ f"(the rest were still written):\n{summary}"
513
+ )
514
+ return written
515
+
516
+ def _project_dir(self, svc, proj) -> Path:
517
+ rel = proj["dir"].format(service=svc["name"], ns=self._service_base_ns(svc))
518
+ return self.solution_dir / rel
519
+
520
+ def _generate_project(self, svc, proj, base_ctx, flt) -> tuple[list[Path], dict]:
521
+ pdir = self._project_dir(svc, proj)
522
+ proj_ns = proj["namespace"].format(ns=self._service_base_ns(svc))
523
+ is_host = proj.get("host", False)
524
+
525
+ # project-to-project references
526
+ refs = []
527
+ for ref_id in proj.get("references", []):
528
+ ref_proj = next(p for p in self.layout["projects"] if p["id"] == ref_id)
529
+ ref_dir = self._project_dir(svc, ref_proj)
530
+ ref_name = ref_proj["csproj"]["path"].format(
531
+ service=svc["name"], ns=self._service_base_ns(svc)
532
+ )
533
+ rel = os.path.relpath(ref_dir / ref_name, start=pdir)
534
+ refs.append({"path": rel.replace("/", "\\")})
535
+
536
+ # nuget for this project
537
+ nuget_inputs = list(proj.get("nuget", []))
538
+ has_provider_artifacts = any(
539
+ art.get("template") in {"Provider.cs.j2", "DbProviderBase.cs.j2", "ProviderBase.cs.j2"}
540
+ for art in proj.get("artifacts", [])
541
+ )
542
+ if is_host or has_provider_artifacts:
543
+ for pkg, _ in base_ctx["resolved_packages"]:
544
+ # Host projects need package NuGets for DI fragments. Provider
545
+ # projects need package NuGets for any generated/provider-specific
546
+ # scaffolds, not only database scaffolds.
547
+ nuget_inputs += [{"name": r.name, "version": r.version} for r in pkg.nuget]
548
+ if is_host:
549
+ nuget_inputs += self.config.get("defaults", {}).get("nuget", [])
550
+ nuget_inputs += svc.get("nuget", [])
551
+ nuget_refs = base.resolve_nuget_refs(
552
+ nuget_inputs,
553
+ config_versions=self._package_versions_for_service(svc),
554
+ version_strategy=self.version_strategy,
555
+ )
556
+
557
+ proj_ctx = dict(base_ctx)
558
+ proj_ctx.update({
559
+ "project_namespace": proj_ns,
560
+ "project_id": proj["id"],
561
+ "is_host": is_host,
562
+ "project_references": refs,
563
+ "nuget_refs": nuget_refs,
564
+ })
565
+
566
+ # deterministic GUID from solution + project path
567
+ guid = str(uuid.uuid5(_GUID_NS, f"{self.solution_name}/{proj_ns}")).upper()
568
+
569
+ csproj = proj["csproj"]
570
+ csproj_name = csproj["path"].format(service=svc["name"], ns=self._service_base_ns(svc))
571
+
572
+ written: list[Path] = []
573
+ # csproj counts as the "project" layer; skip on a filtered layer run that
574
+ # excludes it (but never skip on a service-only filter).
575
+ if flt.allow_layer("csproj"):
576
+ content = self.env.get_template(csproj["template"]).render(**proj_ctx)
577
+ path, _ = base.write_file(pdir / csproj_name, content)
578
+ written.append(path)
579
+
580
+ for art in proj.get("artifacts", []):
581
+ layer = _artifact_layer(art)
582
+ if not flt.allow_layer(layer):
583
+ continue
584
+ try:
585
+ written += self._generate_artifact(svc, pdir, art, proj_ctx, flt)
586
+ except Exception as exc: # noqa: BLE001 - isolate one artifact's failure
587
+ label = art.get("template") or art.get("hook") or layer
588
+ getattr(self, "errors", self.__dict__.setdefault("errors", [])).append(
589
+ f"{svc['name']} / {label} ({layer}): {type(exc).__name__}: {exc}"
590
+ )
591
+
592
+ sln_entry = {
593
+ "name": csproj_name.rsplit(".", 1)[0] if csproj_name.endswith(".csproj") else csproj_name,
594
+ "path": str((pdir.relative_to(self.solution_dir)) / csproj_name).replace("/", "\\"),
595
+ "guid": guid,
596
+ }
597
+ return written, sln_entry
598
+
599
+ def _generate_artifact(self, svc, pdir, art, proj_ctx, flt) -> list[Path]:
600
+ scope = art.get("scope", "once")
601
+ owner = art.get("owner", "framework")
602
+ # Selective regen with --force overwrites write-once (user) files too.
603
+ if owner == "user" and flt.force:
604
+ owner = "framework"
605
+
606
+ if scope == "once":
607
+ loop_items = [None]
608
+ else:
609
+ collection_key, _ = _SCOPE_KEYS[scope]
610
+ loop_items = proj_ctx[collection_key]
611
+
612
+ written = []
613
+ for item in loop_items:
614
+ ctx = dict(proj_ctx)
615
+ fmt = {"service": svc["name"], "ns": proj_ctx["ns"]["base"]}
616
+ if scope != "once":
617
+ _, var_name = _SCOPE_KEYS[scope]
618
+ ctx[var_name] = item
619
+ fmt[var_name] = item.get("name", "") if isinstance(item, dict) else item
620
+
621
+ # per-provider artifacts that gate on db-only providers
622
+ if art.get("only") == "db_provider" and not (isinstance(item, dict) and item.get("is_db")):
623
+ continue
624
+
625
+ out_path = pdir / art["path"].format(**fmt)
626
+
627
+ if "hook" in art:
628
+ content = self._run_hook(art["hook"], ctx)
629
+ else:
630
+ content = self.env.get_template(art["template"]).render(**ctx)
631
+
632
+ path, _ = base.write_file(out_path, content, owner=owner)
633
+ written.append(path)
634
+ return written
635
+
636
+ # ---- hooks (the only place a new building block needs Python) ------- #
637
+ def _run_hook(self, name: str, ctx: dict) -> str:
638
+ if name == "program":
639
+ return self._hook_program(ctx)
640
+ if name == "appsettings":
641
+ return self._hook_appsettings(ctx)
642
+ if name == "launch":
643
+ return self._hook_launch(ctx)
644
+ raise ValueError(f"Unknown manifest hook '{name}'")
645
+
646
+ def _hook_program(self, ctx: dict) -> str:
647
+ fragments = []
648
+ app_fragments = []
649
+ for pkg, settings in ctx["resolved_packages"]:
650
+ if pkg.program_fragment:
651
+ rendered = self.env.from_string(pkg.program_fragment).render(
652
+ settings=settings, **ctx
653
+ ).strip()
654
+ if rendered:
655
+ fragments.append(f"// --- {pkg.display_name} ---\n{rendered}")
656
+ if pkg.app_fragment:
657
+ rendered = self.env.from_string(pkg.app_fragment).render(
658
+ settings=settings, **ctx
659
+ ).strip()
660
+ if rendered:
661
+ app_fragments.append(f"// --- {pkg.display_name} ---\n{rendered}")
662
+ return self.env.get_template("Program.cs.j2").render(
663
+ package_registrations=fragments,
664
+ app_registrations=app_fragments,
665
+ **ctx,
666
+ )
667
+
668
+ def _merged_appsettings(self, ctx: dict) -> dict:
669
+ """Build the fully-merged appsettings dict for a service.
670
+
671
+ Used both to render ``appsettings.json`` and to project every setting
672
+ into ``launchSettings.json`` as ``__``-nested environment variables.
673
+ """
674
+ out = {
675
+ "ServiceName": ctx["service_name"],
676
+ "Logging": {"LogLevel": {"Default": "Information", "Microsoft.AspNetCore": "Warning"}},
677
+ "AllowedHosts": "*",
678
+ }
679
+ out = merge_appsettings(out, json.dumps(ctx["svc"].get("appsettings", {})))
680
+ for pkg, settings in ctx["resolved_packages"]:
681
+ if pkg.appsettings_fragment:
682
+ frag = self.env.from_string(pkg.appsettings_fragment).render(settings=settings, **ctx)
683
+ if frag.strip():
684
+ out = merge_appsettings(out, frag)
685
+ return out
686
+
687
+ def _hook_appsettings(self, ctx: dict) -> str:
688
+ return json.dumps(self._merged_appsettings(ctx), indent=2)
689
+
690
+ def _hook_launch(self, ctx: dict) -> str:
691
+ """Render launchSettings.json with every appsetting mirrored into the
692
+ profile's ``environmentVariables`` using the .NET ``__`` hierarchy
693
+ separator, so a developer running locally gets the same configuration
694
+ the app would read from appsettings without editing JSON by hand."""
695
+ merged = self._merged_appsettings(ctx)
696
+ env_vars = {"ASPNETCORE_ENVIRONMENT": "Development"}
697
+ env_vars.update(flatten_appsettings(merged))
698
+ launch_ctx = dict(ctx)
699
+ launch_ctx["launch_env_vars"] = env_vars
700
+ return self.env.get_template("launchSettings.json.j2").render(**launch_ctx)
701
+
702
+ # ---- solution + config copy ----------------------------------------- #
703
+ def _write_solution(self, projects: list[dict]) -> Path:
704
+ content = self.env.get_template(
705
+ self.layout.get("solution", {}).get("template", "Solution.sln.j2")
706
+ ).render(projects=projects, config=self.config)
707
+ path, _ = base.write_file(self.solution_dir / f"{self.solution_name}.sln", content)
708
+ return path
709
+
710
+ def _write_config_copy(self) -> Path:
711
+ path, _ = base.write_file(
712
+ self.solution_dir / "nd-config.yaml",
713
+ yaml.dump(self.config, sort_keys=False, indent=2),
714
+ )
715
+ return path