drf-to-mkdoc 0.1.9__py3-none-any.whl → 0.2.1__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 drf-to-mkdoc might be problematic. Click here for more details.

Files changed (35) hide show
  1. drf_to_mkdoc/conf/defaults.py +5 -0
  2. drf_to_mkdoc/conf/settings.py +123 -9
  3. drf_to_mkdoc/management/commands/build_docs.py +8 -7
  4. drf_to_mkdoc/management/commands/build_endpoint_docs.py +69 -0
  5. drf_to_mkdoc/management/commands/build_model_docs.py +50 -0
  6. drf_to_mkdoc/management/commands/{generate_model_docs.py → extract_model_data.py} +18 -24
  7. drf_to_mkdoc/static/drf-to-mkdoc/javascripts/try-out-sidebar.js +879 -0
  8. drf_to_mkdoc/static/drf-to-mkdoc/stylesheets/endpoints/try-out-sidebar.css +728 -0
  9. drf_to_mkdoc/utils/ai_tools/__init__.py +0 -0
  10. drf_to_mkdoc/utils/ai_tools/enums.py +13 -0
  11. drf_to_mkdoc/utils/ai_tools/exceptions.py +19 -0
  12. drf_to_mkdoc/utils/ai_tools/providers/__init__.py +0 -0
  13. drf_to_mkdoc/utils/ai_tools/providers/base_provider.py +123 -0
  14. drf_to_mkdoc/utils/ai_tools/providers/gemini_provider.py +80 -0
  15. drf_to_mkdoc/utils/ai_tools/types.py +81 -0
  16. drf_to_mkdoc/utils/commons/__init__.py +0 -0
  17. drf_to_mkdoc/utils/commons/code_extractor.py +22 -0
  18. drf_to_mkdoc/utils/commons/file_utils.py +35 -0
  19. drf_to_mkdoc/utils/commons/model_utils.py +83 -0
  20. drf_to_mkdoc/utils/commons/operation_utils.py +83 -0
  21. drf_to_mkdoc/utils/commons/path_utils.py +78 -0
  22. drf_to_mkdoc/utils/commons/schema_utils.py +230 -0
  23. drf_to_mkdoc/utils/endpoint_detail_generator.py +16 -35
  24. drf_to_mkdoc/utils/endpoint_list_generator.py +1 -1
  25. drf_to_mkdoc/utils/extractors/query_parameter_extractors.py +33 -30
  26. drf_to_mkdoc/utils/model_detail_generator.py +44 -40
  27. drf_to_mkdoc/utils/model_list_generator.py +25 -15
  28. drf_to_mkdoc/utils/schema.py +259 -0
  29. {drf_to_mkdoc-0.1.9.dist-info → drf_to_mkdoc-0.2.1.dist-info}/METADATA +16 -5
  30. {drf_to_mkdoc-0.1.9.dist-info → drf_to_mkdoc-0.2.1.dist-info}/RECORD +33 -16
  31. drf_to_mkdoc/management/commands/generate_docs.py +0 -138
  32. drf_to_mkdoc/utils/common.py +0 -353
  33. {drf_to_mkdoc-0.1.9.dist-info → drf_to_mkdoc-0.2.1.dist-info}/WHEEL +0 -0
  34. {drf_to_mkdoc-0.1.9.dist-info → drf_to_mkdoc-0.2.1.dist-info}/licenses/LICENSE +0 -0
  35. {drf_to_mkdoc-0.1.9.dist-info → drf_to_mkdoc-0.2.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,13 @@
1
+ from enum import Enum
2
+
3
+
4
+ class MessageRole(Enum):
5
+ SYSTEM = "system"
6
+ USER = "user"
7
+ ASSISTANT = "assistant"
8
+ FUNCTION = "function"
9
+ TOOL = "tool"
10
+ MODEL = "model"
11
+
12
+ def __str__(self) -> str:
13
+ return self.value
@@ -0,0 +1,19 @@
1
+ class AIProviderError(Exception):
2
+ """Base exception for AI provider errors"""
3
+
4
+ def __init__(self, message: str, provider: str | None = None, model: str | None = None):
5
+ self.provider = provider
6
+ self.model = model
7
+ super().__init__(message)
8
+
9
+ def __str__(self) -> str:
10
+ base = super().__str__()
11
+ meta = ", ".join(
12
+ part
13
+ for part in (
14
+ f"provider={self.provider}" if self.provider else None,
15
+ f"model={self.model}" if self.model else None,
16
+ )
17
+ if part
18
+ )
19
+ return f"{base} ({meta})" if meta else base
File without changes
@@ -0,0 +1,123 @@
1
+ import json
2
+ from abc import ABC, abstractmethod
3
+ from pathlib import Path
4
+ from threading import Lock
5
+ from typing import Any
6
+
7
+ from django.template import RequestContext
8
+
9
+ from drf_to_mkdoc.conf.settings import drf_to_mkdoc_settings
10
+ from drf_to_mkdoc.utils.ai_tools.exceptions import AIProviderError
11
+ from drf_to_mkdoc.utils.ai_tools.types import (
12
+ ChatResponse,
13
+ Message,
14
+ ProviderConfig,
15
+ TokenUsage,
16
+ )
17
+ from drf_to_mkdoc.utils.commons.schema_utils import OperationExtractor
18
+
19
+
20
+ class BaseProvider(ABC):
21
+ """Abstract base class for AI providers"""
22
+
23
+ def __init__(self, config: ProviderConfig):
24
+ self.config = config
25
+ self._client = None
26
+
27
+ @property
28
+ def client(self):
29
+ """Lazy load client"""
30
+ if self._client is None:
31
+ if not hasattr(self, "_client_lock"):
32
+ # If providers are shared across threads, guard initialization.
33
+ self._client_lock = Lock()
34
+ with self._client_lock:
35
+ if self._client is None:
36
+ self._client = self._initialize_client()
37
+ return self._client
38
+
39
+ @abstractmethod
40
+ def _initialize_client(self) -> Any:
41
+ """Initialize provider-specific client"""
42
+ pass
43
+
44
+ @abstractmethod
45
+ def _send_chat_request(self, formatted_messages: Any, **kwargs) -> Any:
46
+ """Send request to provider's API"""
47
+ pass
48
+
49
+ @abstractmethod
50
+ def _parse_provider_response(self, response: Any) -> ChatResponse:
51
+ """Parse provider response to standard ChatResponse"""
52
+ pass
53
+
54
+ @abstractmethod
55
+ def _extract_token_usage(self, response: Any) -> TokenUsage:
56
+ """Extract token usage from provider response"""
57
+ pass
58
+
59
+ def format_messages_for_provider(self, messages: list[Message]) -> Any:
60
+ """
61
+ Convert your Message objects into a single string for chat.send_message().
62
+ This is a default behavior. You can override it your self as you want.
63
+ """
64
+ lines = []
65
+ for message in messages:
66
+ lines.append(f"{message.role.value}: {message.content}")
67
+
68
+ return "\n".join(lines)
69
+
70
+ def chat_completion(self, messages: list[Message], **kwargs) -> ChatResponse:
71
+ """
72
+ Send chat completion request without functions
73
+
74
+ Args:
75
+ messages: List of chat messages
76
+ **kwargs: Additional provider-specific parameters
77
+
78
+ Returns:
79
+ ChatResponse with AI's response
80
+ """
81
+ try:
82
+ formatted_messages = self.format_messages_for_provider(messages)
83
+
84
+ raw_response = self._send_chat_request(formatted_messages, **kwargs)
85
+
86
+ response = self._parse_provider_response(raw_response)
87
+
88
+ response.usage = self._extract_token_usage(raw_response)
89
+ response.model = self.config.model_name
90
+
91
+ except Exception as e:
92
+ raise AIProviderError(
93
+ f"Chat completion failed: {e!s}",
94
+ provider=self.__class__.__name__,
95
+ model=self.config.model_name,
96
+ ) from e
97
+ else:
98
+ return response
99
+
100
+ def _get_operation_info(
101
+ self, operation_id: str, context: RequestContext | None = None
102
+ ) -> dict[str, Any]:
103
+ return OperationExtractor().operation_map.get(operation_id)
104
+
105
+ def _get_model_info(self, app_label: str, model_name: str) -> dict[str, Any]:
106
+ docs_file = Path(drf_to_mkdoc_settings.MODEL_DOCS_FILE)
107
+
108
+ if not docs_file.exists():
109
+ raise FileNotFoundError(f"Model documentation file not found: {docs_file}")
110
+
111
+ with docs_file.open("r", encoding="utf-8") as f:
112
+ model_docs = json.load(f)
113
+
114
+ if app_label not in model_docs:
115
+ raise LookupError(f"App '{app_label}' not found in model documentation.")
116
+
117
+ if model_name not in model_docs[app_label]:
118
+ raise LookupError(f"Model '{model_name}' not found in model documentation.")
119
+
120
+ return model_docs[app_label][model_name]
121
+
122
+ def __repr__(self) -> str:
123
+ return f"{self.__class__.__name__}(provider={self.__class__.__name__}, model={self.config.model_name})"
@@ -0,0 +1,80 @@
1
+ from google import genai
2
+ from google.genai.types import GenerateContentConfig, GenerateContentResponse
3
+
4
+ from drf_to_mkdoc.utils.ai_tools.exceptions import AIProviderError
5
+ from drf_to_mkdoc.utils.ai_tools.providers.base_provider import BaseProvider
6
+ from drf_to_mkdoc.utils.ai_tools.types import (
7
+ ChatResponse,
8
+ TokenUsage,
9
+ )
10
+
11
+
12
+ class GeminiProvider(BaseProvider):
13
+ def _initialize_client(self) -> genai.Client:
14
+ try:
15
+ client = genai.Client(api_key=self.config.api_key)
16
+
17
+ except Exception as e:
18
+ raise AIProviderError(
19
+ f"Failed to initialize Gemini client: {e!s}",
20
+ provider="GeminiProvider",
21
+ model=self.config.model_name,
22
+ ) from e
23
+ else:
24
+ return client
25
+
26
+ def _send_chat_request(
27
+ self, formatted_messages, *args, **kwargs
28
+ ) -> GenerateContentResponse:
29
+ client: genai.Client = self.client
30
+ try:
31
+ return client.models.generate_content(
32
+ model=self.config.model_name,
33
+ contents=formatted_messages,
34
+ config=GenerateContentConfig(
35
+ temperature=self.config.temperature,
36
+ max_output_tokens=self.config.max_tokens,
37
+ **(self.config.extra_params or {}).get("generate_content_config", {}),
38
+ ),
39
+ )
40
+
41
+ except Exception as e:
42
+ raise AIProviderError(
43
+ f"Chat completion failed: Gemini API request failed: {e!s}",
44
+ provider="GeminiProvider",
45
+ model=self.config.model_name,
46
+ ) from e
47
+
48
+ def _parse_provider_response(self, response: GenerateContentResponse) -> ChatResponse:
49
+ try:
50
+ return ChatResponse(
51
+ content=(getattr(response, "text", "") or "").strip(),
52
+ model=self.config.model_name,
53
+ )
54
+
55
+ except Exception as e:
56
+ raise AIProviderError(
57
+ f"Failed to parse Gemini response: {e!s}",
58
+ provider="GeminiProvider",
59
+ model=self.config.model_name,
60
+ ) from e
61
+
62
+ def _extract_token_usage(self, response: GenerateContentResponse) -> TokenUsage:
63
+ usage = getattr(response, "usage_metadata", None)
64
+ if not usage:
65
+ return TokenUsage(
66
+ request_tokens=0,
67
+ response_tokens=0,
68
+ total_tokens=0,
69
+ provider=self.__class__.__name__,
70
+ model=self.config.model_name,
71
+ )
72
+ request_tokens = int(getattr(usage, "prompt_token_count", 0) or 0)
73
+ response_tokens = int(getattr(usage, "candidates_token_count", 0) or 0)
74
+ return TokenUsage(
75
+ request_tokens=request_tokens,
76
+ response_tokens=response_tokens,
77
+ total_tokens=request_tokens + response_tokens,
78
+ provider=self.__class__.__name__,
79
+ model=self.config.model_name,
80
+ )
@@ -0,0 +1,81 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any
3
+
4
+ from drf_to_mkdoc.utils.ai_tools.enums import MessageRole
5
+
6
+
7
+ @dataclass
8
+ class TokenUsage:
9
+ """Standardized token usage across all providers"""
10
+
11
+ request_tokens: int
12
+ response_tokens: int
13
+ total_tokens: int
14
+ provider: str
15
+ model: str
16
+
17
+ def to_dict(self) -> dict[str, Any]:
18
+ return {
19
+ "request_tokens": self.request_tokens,
20
+ "response_tokens": self.response_tokens,
21
+ "total_tokens": self.total_tokens,
22
+ "provider": self.provider,
23
+ "model": self.model,
24
+ }
25
+
26
+
27
+ @dataclass
28
+ class Message:
29
+ """Chat message"""
30
+
31
+ role: MessageRole
32
+ content: str
33
+ metadata: dict[str, Any] | None = None
34
+
35
+ def to_dict(self) -> dict[str, Any]:
36
+ result = {"role": self.role.value, "content": self.content}
37
+ if self.metadata:
38
+ result["metadata"] = self.metadata
39
+ return result
40
+
41
+
42
+ @dataclass
43
+ class ChatResponse:
44
+ """Standardized response from AI providers"""
45
+
46
+ content: str
47
+ usage: TokenUsage | None = None
48
+ model: str | None = None
49
+ metadata: dict[str, Any] | None = None
50
+
51
+ def to_dict(self) -> dict[str, Any]:
52
+ result = {
53
+ "content": self.content,
54
+ }
55
+ if self.usage:
56
+ result["usage"] = self.usage.to_dict()
57
+ if self.metadata:
58
+ result["metadata"] = self.metadata
59
+ if self.model is not None:
60
+ result["model"] = self.model
61
+ return result
62
+
63
+
64
+ @dataclass
65
+ class ProviderConfig:
66
+ """Configuration for AI providers"""
67
+
68
+ model_name: str
69
+ api_key: str
70
+ temperature: float = 0.7
71
+ max_tokens: int | None = None
72
+ extra_params: dict[str, Any] = field(default_factory=dict)
73
+
74
+ def to_dict(self) -> dict[str, Any]:
75
+ return {
76
+ "api_key": self.api_key,
77
+ "model_name": self.model_name,
78
+ "temperature": self.temperature,
79
+ "max_tokens": self.max_tokens,
80
+ "extra_params": self.extra_params,
81
+ }
File without changes
@@ -0,0 +1,22 @@
1
+ """Code extraction utilities."""
2
+
3
+ from pathlib import Path
4
+
5
+ from drf_to_mkdoc.conf.settings import drf_to_mkdoc_settings
6
+
7
+
8
+ def create_ai_code_directories() -> None:
9
+ """Create the directory structure for AI-generated code files."""
10
+ # Get base config directory
11
+ config_dir = Path(drf_to_mkdoc_settings.CONFIG_DIR)
12
+
13
+ # Create AI code directory
14
+ ai_code_dir = config_dir / drf_to_mkdoc_settings.AI_CONFIG_DIR_NAME
15
+
16
+ # Create subdirectories
17
+ subdirs = ["serializers", "views", "permissions"]
18
+
19
+ # Create all directories
20
+ for subdir in subdirs:
21
+ dir_path = ai_code_dir / subdir
22
+ Path.mkdir(dir_path, parents=True, exist_ok=True)
@@ -0,0 +1,35 @@
1
+ """File operation utilities."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from drf_to_mkdoc.conf.settings import drf_to_mkdoc_settings
8
+
9
+
10
+ def write_file(file_path: str, content: str) -> None:
11
+ full_path = Path(drf_to_mkdoc_settings.DOCS_DIR) / file_path
12
+ try:
13
+ full_path.parent.mkdir(parents=True, exist_ok=True)
14
+ tmp_path = full_path.with_suffix(full_path.suffix + ".tmp")
15
+
16
+ with tmp_path.open("w", encoding="utf-8") as f:
17
+ # Use atomic writes to avoid partially written docs.
18
+ f.write(content)
19
+ tmp_path.replace(full_path)
20
+ except OSError as e:
21
+ raise OSError(f"Failed to write file {full_path}: {e}") from e
22
+
23
+
24
+ def load_json_data(file_path: str, raise_not_found: bool = True) -> dict[str, Any] | None:
25
+ json_file = Path(file_path)
26
+ if not json_file.exists():
27
+ if raise_not_found:
28
+ raise FileNotFoundError(f"File not found: {json_file}")
29
+ return None
30
+
31
+ with json_file.open("r", encoding="utf-8") as f:
32
+ try:
33
+ return json.load(f)
34
+ except json.JSONDecodeError as e:
35
+ raise ValueError(f"Invalid JSON in {json_file}: {e}") from e
@@ -0,0 +1,83 @@
1
+ """Model-related utilities."""
2
+
3
+ import importlib
4
+
5
+ from django.apps import apps
6
+ from django.core.exceptions import AppRegistryNotReady
7
+
8
+ from drf_to_mkdoc.conf.settings import drf_to_mkdoc_settings
9
+ from drf_to_mkdoc.utils.commons.file_utils import load_json_data
10
+
11
+
12
+ def get_model_docstring(class_name: str) -> str | None:
13
+ """Extract docstring from Django model class"""
14
+ try:
15
+ # Check if Django is properly initialized
16
+ apps.check_apps_ready()
17
+
18
+ # Common Django app names to search
19
+ app_names = drf_to_mkdoc_settings.DJANGO_APPS
20
+
21
+ for app_name in app_names:
22
+ try:
23
+ # Try to import the models module
24
+ models_module = importlib.import_module(f"{app_name}.models")
25
+
26
+ # Check if the class exists in this module
27
+ if hasattr(models_module, class_name):
28
+ model_class = getattr(models_module, class_name)
29
+
30
+ # Get the docstring
31
+ docstring = getattr(model_class, "__doc__", None)
32
+
33
+ if docstring:
34
+ # Clean up the docstring
35
+ docstring = docstring.strip()
36
+
37
+ # Filter out auto-generated or generic docstrings
38
+ if (
39
+ docstring
40
+ and not docstring.startswith(class_name + "(")
41
+ and not docstring.startswith("str(object=")
42
+ and not docstring.startswith("Return repr(self)")
43
+ and "django.db.models" not in docstring.lower()
44
+ and len(docstring) > 10
45
+ ): # Minimum meaningful length
46
+ return docstring
47
+
48
+ except (ImportError, AttributeError):
49
+ continue
50
+
51
+ except (ImportError, AppRegistryNotReady):
52
+ # Django not initialized or not available - skip docstring extraction
53
+ pass
54
+
55
+ return None
56
+
57
+
58
+ def get_model_description(class_name: str) -> str:
59
+ """Get a brief description for a model with priority-based selection"""
60
+ # Priority 1: Description from config file
61
+ config = load_json_data(drf_to_mkdoc_settings.DOC_CONFIG_FILE, raise_not_found=False)
62
+ if config and "model_descriptions" in config:
63
+ config_description = config["model_descriptions"].get(class_name, "").strip()
64
+ if config_description:
65
+ return config_description
66
+
67
+ # Priority 2: Extract docstring from model class
68
+ docstring = get_model_docstring(class_name)
69
+ if docstring:
70
+ return docstring
71
+
72
+ # Priority 3: static value
73
+ return "Not provided"
74
+
75
+
76
+ def get_app_descriptions() -> dict[str, str]:
77
+ """Get descriptions for Django apps from config file"""
78
+ config = load_json_data(drf_to_mkdoc_settings.DOC_CONFIG_FILE, raise_not_found=False)
79
+ if config and "app_descriptions" in config:
80
+ return config["app_descriptions"]
81
+
82
+ # Fallback to empty dict if config not available
83
+ return {}
@@ -0,0 +1,83 @@
1
+ """Operation ID and viewset utilities."""
2
+
3
+ import logging
4
+ from functools import lru_cache
5
+ from typing import Any
6
+
7
+ from django.urls import Resolver404, resolve
8
+
9
+ from drf_to_mkdoc.utils.commons.path_utils import substitute_path_params
10
+ from drf_to_mkdoc.utils.commons.schema_utils import get_schema
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @lru_cache
16
+ def get_operation_id_path_map() -> dict[str, tuple[str, list[dict[str, Any]]]]:
17
+ schema = get_schema()
18
+ paths = schema.get("paths", {})
19
+ mapping = {}
20
+
21
+ for path, actions in paths.items():
22
+ for http_method_name, action_data in actions.items():
23
+ if http_method_name.lower() == "parameters" or not isinstance(action_data, dict):
24
+ # Skip path-level parameters entries (e.g., "parameters": [...] in OpenAPI schema)
25
+ continue
26
+ operation_id = action_data.get("operationId")
27
+ if operation_id:
28
+ mapping[operation_id] = (path, action_data.get("parameters", []))
29
+
30
+ return mapping
31
+
32
+
33
+ def extract_viewset_from_operation_id(operation_id: str):
34
+ """Extract the ViewSet class from an OpenAPI operation ID."""
35
+ operation_map = get_operation_id_path_map()
36
+ entry = operation_map.get(operation_id)
37
+ if not entry:
38
+ raise ValueError(f"Unknown operationId: {operation_id!r}")
39
+ path, parameters = entry
40
+
41
+ resolved_path = substitute_path_params(path, parameters)
42
+ try:
43
+ match = resolve(resolved_path)
44
+ view_func = match.func
45
+ if hasattr(view_func, "view_class"):
46
+ # For generic class-based views
47
+ return view_func.view_class
48
+
49
+ if hasattr(view_func, "cls"):
50
+ # For viewsets
51
+ return view_func.cls
52
+
53
+ except Resolver404:
54
+ logger.exception(
55
+ "Failed to resolve path. schema_path=%s tried_path=%s",
56
+ path,
57
+ resolved_path,
58
+ )
59
+ else:
60
+ return view_func
61
+
62
+
63
+ def extract_viewset_name_from_operation_id(operation_id: str):
64
+ view_cls = extract_viewset_from_operation_id(operation_id)
65
+ return view_cls.__name__ if hasattr(view_cls, "__name__") else str(view_cls)
66
+
67
+
68
+ def extract_app_from_operation_id(operation_id: str) -> str:
69
+ view = extract_viewset_from_operation_id(operation_id)
70
+
71
+ if isinstance(view, type):
72
+ module = view.__module__
73
+ elif hasattr(view, "__class__"):
74
+ module = view.__class__.__module__
75
+ else:
76
+ raise TypeError("Expected a view class or instance")
77
+
78
+ return module.split(".")[0]
79
+
80
+
81
+ def format_method_badge(method: str) -> str:
82
+ """Create a colored badge for HTTP method"""
83
+ return f'<span class="method-badge method-{method.lower()}">{method.upper()}</span>'
@@ -0,0 +1,78 @@
1
+ """Path manipulation utilities."""
2
+
3
+ import logging
4
+ import re
5
+ from typing import Any
6
+
7
+ from django.utils.module_loading import import_string
8
+
9
+ from drf_to_mkdoc.conf.settings import drf_to_mkdoc_settings
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def substitute_path_params(path: str, parameters: list[dict[str, Any]]) -> str:
15
+ django_path = convert_to_django_path(path, parameters)
16
+
17
+ django_path = re.sub(r"\{[^}]+\}", "1", django_path)
18
+ django_path = re.sub(r"<int:[^>]+>", "1", django_path)
19
+ django_path = re.sub(r"<uuid:[^>]+>", "12345678-1234-5678-9abc-123456789012", django_path)
20
+ django_path = re.sub(r"<float:[^>]+>", "1.0", django_path)
21
+ django_path = re.sub(r"<(?:string|str):[^>]+>", "dummy", django_path)
22
+ django_path = re.sub(r"<path:[^>]+>", "dummy/path", django_path)
23
+ django_path = re.sub(r"<[^:>]+>", "dummy", django_path) # Catch remaining simple params
24
+
25
+ return django_path # noqa: RET504
26
+
27
+
28
+ def convert_to_django_path(path: str, parameters: list[dict[str, Any]]) -> str:
29
+ """
30
+ Convert a path with {param} to a Django-style path with <type:param>.
31
+ If PATH_PARAM_SUBSTITUTE_FUNCTION is set, call it and merge its returned mapping.
32
+ """
33
+ function = None
34
+ func_path = drf_to_mkdoc_settings.PATH_PARAM_SUBSTITUTE_FUNCTION
35
+
36
+ if func_path:
37
+ try:
38
+ function = import_string(func_path)
39
+ except ImportError:
40
+ logger.warning("Invalid PATH_PARAM_SUBSTITUTE_FUNCTION import path: %r", func_path)
41
+
42
+ # If custom function exists and returns a valid value, use it
43
+ mapping = dict(drf_to_mkdoc_settings.PATH_PARAM_SUBSTITUTE_MAPPING or {})
44
+ if callable(function):
45
+ try:
46
+ result = function(path, parameters)
47
+ if result and isinstance(result, dict):
48
+ mapping.update(result)
49
+ except Exception:
50
+ logger.exception("Error in custom path substitutor %r for path %r", func_path, path)
51
+
52
+ # Default Django path conversion
53
+ def replacement(match):
54
+ param_name = match.group(1)
55
+ custom_param_type = mapping.get(param_name)
56
+ if custom_param_type and custom_param_type in ("int", "uuid", "str"):
57
+ converter = custom_param_type
58
+ else:
59
+ param_info = next((p for p in parameters if p.get("name") == param_name), {})
60
+ param_type = param_info.get("schema", {}).get("type")
61
+ param_format = param_info.get("schema", {}).get("format")
62
+
63
+ if param_type == "integer":
64
+ converter = "int"
65
+ elif param_type == "string" and param_format == "uuid":
66
+ converter = "uuid"
67
+ else:
68
+ converter = "str"
69
+
70
+ return f"<{converter}:{param_name}>"
71
+
72
+ return re.sub(r"{(\w+)}", replacement, path)
73
+
74
+
75
+ def create_safe_filename(path: str, method: str) -> str:
76
+ """Create a safe filename from path and method"""
77
+ safe_path = re.sub(r"[^a-zA-Z0-9_-]", "_", path.strip("/"))
78
+ return f"{method.lower()}_{safe_path}.md"