django-cfg 1.4.108__py3-none-any.whl → 1.4.110__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.

Potentially problematic release.


This version of django-cfg might be problematic. Click here for more details.

Files changed (34) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/modules/django_admin/__init__.py +6 -0
  3. django_cfg/modules/django_admin/base/pydantic_admin.py +91 -0
  4. django_cfg/modules/django_admin/config/__init__.py +5 -0
  5. django_cfg/modules/django_admin/config/admin_config.py +7 -0
  6. django_cfg/modules/django_admin/config/documentation_config.py +406 -0
  7. django_cfg/modules/django_admin/config/field_config.py +87 -0
  8. django_cfg/modules/django_admin/templates/django_admin/change_form_docs.html +23 -0
  9. django_cfg/modules/django_admin/templates/django_admin/change_list_docs.html +23 -0
  10. django_cfg/modules/django_admin/templates/django_admin/documentation_block.html +297 -0
  11. django_cfg/modules/django_admin/templates/django_admin/markdown_docs_block.html +37 -0
  12. django_cfg/modules/django_admin/utils/__init__.py +3 -0
  13. django_cfg/modules/django_admin/utils/html_builder.py +94 -1
  14. django_cfg/modules/django_admin/utils/markdown_renderer.py +344 -0
  15. django_cfg/pyproject.toml +2 -2
  16. django_cfg/static/frontend/admin.zip +0 -0
  17. {django_cfg-1.4.108.dist-info → django_cfg-1.4.110.dist-info}/METADATA +2 -1
  18. {django_cfg-1.4.108.dist-info → django_cfg-1.4.110.dist-info}/RECORD +21 -28
  19. django_cfg/modules/django_admin/CHANGELOG_CODE_METHODS.md +0 -153
  20. django_cfg/modules/django_admin/IMPORT_EXPORT_FIX.md +0 -72
  21. django_cfg/modules/django_admin/RESOURCE_CONFIG_ENHANCEMENT.md +0 -350
  22. django_cfg/modules/django_client/system/__init__.py +0 -24
  23. django_cfg/modules/django_client/system/base_generator.py +0 -123
  24. django_cfg/modules/django_client/system/generate_mjs_clients.py +0 -176
  25. django_cfg/modules/django_client/system/mjs_generator.py +0 -219
  26. django_cfg/modules/django_client/system/schema_parser.py +0 -199
  27. django_cfg/modules/django_client/system/templates/api_client.js.j2 +0 -87
  28. django_cfg/modules/django_client/system/templates/app_index.js.j2 +0 -13
  29. django_cfg/modules/django_client/system/templates/base_client.js.j2 +0 -166
  30. django_cfg/modules/django_client/system/templates/main_index.js.j2 +0 -80
  31. django_cfg/modules/django_client/system/templates/types.js.j2 +0 -24
  32. {django_cfg-1.4.108.dist-info → django_cfg-1.4.110.dist-info}/WHEEL +0 -0
  33. {django_cfg-1.4.108.dist-info → django_cfg-1.4.110.dist-info}/entry_points.txt +0 -0
  34. {django_cfg-1.4.108.dist-info → django_cfg-1.4.110.dist-info}/licenses/LICENSE +0 -0
django_cfg/__init__.py CHANGED
@@ -32,7 +32,7 @@ Example:
32
32
  default_app_config = "django_cfg.apps.DjangoCfgConfig"
33
33
 
34
34
  # Version information
35
- __version__ = "1.4.108"
35
+ __version__ = "1.4.110"
36
36
  __license__ = "MIT"
37
37
 
38
38
  # Import registry for organized lazy loading
@@ -51,9 +51,11 @@ from .config import (
51
51
  BooleanField,
52
52
  CurrencyField,
53
53
  DateTimeField,
54
+ DocumentationConfig,
54
55
  FieldConfig,
55
56
  FieldsetConfig,
56
57
  ImageField,
58
+ MarkdownField,
57
59
  ResourceConfig,
58
60
  TextField,
59
61
  UserField,
@@ -73,6 +75,7 @@ from .icons import IconCategories, Icons
73
75
  from .utils import (
74
76
  CounterBadge,
75
77
  DateTimeDisplay,
78
+ MarkdownRenderer,
76
79
  MoneyDisplay,
77
80
  ProgressBadge,
78
81
  StatusBadge,
@@ -105,12 +108,14 @@ __all__ = [
105
108
  "ActionConfig",
106
109
  "ResourceConfig",
107
110
  "BackgroundTaskConfig",
111
+ "DocumentationConfig",
108
112
  # Specialized Field Types
109
113
  "BadgeField",
110
114
  "BooleanField",
111
115
  "CurrencyField",
112
116
  "DateTimeField",
113
117
  "ImageField",
118
+ "MarkdownField",
114
119
  "TextField",
115
120
  "UserField",
116
121
  # Advanced
@@ -125,6 +130,7 @@ __all__ = [
125
130
  "StatusBadge",
126
131
  "ProgressBadge",
127
132
  "CounterBadge",
133
+ "MarkdownRenderer",
128
134
  # Decorators
129
135
  "computed_field",
130
136
  "badge_field",
@@ -3,6 +3,7 @@ PydanticAdmin - Declarative admin base class.
3
3
  """
4
4
 
5
5
  import logging
6
+ from pathlib import Path
6
7
  from typing import Any, List, Optional
7
8
 
8
9
  from django.utils.safestring import mark_safe
@@ -152,6 +153,56 @@ class PydanticAdminMixin:
152
153
 
153
154
  cls.changelist_view = changelist_view_with_import_export
154
155
 
156
+ # Documentation configuration
157
+ if config.documentation:
158
+ cls._setup_documentation(config)
159
+
160
+ @classmethod
161
+ def _setup_documentation(cls, config: AdminConfig):
162
+ """
163
+ Setup documentation using unfold's template hooks.
164
+
165
+ Uses unfold's built-in hooks:
166
+ - list_before_template: Shows documentation before changelist table
167
+ - change_form_before_template: Shows documentation before fieldsets
168
+ """
169
+ doc_config = config.documentation
170
+
171
+ # Set unfold template hooks
172
+ if doc_config.show_on_changelist:
173
+ cls.list_before_template = "django_admin/documentation_block.html"
174
+
175
+ if doc_config.show_on_changeform:
176
+ cls.change_form_before_template = "django_admin/documentation_block.html"
177
+
178
+ # Store documentation config for access in views
179
+ cls.documentation_config = doc_config
180
+
181
+ def _get_app_path(self) -> Optional[Path]:
182
+ """
183
+ Detect the app path for relative file resolution.
184
+
185
+ Returns:
186
+ Path to the app directory or None
187
+ """
188
+ if not self.model:
189
+ return None
190
+
191
+ try:
192
+ # Get app label from model
193
+ app_label = self.model._meta.app_label
194
+
195
+ # Try to get app config
196
+ from django.apps import apps
197
+ app_config = apps.get_app_config(app_label)
198
+
199
+ if app_config and hasattr(app_config, 'path'):
200
+ return Path(app_config.path)
201
+ except Exception as e:
202
+ logger.warning(f"Could not detect app path for {self.model}: {e}")
203
+
204
+ return None
205
+
155
206
  @classmethod
156
207
  def _generate_resource_class(cls, config: AdminConfig):
157
208
  """
@@ -452,6 +503,46 @@ class PydanticAdminMixin:
452
503
 
453
504
  return tuple(filtered_fieldsets)
454
505
 
506
+ def changelist_view(self, request, extra_context=None):
507
+ """Override to add documentation context to changelist."""
508
+ if extra_context is None:
509
+ extra_context = {}
510
+
511
+ # Add documentation context if configured
512
+ if hasattr(self, 'documentation_config') and self.documentation_config:
513
+ doc_config = self.documentation_config
514
+ app_path = self._get_app_path()
515
+
516
+ if doc_config.show_on_changelist:
517
+ extra_context['documentation_config'] = doc_config
518
+ extra_context['documentation_sections'] = doc_config.get_sections(app_path)
519
+
520
+ # Add management commands if enabled
521
+ if doc_config.show_management_commands:
522
+ extra_context['management_commands'] = doc_config._discover_management_commands(app_path)
523
+
524
+ return super().changelist_view(request, extra_context)
525
+
526
+ def changeform_view(self, request, object_id=None, form_url='', extra_context=None):
527
+ """Override to add documentation context to changeform."""
528
+ if extra_context is None:
529
+ extra_context = {}
530
+
531
+ # Add documentation context if configured
532
+ if hasattr(self, 'documentation_config') and self.documentation_config:
533
+ doc_config = self.documentation_config
534
+ app_path = self._get_app_path()
535
+
536
+ if doc_config.show_on_changeform:
537
+ extra_context['documentation_config'] = doc_config
538
+ extra_context['documentation_sections'] = doc_config.get_sections(app_path)
539
+
540
+ # Add management commands if enabled
541
+ if doc_config.show_management_commands:
542
+ extra_context['management_commands'] = doc_config._discover_management_commands(app_path)
543
+
544
+ return super().changeform_view(request, object_id, form_url, extra_context)
545
+
455
546
  def formfield_for_dbfield(self, db_field, request, **kwargs):
456
547
  """
457
548
  Override form field for specific database field types.
@@ -5,6 +5,7 @@ Configuration models for declarative Django Admin.
5
5
  from .action_config import ActionConfig
6
6
  from .admin_config import AdminConfig
7
7
  from .background_task_config import BackgroundTaskConfig
8
+ from .documentation_config import DocumentationConfig, DocumentationSection
8
9
  from .field_config import (
9
10
  FieldConfig,
10
11
  BadgeField,
@@ -12,6 +13,7 @@ from .field_config import (
12
13
  CurrencyField,
13
14
  DateTimeField,
14
15
  ImageField,
16
+ MarkdownField,
15
17
  TextField,
16
18
  UserField,
17
19
  )
@@ -25,12 +27,15 @@ __all__ = [
25
27
  "ActionConfig",
26
28
  "ResourceConfig",
27
29
  "BackgroundTaskConfig",
30
+ "DocumentationConfig",
31
+ "DocumentationSection",
28
32
  # Specialized Field Types
29
33
  "BadgeField",
30
34
  "BooleanField",
31
35
  "CurrencyField",
32
36
  "DateTimeField",
33
37
  "ImageField",
38
+ "MarkdownField",
34
39
  "TextField",
35
40
  "UserField",
36
41
  ]
@@ -9,6 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field
9
9
 
10
10
  from .action_config import ActionConfig
11
11
  from .background_task_config import BackgroundTaskConfig
12
+ from .documentation_config import DocumentationConfig
12
13
  from .field_config import FieldConfig
13
14
  from .fieldset_config import FieldsetConfig
14
15
  from .resource_config import ResourceConfig
@@ -141,6 +142,12 @@ class AdminConfig(BaseModel):
141
142
  description="Configuration for background task processing"
142
143
  )
143
144
 
145
+ # Documentation
146
+ documentation: Optional[DocumentationConfig] = Field(
147
+ None,
148
+ description="Markdown documentation configuration"
149
+ )
150
+
144
151
  def get_display_field_config(self, field_name: str) -> Optional[FieldConfig]:
145
152
  """Get FieldConfig for a specific field."""
146
153
  for field_config in self.display_fields:
@@ -0,0 +1,406 @@
1
+ """
2
+ Documentation configuration for Django Admin.
3
+ """
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Dict, List, Optional, Union
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
10
+
11
+
12
+ class DocumentationSection(BaseModel):
13
+ """Single documentation section with title and content."""
14
+
15
+ model_config = ConfigDict(arbitrary_types_allowed=True)
16
+
17
+ title: str = Field(..., description="Section title")
18
+ content: str = Field(..., description="Rendered HTML content")
19
+ file_path: Optional[Path] = Field(None, description="Source file path")
20
+ default_open: bool = Field(False, description="Open by default")
21
+
22
+
23
+ class DocumentationConfig(BaseModel):
24
+ """
25
+ Configuration for markdown documentation in Django Admin.
26
+
27
+ Supports three modes:
28
+
29
+ 1. **Directory mode** (recommended):
30
+ Automatically discovers all .md files in directory recursively.
31
+ Each file becomes a collapsible section.
32
+
33
+ DocumentationConfig(
34
+ source_dir="docs", # Relative to app
35
+ title="Documentation",
36
+ max_height="600px"
37
+ )
38
+
39
+ 2. **Single file mode**:
40
+ Displays single markdown file.
41
+
42
+ DocumentationConfig(
43
+ source_file="docs/README.md",
44
+ title="Documentation"
45
+ )
46
+
47
+ 3. **String content mode**:
48
+ Direct markdown content.
49
+
50
+ DocumentationConfig(
51
+ source_content="# Hello\\nWorld",
52
+ title="Documentation"
53
+ )
54
+
55
+ Path resolution:
56
+ - Relative: "docs" → current app's docs/
57
+ - Project: "apps/crypto/docs" → project root
58
+ - Absolute: "/full/path/to/docs"
59
+ """
60
+
61
+ model_config = ConfigDict(validate_assignment=True, extra="forbid")
62
+
63
+ # Content source (one of these must be provided)
64
+ source_dir: Optional[Union[str, Path]] = Field(
65
+ None,
66
+ description="Path to directory with .md files (scans recursively)"
67
+ )
68
+ source_file: Optional[Union[str, Path]] = Field(
69
+ None,
70
+ description="Path to single markdown file"
71
+ )
72
+ source_content: Optional[str] = Field(
73
+ None,
74
+ description="Markdown content as string"
75
+ )
76
+
77
+ # Display options
78
+ title: str = Field("Documentation", description="Main title for documentation block")
79
+ collapsible: bool = Field(True, description="Make sections collapsible")
80
+ default_open: bool = Field(False, description="Open first section by default")
81
+ max_height: Optional[str] = Field("600px", description="Max height with scrolling per section")
82
+
83
+ # Placement
84
+ show_on_changelist: bool = Field(True, description="Show on list page (above table)")
85
+ show_on_changeform: bool = Field(True, description="Show on edit/add page (before fieldsets)")
86
+
87
+ # Markdown rendering
88
+ enable_plugins: bool = Field(True, description="Enable mistune plugins")
89
+
90
+ # Sorting
91
+ sort_sections: bool = Field(True, description="Sort sections alphabetically by title")
92
+
93
+ # Management commands discovery
94
+ show_management_commands: bool = Field(
95
+ True,
96
+ description="Auto-discover and display management commands from app"
97
+ )
98
+
99
+ @field_validator('source_dir', 'source_file', 'source_content')
100
+ @classmethod
101
+ def validate_source(cls, v, info):
102
+ """Ensure at least one source is provided."""
103
+ return v
104
+
105
+ def _resolve_path(self, path: Union[str, Path], app_path: Optional[Path] = None) -> Optional[Path]:
106
+ """
107
+ Resolve file or directory path with support for:
108
+ - Relative to app: "docs"
109
+ - Relative to project: "apps/myapp/docs"
110
+ - Absolute: "/full/path/to/docs"
111
+
112
+ Args:
113
+ path: Path to resolve
114
+ app_path: Path to the app directory (auto-detected from model)
115
+
116
+ Returns:
117
+ Resolved absolute path or None
118
+ """
119
+ if not path:
120
+ return None
121
+
122
+ path_obj = Path(path)
123
+
124
+ # If absolute path, return as is
125
+ if path_obj.is_absolute():
126
+ return path_obj if path_obj.exists() else None
127
+
128
+ # Try project root first (for paths like "apps/crypto/docs")
129
+ from django.conf import settings
130
+ base_dir = Path(settings.BASE_DIR)
131
+
132
+ # Try relative to project root
133
+ project_path = base_dir / path_obj
134
+ if project_path.exists():
135
+ return project_path
136
+
137
+ # Try relative to app if provided
138
+ if app_path:
139
+ app_path_resolved = app_path / path_obj
140
+ if app_path_resolved.exists():
141
+ return app_path_resolved
142
+
143
+ # Try to find in any app's directory
144
+ if hasattr(settings, 'INSTALLED_APPS'):
145
+ for app in settings.INSTALLED_APPS:
146
+ try:
147
+ # Get app module
148
+ import importlib
149
+ app_module = importlib.import_module(app.split('.')[0])
150
+ if hasattr(app_module, '__path__'):
151
+ app_dir = Path(app_module.__path__[0])
152
+ app_file = app_dir / path_obj
153
+ if app_file.exists():
154
+ return app_file
155
+ except (ImportError, AttributeError, IndexError):
156
+ continue
157
+
158
+ return None
159
+
160
+ def _scan_markdown_files(self, directory: Path) -> List[Path]:
161
+ """
162
+ Recursively scan directory for markdown files.
163
+
164
+ Args:
165
+ directory: Directory to scan
166
+
167
+ Returns:
168
+ List of markdown file paths
169
+ """
170
+ md_files = []
171
+
172
+ if not directory.is_dir():
173
+ return md_files
174
+
175
+ # Recursively find all .md files
176
+ for md_file in directory.rglob("*.md"):
177
+ if md_file.is_file():
178
+ md_files.append(md_file)
179
+
180
+ return md_files
181
+
182
+ def _get_section_title(self, file_path: Path, base_dir: Path) -> str:
183
+ """
184
+ Generate section title from file path.
185
+
186
+ Strategies:
187
+ 1. Extract first H1 from content if available
188
+ 2. If README.md → use parent directory name
189
+ 3. If nested → use "Parent / Filename"
190
+
191
+ Args:
192
+ file_path: Path to markdown file
193
+ base_dir: Base documentation directory
194
+
195
+ Returns:
196
+ Section title
197
+ """
198
+ # Try to extract H1 from file
199
+ try:
200
+ content = file_path.read_text(encoding='utf-8')
201
+ lines = content.split('\n')
202
+ for line in lines:
203
+ line = line.strip()
204
+ if line.startswith('# '):
205
+ return line[2:].strip()
206
+ except Exception:
207
+ pass
208
+
209
+ # Fallback to filename
210
+ relative_path = file_path.relative_to(base_dir)
211
+
212
+ # If README.md, use parent directory name
213
+ if file_path.stem.lower() == 'readme':
214
+ if relative_path.parent != Path('.'):
215
+ return str(relative_path.parent).replace('/', ' / ').replace('_', ' ').title()
216
+ return "Overview"
217
+
218
+ # Build title from path
219
+ parts = []
220
+ if relative_path.parent != Path('.'):
221
+ parts.append(str(relative_path.parent).replace('/', ' / ').replace('_', ' ').title())
222
+
223
+ # Add filename without extension
224
+ parts.append(file_path.stem.replace('_', ' ').replace('-', ' ').title())
225
+
226
+ return ' / '.join(parts)
227
+
228
+ def get_sections(self, app_path: Optional[Path] = None) -> List[DocumentationSection]:
229
+ """
230
+ Get all documentation sections.
231
+
232
+ Returns list of sections based on mode:
233
+ - Directory mode: One section per .md file
234
+ - Single file mode: One section
235
+ - String content mode: One section
236
+
237
+ Args:
238
+ app_path: Optional path to app directory for relative path resolution
239
+
240
+ Returns:
241
+ List of DocumentationSection objects
242
+ """
243
+ from django_cfg.modules.django_admin.utils import MarkdownRenderer
244
+
245
+ sections = []
246
+
247
+ # Directory mode
248
+ if self.source_dir:
249
+ resolved_dir = self._resolve_path(self.source_dir, app_path)
250
+ if resolved_dir and resolved_dir.is_dir():
251
+ md_files = self._scan_markdown_files(resolved_dir)
252
+
253
+ for idx, md_file in enumerate(md_files):
254
+ try:
255
+ content = md_file.read_text(encoding='utf-8')
256
+ rendered = MarkdownRenderer.render_markdown(
257
+ content,
258
+ enable_plugins=self.enable_plugins
259
+ )
260
+
261
+ title = self._get_section_title(md_file, resolved_dir)
262
+
263
+ sections.append(DocumentationSection(
264
+ title=title,
265
+ content=rendered,
266
+ file_path=md_file,
267
+ default_open=(idx == 0 and self.default_open)
268
+ ))
269
+ except Exception as e:
270
+ # Skip files that can't be read
271
+ continue
272
+
273
+ # Single file mode
274
+ elif self.source_file:
275
+ resolved_file = self._resolve_path(self.source_file, app_path)
276
+ if resolved_file and resolved_file.is_file():
277
+ try:
278
+ content = resolved_file.read_text(encoding='utf-8')
279
+ rendered = MarkdownRenderer.render_markdown(
280
+ content,
281
+ enable_plugins=self.enable_plugins
282
+ )
283
+
284
+ title = self._get_section_title(resolved_file, resolved_file.parent)
285
+
286
+ sections.append(DocumentationSection(
287
+ title=title,
288
+ content=rendered,
289
+ file_path=resolved_file,
290
+ default_open=self.default_open
291
+ ))
292
+ except Exception:
293
+ pass
294
+
295
+ # String content mode
296
+ elif self.source_content:
297
+ rendered = MarkdownRenderer.render_markdown(
298
+ self.source_content,
299
+ enable_plugins=self.enable_plugins
300
+ )
301
+
302
+ sections.append(DocumentationSection(
303
+ title=self.title,
304
+ content=rendered,
305
+ default_open=self.default_open
306
+ ))
307
+
308
+ # Sort sections if requested
309
+ if self.sort_sections and len(sections) > 1:
310
+ sections.sort(key=lambda s: s.title.lower())
311
+
312
+ return sections
313
+
314
+ def _discover_management_commands(self, app_path: Optional[Path] = None) -> List[Dict[str, any]]:
315
+ """
316
+ Discover management commands in app's management/commands directory.
317
+
318
+ Args:
319
+ app_path: Path to the app directory
320
+
321
+ Returns:
322
+ List of command dictionaries with name, help text, and arguments
323
+ """
324
+ commands = []
325
+
326
+ if not app_path:
327
+ return commands
328
+
329
+ commands_dir = app_path / "management" / "commands"
330
+ if not commands_dir.exists() or not commands_dir.is_dir():
331
+ return commands
332
+
333
+ # Find all command files
334
+ for cmd_file in commands_dir.glob("*.py"):
335
+ if cmd_file.stem.startswith('_'):
336
+ continue
337
+
338
+ try:
339
+ # Import the command module
340
+ import importlib.util
341
+ spec = importlib.util.spec_from_file_location(
342
+ f"command_{cmd_file.stem}", cmd_file
343
+ )
344
+ if spec and spec.loader:
345
+ module = importlib.util.module_from_spec(spec)
346
+ spec.loader.exec_module(module)
347
+
348
+ # Get the Command class
349
+ if hasattr(module, 'Command'):
350
+ cmd_class = module.Command
351
+ cmd_instance = cmd_class()
352
+
353
+ # Extract command info
354
+ command_info = {
355
+ 'name': cmd_file.stem,
356
+ 'help': getattr(cmd_instance, 'help', 'No description available'),
357
+ 'arguments': []
358
+ }
359
+
360
+ # Try to extract arguments if add_arguments method exists
361
+ if hasattr(cmd_instance, 'add_arguments'):
362
+ # Create a mock parser to extract arguments
363
+ import argparse
364
+ parser = argparse.ArgumentParser()
365
+ try:
366
+ cmd_instance.add_arguments(parser)
367
+ # Extract arguments from parser
368
+ for action in parser._actions:
369
+ if action.dest != 'help':
370
+ arg_info = {
371
+ 'name': '/'.join(action.option_strings) if action.option_strings else action.dest,
372
+ 'help': action.help or '',
373
+ 'required': action.required if hasattr(action, 'required') else False,
374
+ 'default': action.default if action.default != argparse.SUPPRESS else None
375
+ }
376
+ command_info['arguments'].append(arg_info)
377
+ except Exception:
378
+ pass
379
+
380
+ commands.append(command_info)
381
+
382
+ except Exception:
383
+ # Skip commands that can't be imported
384
+ continue
385
+
386
+ # Sort commands alphabetically
387
+ commands.sort(key=lambda c: c['name'])
388
+
389
+ return commands
390
+
391
+ def get_content(self, app_path: Optional[Path] = None) -> Optional[str]:
392
+ """
393
+ Get rendered markdown content (legacy single-section method).
394
+
395
+ For multi-section support, use get_sections() instead.
396
+
397
+ Args:
398
+ app_path: Optional path to app directory for relative path resolution
399
+
400
+ Returns:
401
+ Rendered HTML string or None
402
+ """
403
+ sections = self.get_sections(app_path)
404
+ if sections:
405
+ return sections[0].content
406
+ return None