django-cfg 1.4.10__py3-none-any.whl → 1.4.11__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 (181) hide show
  1. django_cfg/apps/agents/management/commands/create_agent.py +1 -1
  2. django_cfg/apps/agents/management/commands/orchestrator_status.py +3 -3
  3. django_cfg/apps/newsletter/serializers.py +40 -3
  4. django_cfg/apps/newsletter/views/campaigns.py +12 -3
  5. django_cfg/apps/newsletter/views/emails.py +14 -3
  6. django_cfg/apps/newsletter/views/subscriptions.py +12 -2
  7. django_cfg/apps/payments/views/api/currencies.py +49 -6
  8. django_cfg/apps/payments/views/api/webhooks.py +72 -7
  9. django_cfg/apps/payments/views/overview/serializers.py +34 -1
  10. django_cfg/apps/payments/views/overview/views.py +2 -1
  11. django_cfg/apps/payments/views/serializers/payments.py +6 -6
  12. django_cfg/apps/urls.py +106 -45
  13. django_cfg/core/base/config_model.py +2 -2
  14. django_cfg/core/constants.py +1 -1
  15. django_cfg/core/generation/integration_generators/__init__.py +1 -1
  16. django_cfg/core/generation/integration_generators/api.py +72 -49
  17. django_cfg/core/integration/display/startup.py +30 -22
  18. django_cfg/core/integration/url_integration.py +15 -16
  19. django_cfg/dashboard/sections/documentation.py +391 -0
  20. django_cfg/management/commands/check_endpoints.py +11 -160
  21. django_cfg/management/commands/check_settings.py +13 -348
  22. django_cfg/management/commands/clear_constance.py +13 -201
  23. django_cfg/management/commands/create_token.py +13 -321
  24. django_cfg/management/commands/generate_clients.py +23 -0
  25. django_cfg/management/commands/list_urls.py +13 -306
  26. django_cfg/management/commands/migrate_all.py +13 -126
  27. django_cfg/management/commands/migrator.py +13 -396
  28. django_cfg/management/commands/rundramatiq.py +15 -247
  29. django_cfg/management/commands/rundramatiq_simulator.py +12 -429
  30. django_cfg/management/commands/runserver_ngrok.py +15 -160
  31. django_cfg/management/commands/script.py +12 -488
  32. django_cfg/management/commands/show_config.py +12 -215
  33. django_cfg/management/commands/show_urls.py +12 -342
  34. django_cfg/management/commands/superuser.py +15 -295
  35. django_cfg/management/commands/task_clear.py +14 -217
  36. django_cfg/management/commands/task_status.py +13 -248
  37. django_cfg/management/commands/test_email.py +15 -86
  38. django_cfg/management/commands/test_telegram.py +14 -61
  39. django_cfg/management/commands/test_twilio.py +15 -105
  40. django_cfg/management/commands/tree.py +13 -383
  41. django_cfg/management/commands/validate_openapi.py +10 -0
  42. django_cfg/middleware/README.md +1 -1
  43. django_cfg/middleware/user_activity.py +3 -3
  44. django_cfg/models/__init__.py +2 -2
  45. django_cfg/models/api/drf/spectacular.py +6 -6
  46. django_cfg/models/django/__init__.py +2 -2
  47. django_cfg/models/django/openapi.py +238 -0
  48. django_cfg/modules/django_admin/management/__init__.py +0 -0
  49. django_cfg/modules/django_admin/management/commands/__init__.py +0 -0
  50. django_cfg/modules/django_admin/management/commands/check_endpoints.py +169 -0
  51. django_cfg/modules/django_admin/management/commands/check_settings.py +355 -0
  52. django_cfg/modules/django_admin/management/commands/clear_constance.py +208 -0
  53. django_cfg/modules/django_admin/management/commands/create_token.py +328 -0
  54. django_cfg/modules/django_admin/management/commands/list_urls.py +313 -0
  55. django_cfg/modules/django_admin/management/commands/migrate_all.py +133 -0
  56. django_cfg/modules/django_admin/management/commands/migrator.py +403 -0
  57. django_cfg/modules/django_admin/management/commands/script.py +496 -0
  58. django_cfg/modules/django_admin/management/commands/show_config.py +225 -0
  59. django_cfg/modules/django_admin/management/commands/show_urls.py +361 -0
  60. django_cfg/modules/django_admin/management/commands/superuser.py +302 -0
  61. django_cfg/modules/django_admin/management/commands/tree.py +390 -0
  62. django_cfg/modules/django_client/__init__.py +20 -0
  63. django_cfg/modules/django_client/apps.py +35 -0
  64. django_cfg/modules/django_client/core/__init__.py +56 -0
  65. django_cfg/modules/django_client/core/archive/__init__.py +11 -0
  66. django_cfg/modules/django_client/core/archive/manager.py +134 -0
  67. django_cfg/modules/django_client/core/cli/__init__.py +12 -0
  68. django_cfg/modules/django_client/core/cli/main.py +235 -0
  69. django_cfg/modules/django_client/core/config/__init__.py +18 -0
  70. django_cfg/modules/django_client/core/config/config.py +188 -0
  71. django_cfg/modules/django_client/core/config/group.py +101 -0
  72. django_cfg/modules/django_client/core/config/service.py +209 -0
  73. django_cfg/modules/django_client/core/generator/__init__.py +115 -0
  74. django_cfg/modules/django_client/core/generator/base.py +767 -0
  75. django_cfg/modules/django_client/core/generator/python.py +751 -0
  76. django_cfg/modules/django_client/core/generator/templates/python/__init__.py.jinja +9 -0
  77. django_cfg/modules/django_client/core/generator/templates/python/api_wrapper.py.jinja +130 -0
  78. django_cfg/modules/django_client/core/generator/templates/python/app_init.py.jinja +6 -0
  79. django_cfg/modules/django_client/core/generator/templates/python/client/app_client.py.jinja +18 -0
  80. django_cfg/modules/django_client/core/generator/templates/python/client/flat_client.py.jinja +38 -0
  81. django_cfg/modules/django_client/core/generator/templates/python/client/main_client.py.jinja +50 -0
  82. django_cfg/modules/django_client/core/generator/templates/python/client/main_client_file.py.jinja +13 -0
  83. django_cfg/modules/django_client/core/generator/templates/python/client/operation_method.py.jinja +7 -0
  84. django_cfg/modules/django_client/core/generator/templates/python/client/sub_client.py.jinja +11 -0
  85. django_cfg/modules/django_client/core/generator/templates/python/client_file.py.jinja +13 -0
  86. django_cfg/modules/django_client/core/generator/templates/python/main_init.py.jinja +50 -0
  87. django_cfg/modules/django_client/core/generator/templates/python/models/app_models.py.jinja +17 -0
  88. django_cfg/modules/django_client/core/generator/templates/python/models/enum_class.py.jinja +15 -0
  89. django_cfg/modules/django_client/core/generator/templates/python/models/enums.py.jinja +8 -0
  90. django_cfg/modules/django_client/core/generator/templates/python/models/models.py.jinja +17 -0
  91. django_cfg/modules/django_client/core/generator/templates/python/models/schema_class.py.jinja +19 -0
  92. django_cfg/modules/django_client/core/generator/templates/python/utils/logger.py.jinja +255 -0
  93. django_cfg/modules/django_client/core/generator/templates/python/utils/schema.py.jinja +12 -0
  94. django_cfg/modules/django_client/core/generator/templates/typescript/app_index.ts.jinja +2 -0
  95. django_cfg/modules/django_client/core/generator/templates/typescript/client/app_client.ts.jinja +18 -0
  96. django_cfg/modules/django_client/core/generator/templates/typescript/client/client.ts.jinja +327 -0
  97. django_cfg/modules/django_client/core/generator/templates/typescript/client/flat_client.ts.jinja +109 -0
  98. django_cfg/modules/django_client/core/generator/templates/typescript/client/main_client_file.ts.jinja +9 -0
  99. django_cfg/modules/django_client/core/generator/templates/typescript/client/operation.ts.jinja +61 -0
  100. django_cfg/modules/django_client/core/generator/templates/typescript/client/sub_client.ts.jinja +15 -0
  101. django_cfg/modules/django_client/core/generator/templates/typescript/client_file.ts.jinja +9 -0
  102. django_cfg/modules/django_client/core/generator/templates/typescript/index.ts.jinja +5 -0
  103. django_cfg/modules/django_client/core/generator/templates/typescript/main_index.ts.jinja +206 -0
  104. django_cfg/modules/django_client/core/generator/templates/typescript/models/app_models.ts.jinja +8 -0
  105. django_cfg/modules/django_client/core/generator/templates/typescript/models/enums.ts.jinja +4 -0
  106. django_cfg/modules/django_client/core/generator/templates/typescript/models/models.ts.jinja +8 -0
  107. django_cfg/modules/django_client/core/generator/templates/typescript/utils/errors.ts.jinja +114 -0
  108. django_cfg/modules/django_client/core/generator/templates/typescript/utils/http.ts.jinja +98 -0
  109. django_cfg/modules/django_client/core/generator/templates/typescript/utils/logger.ts.jinja +251 -0
  110. django_cfg/modules/django_client/core/generator/templates/typescript/utils/schema.ts.jinja +7 -0
  111. django_cfg/modules/django_client/core/generator/templates/typescript/utils/storage.ts.jinja +114 -0
  112. django_cfg/modules/django_client/core/generator/typescript.py +872 -0
  113. django_cfg/modules/django_client/core/groups/__init__.py +13 -0
  114. django_cfg/modules/django_client/core/groups/detector.py +178 -0
  115. django_cfg/modules/django_client/core/groups/manager.py +314 -0
  116. django_cfg/modules/django_client/core/ir/__init__.py +57 -0
  117. django_cfg/modules/django_client/core/ir/context.py +387 -0
  118. django_cfg/modules/django_client/core/ir/operation.py +518 -0
  119. django_cfg/modules/django_client/core/ir/schema.py +353 -0
  120. django_cfg/modules/django_client/core/parser/__init__.py +74 -0
  121. django_cfg/modules/django_client/core/parser/base.py +648 -0
  122. django_cfg/modules/django_client/core/parser/models/__init__.py +74 -0
  123. django_cfg/modules/django_client/core/parser/models/base.py +212 -0
  124. django_cfg/modules/django_client/core/parser/models/components.py +160 -0
  125. django_cfg/modules/django_client/core/parser/models/openapi.py +203 -0
  126. django_cfg/modules/django_client/core/parser/models/operation.py +207 -0
  127. django_cfg/modules/django_client/core/parser/models/schema.py +266 -0
  128. django_cfg/modules/django_client/core/parser/openapi30.py +56 -0
  129. django_cfg/modules/django_client/core/parser/openapi31.py +64 -0
  130. django_cfg/modules/django_client/core/validation/__init__.py +22 -0
  131. django_cfg/modules/django_client/core/validation/checker.py +134 -0
  132. django_cfg/modules/django_client/core/validation/fixer.py +216 -0
  133. django_cfg/modules/django_client/core/validation/reporter.py +480 -0
  134. django_cfg/modules/django_client/core/validation/rules/__init__.py +11 -0
  135. django_cfg/modules/django_client/core/validation/rules/base.py +96 -0
  136. django_cfg/modules/django_client/core/validation/rules/type_hints.py +288 -0
  137. django_cfg/modules/django_client/core/validation/safety.py +266 -0
  138. django_cfg/modules/django_client/management/__init__.py +3 -0
  139. django_cfg/modules/django_client/management/commands/__init__.py +3 -0
  140. django_cfg/modules/django_client/management/commands/generate_client.py +422 -0
  141. django_cfg/modules/django_client/management/commands/validate_openapi.py +343 -0
  142. django_cfg/modules/django_client/spectacular/__init__.py +9 -0
  143. django_cfg/modules/django_client/spectacular/enum_naming.py +192 -0
  144. django_cfg/modules/django_client/urls.py +72 -0
  145. django_cfg/modules/django_email/management/__init__.py +0 -0
  146. django_cfg/modules/django_email/management/commands/__init__.py +0 -0
  147. django_cfg/modules/django_email/management/commands/test_email.py +93 -0
  148. django_cfg/modules/django_logging/django_logger.py +6 -6
  149. django_cfg/modules/django_ngrok/management/__init__.py +0 -0
  150. django_cfg/modules/django_ngrok/management/commands/__init__.py +0 -0
  151. django_cfg/modules/django_ngrok/management/commands/runserver_ngrok.py +167 -0
  152. django_cfg/modules/django_tasks/management/__init__.py +0 -0
  153. django_cfg/modules/django_tasks/management/commands/__init__.py +0 -0
  154. django_cfg/modules/django_tasks/management/commands/rundramatiq.py +254 -0
  155. django_cfg/modules/django_tasks/management/commands/rundramatiq_simulator.py +437 -0
  156. django_cfg/modules/django_tasks/management/commands/task_clear.py +226 -0
  157. django_cfg/modules/django_tasks/management/commands/task_status.py +257 -0
  158. django_cfg/modules/django_telegram/management/__init__.py +0 -0
  159. django_cfg/modules/django_telegram/management/commands/__init__.py +0 -0
  160. django_cfg/modules/django_telegram/management/commands/test_telegram.py +68 -0
  161. django_cfg/modules/django_twilio/management/__init__.py +0 -0
  162. django_cfg/modules/django_twilio/management/commands/__init__.py +0 -0
  163. django_cfg/modules/django_twilio/management/commands/test_twilio.py +112 -0
  164. django_cfg/modules/django_unfold/callbacks/main.py +16 -5
  165. django_cfg/modules/django_unfold/callbacks/revolution.py +41 -36
  166. django_cfg/pyproject.toml +2 -6
  167. django_cfg/registry/third_party.py +5 -7
  168. django_cfg/routing/callbacks.py +1 -1
  169. django_cfg/static/admin/css/prose-unfold.css +666 -0
  170. django_cfg/templates/admin/index.html +8 -0
  171. django_cfg/templates/admin/index_new.html +13 -0
  172. django_cfg/templates/admin/layouts/dashboard_with_tabs.html +15 -3
  173. django_cfg/templates/admin/sections/documentation_section.html +172 -0
  174. django_cfg/templates/admin/snippets/tabs/documentation_tab.html +231 -0
  175. {django_cfg-1.4.10.dist-info → django_cfg-1.4.11.dist-info}/METADATA +2 -2
  176. {django_cfg-1.4.10.dist-info → django_cfg-1.4.11.dist-info}/RECORD +180 -59
  177. django_cfg/management/commands/generate.py +0 -107
  178. /django_cfg/models/django/{revolution.py → revolution_legacy.py} +0 -0
  179. {django_cfg-1.4.10.dist-info → django_cfg-1.4.11.dist-info}/WHEEL +0 -0
  180. {django_cfg-1.4.10.dist-info → django_cfg-1.4.11.dist-info}/entry_points.txt +0 -0
  181. {django_cfg-1.4.10.dist-info → django_cfg-1.4.11.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,767 @@
1
+ """
2
+ Base Generator - Common code generation logic.
3
+
4
+ This module defines the abstract BaseGenerator class that provides
5
+ common functionality for all code generators (Python, TypeScript, etc.).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from abc import ABC, abstractmethod
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from ..ir import IRContext, IROperationObject, IRSchemaObject
15
+
16
+
17
+ class GeneratedFile:
18
+ """
19
+ Represents a generated file.
20
+
21
+ Attributes:
22
+ path: Relative file path (e.g., 'models.py', 'client.ts')
23
+ content: Generated file content
24
+ description: Human-readable description
25
+ """
26
+
27
+ def __init__(self, path: str, content: str, description: str | None = None):
28
+ self.path = path
29
+ self.content = content
30
+ self.description = description
31
+
32
+ def __repr__(self) -> str:
33
+ return f"GeneratedFile(path={self.path!r}, size={len(self.content)} bytes)"
34
+
35
+
36
+ class BaseGenerator(ABC):
37
+ """
38
+ Abstract base generator for IR → Code conversion.
39
+
40
+ Subclasses implement language-specific generation:
41
+ - PythonGenerator: Generates Python client (Pydantic 2 + httpx)
42
+ - TypeScriptGenerator: Generates TypeScript client (Fetch API)
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ context: IRContext,
48
+ client_structure: str = "namespaced",
49
+ openapi_schema: dict | None = None,
50
+ tag_prefix: str = "",
51
+ ):
52
+ """
53
+ Initialize generator with IR context.
54
+
55
+ Args:
56
+ context: IRContext from parser
57
+ client_structure: Client structure ("flat" or "namespaced")
58
+ openapi_schema: OpenAPI schema dict (for embedding in client)
59
+ tag_prefix: Prefix to add to all tag names (e.g., "cfg_")
60
+ """
61
+ self.context = context
62
+ self.client_structure = client_structure
63
+ self.openapi_schema = openapi_schema
64
+ self.tag_prefix = tag_prefix
65
+
66
+ # ===== Namespaced Structure Helpers =====
67
+
68
+ def group_operations_by_tag(self) -> dict[str, list[IROperationObject]]:
69
+ """
70
+ Group operations by their first tag with case-insensitive normalization.
71
+
72
+ Tags are normalized to prevent duplicates caused by case differences
73
+ (e.g., "Profiles" and "profiles" are treated as the same tag).
74
+ The canonical tag (first encountered) is preserved for display purposes.
75
+
76
+ Returns:
77
+ Dictionary mapping canonical tag names to lists of operations.
78
+ Operations without tags are grouped under "default".
79
+
80
+ Examples:
81
+ >>> ops = generator.group_operations_by_tag()
82
+ >>> ops["users"] # [list_op, create_op, retrieve_op, ...]
83
+ >>> # "Users" and "users" are merged into one group with canonical "Users"
84
+ """
85
+ from collections import defaultdict
86
+
87
+ ops_by_tag = defaultdict(list)
88
+ tag_canonical = {} # normalized_tag -> canonical_tag mapping
89
+ tag_variants = defaultdict(set) # Track all variants for warnings
90
+
91
+ for op_id, operation in self.context.operations.items():
92
+ tag = operation.tags[0] if operation.tags else "default"
93
+
94
+ # Normalize tag to lowercase for comparison
95
+ normalized_tag = tag.lower()
96
+
97
+ # Track all case variants
98
+ tag_variants[normalized_tag].add(tag)
99
+
100
+ # Use first encountered version as canonical
101
+ if normalized_tag not in tag_canonical:
102
+ tag_canonical[normalized_tag] = tag
103
+
104
+ # Group under canonical tag
105
+ canonical = tag_canonical[normalized_tag]
106
+ ops_by_tag[canonical].append(operation)
107
+
108
+ # Warn about case inconsistencies
109
+ for normalized, variants in tag_variants.items():
110
+ if len(variants) > 1:
111
+ canonical = tag_canonical[normalized]
112
+ other_variants = sorted(variants - {canonical})
113
+ print(f"⚠️ Warning: Found case variants of tag '{canonical}': {other_variants}")
114
+ print(f" → Using '{canonical}' as canonical tag")
115
+
116
+ return dict(ops_by_tag)
117
+
118
+ def tag_to_class_name(self, tag: str, suffix: str = "API") -> str:
119
+ """
120
+ Convert tag to PascalCase class name.
121
+
122
+ Args:
123
+ tag: Tag name (e.g., "users", "user-management", "django_cfg.auth")
124
+ suffix: Class name suffix (default: "API")
125
+
126
+ Returns:
127
+ PascalCase class name with suffix and optional prefix
128
+
129
+ Examples:
130
+ >>> generator.tag_to_class_name("users")
131
+ 'UsersAPI'
132
+ >>> generator.tag_to_class_name("user-management")
133
+ 'UserManagementAPI'
134
+ >>> generator.tag_to_class_name("django_cfg.auth")
135
+ 'DjangoCfgAuthAPI'
136
+ >>> generator = BaseGenerator(context, tag_prefix="cfg_")
137
+ >>> generator.tag_to_class_name("auth")
138
+ 'CfgAuthAPI'
139
+ """
140
+ # Use tag_to_property_name to get normalized name with prefix
141
+ normalized = self.tag_to_property_name(tag)
142
+ # Split into words and capitalize
143
+ words = normalized.split('_')
144
+ return ''.join(word.capitalize() for word in words if word) + suffix
145
+
146
+ def tag_to_property_name(self, tag: str) -> str:
147
+ """
148
+ Convert tag to valid property/variable name.
149
+
150
+ Args:
151
+ tag: Tag name (e.g., "Blog - Categories", "user-management", "django_cfg.leads")
152
+
153
+ Returns:
154
+ Valid identifier (snake_case for Python, camelCase for TS) with optional prefix
155
+
156
+ Examples:
157
+ >>> generator.tag_to_property_name("Blog - Categories")
158
+ 'blog_categories'
159
+ >>> generator.tag_to_property_name("user-management")
160
+ 'user_management'
161
+ >>> generator = BaseGenerator(context, tag_prefix="cfg_")
162
+ >>> generator.tag_to_property_name("auth")
163
+ 'cfg_auth'
164
+ >>> generator.tag_to_property_name("django_cfg.leads")
165
+ 'cfg_leads' # django_cfg prefix is stripped before adding group prefix
166
+ """
167
+ from django.utils.text import slugify
168
+ normalized = slugify(tag).replace('-', '_')
169
+
170
+ # Strip common app label prefixes to avoid duplication
171
+ # (e.g., "django_cfg_leads" → "leads" before adding group prefix)
172
+ prefixes_to_strip = ['django_cfg_', 'django_cfg.']
173
+ for prefix in prefixes_to_strip:
174
+ prefix_normalized = slugify(prefix).replace('-', '_')
175
+ if normalized.startswith(prefix_normalized):
176
+ normalized = normalized[len(prefix_normalized):]
177
+ break
178
+
179
+ # Strip leading underscores after prefix removal
180
+ # (e.g., "django_cfg.accounts" → "django_cfg_accounts" → "_accounts" → "accounts")
181
+ normalized = normalized.lstrip('_')
182
+
183
+ # Add group prefix if configured and not already present
184
+ if self.tag_prefix:
185
+ # Check if tag already starts with prefix (avoid duplication)
186
+ if not normalized.startswith(self.tag_prefix):
187
+ normalized = f"{self.tag_prefix}{normalized}"
188
+
189
+ return normalized
190
+
191
+ def extract_app_from_path(self, path: str) -> str | None:
192
+ """
193
+ Extract Django app name from URL path.
194
+
195
+ Args:
196
+ path: URL path (e.g., "/django_cfg_leads/leads/", "/django_cfg_newsletter/campaigns/")
197
+
198
+ Returns:
199
+ App name without trailing slash, or None if no app detected
200
+
201
+ Examples:
202
+ >>> generator.extract_app_from_path("/django_cfg_leads/leads/")
203
+ 'django_cfg_leads'
204
+ >>> generator.extract_app_from_path("/django_cfg_newsletter/campaigns/")
205
+ 'django_cfg_newsletter'
206
+ >>> generator.extract_app_from_path("/api/users/")
207
+ 'api'
208
+ """
209
+ # Remove leading/trailing slashes and split
210
+ parts = path.strip('/').split('/')
211
+
212
+ # First part is usually the app name
213
+ if parts:
214
+ return parts[0]
215
+
216
+ return None
217
+
218
+ def tag_and_app_to_folder_name(self, tag: str, operations: list) -> str:
219
+ """
220
+ Generate folder name from tag and app name with smart deduplication.
221
+
222
+ When tag matches app name, the redundant tag portion is omitted to avoid
223
+ folder names like 'cfg__tasks__tasks'. This keeps naming clean and intuitive.
224
+
225
+ Args:
226
+ tag: Tag name (e.g., "Campaigns", "Tasks", "django_cfg.leads")
227
+ operations: List of operations to extract app name from
228
+
229
+ Returns:
230
+ Folder name in one of these formats:
231
+ - group__app__tag (when tag differs from app)
232
+ - group__app (when tag matches app)
233
+ - group (when all three match - rare case)
234
+
235
+ Examples:
236
+ >>> # Distinct tag and app
237
+ >>> # operations with path="/django_cfg_newsletter/campaigns/"
238
+ >>> generator.tag_and_app_to_folder_name("Campaigns", operations)
239
+ 'cfg__newsletter__campaigns'
240
+
241
+ >>> # Tag matches app (deduplication applied)
242
+ >>> # operations with path="/tasks/api/..."
243
+ >>> generator.tag_and_app_to_folder_name("Tasks", operations)
244
+ 'cfg__tasks'
245
+
246
+ >>> # Tag matches app (deduplication applied)
247
+ >>> # operations with path="/django_cfg_leads/leads/"
248
+ >>> generator.tag_and_app_to_folder_name("Leads", operations)
249
+ 'cfg__leads'
250
+
251
+ >>> # Triple match - all same (rare)
252
+ >>> # operations with path="/profiles/profiles/"
253
+ >>> generator.tag_and_app_to_folder_name("Profiles", operations) # group=profiles
254
+ 'profiles'
255
+ """
256
+ from django.utils.text import slugify
257
+
258
+ # Extract app name from first operation's path
259
+ app_name = None
260
+ if operations:
261
+ app_name = self.extract_app_from_path(operations[0].path)
262
+
263
+ if not app_name:
264
+ # Fallback: just use normalized tag
265
+ return self.tag_to_property_name(tag)
266
+
267
+ # Normalize app name (strip django_cfg prefix)
268
+ normalized_app = slugify(app_name).replace('-', '_')
269
+ prefixes_to_strip = ['django_cfg_', 'django_cfg.']
270
+ for prefix in prefixes_to_strip:
271
+ prefix_normalized = slugify(prefix).replace('-', '_')
272
+ if normalized_app.startswith(prefix_normalized):
273
+ normalized_app = normalized_app[len(prefix_normalized):]
274
+ break
275
+ normalized_app = normalized_app.lstrip('_')
276
+
277
+ # Normalize tag (strip django_cfg prefix)
278
+ normalized_tag = slugify(tag).replace('-', '_')
279
+ for prefix in prefixes_to_strip:
280
+ prefix_normalized = slugify(prefix).replace('-', '_')
281
+ if normalized_tag.startswith(prefix_normalized):
282
+ normalized_tag = normalized_tag[len(prefix_normalized):]
283
+ break
284
+ normalized_tag = normalized_tag.lstrip('_')
285
+
286
+ # Smart deduplication: if tag matches app, skip tag portion
287
+ if normalized_tag == normalized_app:
288
+ # Tag is redundant with app name
289
+ if self.tag_prefix:
290
+ group = self.tag_prefix.rstrip('_')
291
+ # Check if group also matches app (triple redundancy)
292
+ if group == normalized_app:
293
+ # All three are the same, just use group
294
+ return group
295
+ else:
296
+ # Tag matches app but not group: use group__app
297
+ return f"{group}__{normalized_app}"
298
+ else:
299
+ # No group prefix, just use app name
300
+ return normalized_app
301
+
302
+ # Build folder name: group__app__tag (when tag is distinct)
303
+ if self.tag_prefix:
304
+ # tag_prefix already has trailing underscore: "cfg_"
305
+ group = self.tag_prefix.rstrip('_')
306
+ return f"{group}__{normalized_app}__{normalized_tag}"
307
+ else:
308
+ return f"{normalized_app}__{normalized_tag}"
309
+
310
+ def tag_to_display_name(self, tag: str) -> str:
311
+ """
312
+ Convert tag to human-readable display name for docstrings.
313
+
314
+ This method strips common prefixes and formats the tag for display in
315
+ documentation without adding group prefixes.
316
+
317
+ Args:
318
+ tag: Tag name (e.g., "django_cfg.leads", "Campaigns", "User Profile")
319
+
320
+ Returns:
321
+ Human-readable tag name (title case)
322
+
323
+ Examples:
324
+ >>> generator.tag_to_display_name("Campaigns")
325
+ 'Campaigns'
326
+ >>> generator.tag_to_display_name("django_cfg.leads")
327
+ 'Leads'
328
+ >>> generator.tag_to_display_name("django_cfg_accounts")
329
+ 'Accounts'
330
+ >>> generator.tag_to_display_name("user-management")
331
+ 'User Management'
332
+ """
333
+ from django.utils.text import slugify
334
+
335
+ # If tag is already in title case with spaces, return as-is
336
+ if ' ' in tag and tag[0].isupper():
337
+ return tag
338
+
339
+ # Normalize the tag
340
+ normalized = slugify(tag).replace('-', '_')
341
+
342
+ # Strip common app label prefixes
343
+ prefixes_to_strip = ['django_cfg_', 'django_cfg.']
344
+ for prefix in prefixes_to_strip:
345
+ prefix_normalized = slugify(prefix).replace('-', '_')
346
+ if normalized.startswith(prefix_normalized):
347
+ normalized = normalized[len(prefix_normalized):]
348
+ break
349
+
350
+ # Strip leading underscores
351
+ normalized = normalized.lstrip('_')
352
+
353
+ # Convert to title case with spaces
354
+ words = normalized.split('_')
355
+ return ' '.join(word.capitalize() for word in words if word)
356
+
357
+ def remove_tag_prefix(self, operation_id: str, tag: str) -> str:
358
+ """
359
+ Remove tag prefix from operation_id.
360
+
361
+ This method handles complex operation ID patterns by:
362
+ 1. Stripping common app label prefixes (django_cfg_*, etc.)
363
+ 2. Attempting to strip the normalized tag name
364
+ 3. Returning the cleaned operation_id
365
+
366
+ Args:
367
+ operation_id: Operation ID (e.g., "django_cfg_newsletter_campaigns_list", "posts_list")
368
+ tag: Tag name (e.g., "Campaigns", "posts", "django_cfg.accounts")
369
+
370
+ Returns:
371
+ Operation ID without tag prefix
372
+
373
+ Examples:
374
+ >>> generator.remove_tag_prefix("posts_list", "posts")
375
+ 'list'
376
+ >>> generator.remove_tag_prefix("django_cfg_newsletter_campaigns_list", "Campaigns")
377
+ 'list'
378
+ >>> generator.remove_tag_prefix("django_cfg_accounts_token_refresh_create", "Auth")
379
+ 'token_refresh_create'
380
+ >>> generator.remove_tag_prefix("retrieve", "users")
381
+ 'retrieve' # No prefix to remove
382
+ """
383
+ from django.utils.text import slugify
384
+
385
+ # First, strip common app label prefixes from operation_id
386
+ # This handles cases like "django_cfg_newsletter_campaigns_list"
387
+ cleaned_op_id = operation_id
388
+ app_prefixes_to_strip = [
389
+ 'django_cfg_newsletter_',
390
+ 'django_cfg_accounts_',
391
+ 'django_cfg_leads_',
392
+ 'django_cfg_support_',
393
+ 'django_cfg_agents_',
394
+ 'django_cfg_knowbase_',
395
+ 'django_cfg_payments_',
396
+ 'django_cfg_tasks_',
397
+ 'django_cfg_', # Catch-all for any django_cfg_* prefixes
398
+ ]
399
+
400
+ for app_prefix in app_prefixes_to_strip:
401
+ if cleaned_op_id.startswith(app_prefix):
402
+ cleaned_op_id = cleaned_op_id[len(app_prefix):]
403
+ break
404
+
405
+ # Now try to remove the normalized tag as a prefix
406
+ # Normalize tag same way as tag_to_property_name but without adding group prefix
407
+ normalized_tag = slugify(tag).replace('-', '_')
408
+
409
+ # Strip django_cfg prefix from tag too
410
+ tag_prefixes = ['django_cfg_', 'django_cfg.']
411
+ for prefix in tag_prefixes:
412
+ prefix_normalized = slugify(prefix).replace('-', '_')
413
+ if normalized_tag.startswith(prefix_normalized):
414
+ normalized_tag = normalized_tag[len(prefix_normalized):]
415
+ break
416
+
417
+ # Strip leading underscores from tag
418
+ normalized_tag = normalized_tag.lstrip('_')
419
+
420
+ # Try to remove normalized tag as prefix
421
+ tag_prefix = f"{normalized_tag}_"
422
+ if cleaned_op_id.startswith(tag_prefix):
423
+ cleaned_op_id = cleaned_op_id[len(tag_prefix):]
424
+
425
+ return cleaned_op_id
426
+
427
+ # ===== Main Generate Method =====
428
+
429
+ @abstractmethod
430
+ def generate(self) -> list[GeneratedFile]:
431
+ """
432
+ Generate all client files.
433
+
434
+ Returns:
435
+ List of GeneratedFile objects
436
+
437
+ Examples:
438
+ >>> generator = PythonGenerator(context)
439
+ >>> files = generator.generate()
440
+ >>> for file in files:
441
+ ... print(f"{file.path}: {len(file.content)} bytes")
442
+ models.py: 1234 bytes
443
+ client.py: 5678 bytes
444
+ """
445
+ pass
446
+
447
+ # ===== Schema Generation (Abstract) =====
448
+
449
+ @abstractmethod
450
+ def generate_schema(self, schema: IRSchemaObject) -> str:
451
+ """
452
+ Generate code for a single schema.
453
+
454
+ Args:
455
+ schema: IRSchemaObject to generate
456
+
457
+ Returns:
458
+ Generated code (class definition, interface, etc.)
459
+
460
+ Examples:
461
+ >>> schema = IRSchemaObject(name="User", type="object", ...)
462
+ >>> code = generator.generate_schema(schema)
463
+ >>> # Python: "class User(BaseModel): ..."
464
+ >>> # TypeScript: "interface User { ... }"
465
+ """
466
+ pass
467
+
468
+ @abstractmethod
469
+ def generate_enum(self, schema: IRSchemaObject) -> str:
470
+ """
471
+ Generate enum code from schema with x-enum-varnames.
472
+
473
+ Args:
474
+ schema: IRSchemaObject with enum + enum_var_names
475
+
476
+ Returns:
477
+ Generated enum code
478
+
479
+ Examples:
480
+ >>> schema = IRSchemaObject(
481
+ ... name="StatusEnum",
482
+ ... type="integer",
483
+ ... enum=[1, 2, 3],
484
+ ... enum_var_names=["STATUS_NEW", "STATUS_IN_PROGRESS", "STATUS_COMPLETE"]
485
+ ... )
486
+ >>> code = generator.generate_enum(schema)
487
+ >>> # Python: "class StatusEnum(IntEnum): ..."
488
+ >>> # TypeScript: "enum StatusEnum { ... }"
489
+ """
490
+ pass
491
+
492
+ # ===== Operation Generation (Abstract) =====
493
+
494
+ @abstractmethod
495
+ def generate_operation(self, operation: IROperationObject) -> str:
496
+ """
497
+ Generate code for a single operation (endpoint method).
498
+
499
+ Args:
500
+ operation: IROperationObject to generate
501
+
502
+ Returns:
503
+ Generated method code
504
+
505
+ Examples:
506
+ >>> operation = IROperationObject(
507
+ ... operation_id="users_list",
508
+ ... http_method="GET",
509
+ ... path="/api/users/",
510
+ ... ...
511
+ ... )
512
+ >>> code = generator.generate_operation(operation)
513
+ >>> # Python: "async def users_list(self, ...) -> list[User]: ..."
514
+ >>> # TypeScript: "async usersList(...): Promise<User[]> { ... }"
515
+ """
516
+ pass
517
+
518
+ # ===== Helpers =====
519
+
520
+ def get_request_schemas(self) -> dict[str, IRSchemaObject]:
521
+ """Get all request schemas (UserRequest, etc.)."""
522
+ return self.context.request_models
523
+
524
+ def get_response_schemas(self) -> dict[str, IRSchemaObject]:
525
+ """Get all response schemas (User, etc.)."""
526
+ return self.context.response_models
527
+
528
+ def get_patch_schemas(self) -> dict[str, IRSchemaObject]:
529
+ """Get all PATCH schemas (PatchedUser, etc.)."""
530
+ return self.context.patch_models
531
+
532
+ def get_enum_schemas(self) -> dict[str, IRSchemaObject]:
533
+ """Get all enum schemas with x-enum-varnames."""
534
+ return self.context.enum_schemas
535
+
536
+ def _collect_enums_from_schemas(
537
+ self, schemas: dict[str, IRSchemaObject]
538
+ ) -> dict[str, IRSchemaObject]:
539
+ """
540
+ Recursively collect all enum schemas used by given schemas.
541
+
542
+ This method extracts enums from:
543
+ - Top-level enum schemas (StatusEnum as a component)
544
+ - Nested enum properties (User.status where status has enum)
545
+ - Array items with enums (Task.tags where tags[0] is enum)
546
+
547
+ Auto-generates enum_var_names if not present:
548
+ - "open" → "OPEN"
549
+ - "waiting_for_user" → "WAITING_FOR_USER"
550
+
551
+ Args:
552
+ schemas: Dictionary of schemas to scan
553
+
554
+ Returns:
555
+ Dictionary of enum schemas found (with auto-generated var names)
556
+
557
+ Examples:
558
+ >>> schemas = {
559
+ ... "Payment": IRSchemaObject(
560
+ ... name="Payment",
561
+ ... type="object",
562
+ ... properties={
563
+ ... "status": IRSchemaObject(
564
+ ... name="StatusEnum",
565
+ ... type="string",
566
+ ... enum=["open", "closed"]
567
+ ... )
568
+ ... }
569
+ ... )
570
+ ... }
571
+ >>> enums = generator._collect_enums_from_schemas(schemas)
572
+ >>> enums["StatusEnum"].enum_var_names
573
+ ["OPEN", "CLOSED"]
574
+ """
575
+ enums = {}
576
+
577
+ def auto_generate_enum_var_names(schema: IRSchemaObject) -> IRSchemaObject:
578
+ """Auto-generate enum_var_names from enum values if missing."""
579
+ if schema.enum and not schema.enum_var_names:
580
+ # Generate variable names from values
581
+ var_names = []
582
+ for value in schema.enum:
583
+ if isinstance(value, str):
584
+ # Convert "waiting_for_user" → "WAITING_FOR_USER"
585
+ var_name = value.upper().replace("-", "_").replace(" ", "_")
586
+ else:
587
+ # For integers: 1 → "VALUE_1"
588
+ var_name = f"VALUE_{value}"
589
+ var_names.append(var_name)
590
+
591
+ # Create new schema with auto-generated var names
592
+ schema = IRSchemaObject(
593
+ **{**schema.model_dump(), "enum_var_names": var_names}
594
+ )
595
+ return schema
596
+
597
+ def collect_recursive(schema: IRSchemaObject):
598
+ """Recursively collect enums from schema and its nested properties."""
599
+ # Check if this schema itself is an enum (with or without x-enum-varnames)
600
+ if schema.enum and schema.name:
601
+ schema = auto_generate_enum_var_names(schema)
602
+ enums[schema.name] = schema
603
+
604
+ # Check if this schema is a reference to an enum
605
+ if schema.ref and schema.ref in self.context.schemas:
606
+ ref_schema = self.context.schemas[schema.ref]
607
+ if ref_schema.enum:
608
+ ref_schema = auto_generate_enum_var_names(ref_schema)
609
+ enums[ref_schema.name] = ref_schema
610
+
611
+ # Check properties for enums
612
+ if schema.properties:
613
+ for prop_schema in schema.properties.values():
614
+ # If property has enum, it's a standalone enum
615
+ if prop_schema.enum and prop_schema.name:
616
+ prop_schema = auto_generate_enum_var_names(prop_schema)
617
+ enums[prop_schema.name] = prop_schema
618
+ # Check if property is a reference to an enum
619
+ elif prop_schema.ref and prop_schema.ref in self.context.schemas:
620
+ ref_schema = self.context.schemas[prop_schema.ref]
621
+ if ref_schema.enum:
622
+ ref_schema = auto_generate_enum_var_names(ref_schema)
623
+ enums[ref_schema.name] = ref_schema
624
+ # Recurse into nested objects
625
+ elif prop_schema.type == "object":
626
+ collect_recursive(prop_schema)
627
+ # Recurse into arrays
628
+ elif prop_schema.type == "array" and prop_schema.items:
629
+ if prop_schema.items.enum and prop_schema.items.name:
630
+ items = auto_generate_enum_var_names(prop_schema.items)
631
+ enums[items.name] = items
632
+ elif prop_schema.items.ref and prop_schema.items.ref in self.context.schemas:
633
+ ref_items = self.context.schemas[prop_schema.items.ref]
634
+ if ref_items.enum:
635
+ ref_items = auto_generate_enum_var_names(ref_items)
636
+ enums[ref_items.name] = ref_items
637
+ elif prop_schema.items.type == "object":
638
+ collect_recursive(prop_schema.items)
639
+
640
+ # Check array items for enums (if schema itself is array)
641
+ if schema.items:
642
+ if schema.items.enum and schema.items.name:
643
+ items = auto_generate_enum_var_names(schema.items)
644
+ enums[items.name] = items
645
+ elif schema.items.ref and schema.items.ref in self.context.schemas:
646
+ ref_items = self.context.schemas[schema.items.ref]
647
+ if ref_items.enum:
648
+ ref_items = auto_generate_enum_var_names(ref_items)
649
+ enums[ref_items.name] = ref_items
650
+ elif schema.items.type == "object":
651
+ collect_recursive(schema.items)
652
+
653
+ # Collect enums from all schemas
654
+ for schema in schemas.values():
655
+ collect_recursive(schema)
656
+
657
+ return enums
658
+
659
+ def get_operations_by_tag(self) -> dict[str, list[IROperationObject]]:
660
+ """Get operations grouped by tags."""
661
+ return self.context.operations_by_tag
662
+
663
+ def save_files(self, files: list[GeneratedFile], output_dir: Path) -> None:
664
+ """
665
+ Save generated files to disk.
666
+
667
+ Args:
668
+ files: List of GeneratedFile objects
669
+ output_dir: Output directory path
670
+
671
+ Examples:
672
+ >>> files = generator.generate()
673
+ >>> generator.save_files(files, Path("./generated"))
674
+ """
675
+ output_dir.mkdir(parents=True, exist_ok=True)
676
+
677
+ for file in files:
678
+ file_path = output_dir / file.path
679
+ file_path.parent.mkdir(parents=True, exist_ok=True)
680
+
681
+ with open(file_path, "w", encoding="utf-8") as f:
682
+ f.write(file.content)
683
+
684
+ # ===== Formatting Helpers =====
685
+
686
+ def indent(self, code: str, spaces: int = 4) -> str:
687
+ """
688
+ Indent code by N spaces.
689
+
690
+ Args:
691
+ code: Code to indent
692
+ spaces: Number of spaces (default: 4)
693
+
694
+ Returns:
695
+ Indented code
696
+
697
+ Examples:
698
+ >>> generator.indent("x = 1\\ny = 2", 4)
699
+ ' x = 1\\n y = 2'
700
+ """
701
+ lines = code.split("\n")
702
+ indent_str = " " * spaces
703
+ return "\n".join(f"{indent_str}{line}" if line.strip() else line for line in lines)
704
+
705
+ def sanitize_name(self, name: str) -> str:
706
+ """
707
+ Sanitize schema/operation name to valid identifier.
708
+
709
+ Args:
710
+ name: Raw name
711
+
712
+ Returns:
713
+ Sanitized name
714
+
715
+ Examples:
716
+ >>> generator.sanitize_name("User-Profile")
717
+ 'User_Profile'
718
+ >>> generator.sanitize_name("2Users")
719
+ '_2Users'
720
+ """
721
+ # Replace invalid characters with underscore
722
+ sanitized = "".join(c if c.isalnum() or c == "_" else "_" for c in name)
723
+
724
+ # Ensure doesn't start with digit
725
+ if sanitized and sanitized[0].isdigit():
726
+ sanitized = f"_{sanitized}"
727
+
728
+ return sanitized or "Unknown"
729
+
730
+ def wrap_comment(self, text: str, max_length: int = 80) -> list[str]:
731
+ """
732
+ Wrap long comment text to multiple lines.
733
+
734
+ Args:
735
+ text: Comment text
736
+ max_length: Maximum line length
737
+
738
+ Returns:
739
+ List of wrapped lines
740
+
741
+ Examples:
742
+ >>> generator.wrap_comment("This is a very long comment that should be wrapped", 30)
743
+ ['This is a very long comment', 'that should be wrapped']
744
+ """
745
+ if not text:
746
+ return []
747
+
748
+ words = text.split()
749
+ lines = []
750
+ current_line = []
751
+ current_length = 0
752
+
753
+ for word in words:
754
+ word_length = len(word) + (1 if current_line else 0)
755
+
756
+ if current_length + word_length > max_length and current_line:
757
+ lines.append(" ".join(current_line))
758
+ current_line = [word]
759
+ current_length = len(word)
760
+ else:
761
+ current_line.append(word)
762
+ current_length += word_length
763
+
764
+ if current_line:
765
+ lines.append(" ".join(current_line))
766
+
767
+ return lines