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.
- drf_to_mkdoc/conf/defaults.py +5 -0
- drf_to_mkdoc/conf/settings.py +123 -9
- drf_to_mkdoc/management/commands/build_docs.py +8 -7
- drf_to_mkdoc/management/commands/build_endpoint_docs.py +69 -0
- drf_to_mkdoc/management/commands/build_model_docs.py +50 -0
- drf_to_mkdoc/management/commands/{generate_model_docs.py → extract_model_data.py} +18 -24
- drf_to_mkdoc/static/drf-to-mkdoc/javascripts/try-out-sidebar.js +879 -0
- drf_to_mkdoc/static/drf-to-mkdoc/stylesheets/endpoints/try-out-sidebar.css +728 -0
- drf_to_mkdoc/utils/ai_tools/__init__.py +0 -0
- drf_to_mkdoc/utils/ai_tools/enums.py +13 -0
- drf_to_mkdoc/utils/ai_tools/exceptions.py +19 -0
- drf_to_mkdoc/utils/ai_tools/providers/__init__.py +0 -0
- drf_to_mkdoc/utils/ai_tools/providers/base_provider.py +123 -0
- drf_to_mkdoc/utils/ai_tools/providers/gemini_provider.py +80 -0
- drf_to_mkdoc/utils/ai_tools/types.py +81 -0
- drf_to_mkdoc/utils/commons/__init__.py +0 -0
- drf_to_mkdoc/utils/commons/code_extractor.py +22 -0
- drf_to_mkdoc/utils/commons/file_utils.py +35 -0
- drf_to_mkdoc/utils/commons/model_utils.py +83 -0
- drf_to_mkdoc/utils/commons/operation_utils.py +83 -0
- drf_to_mkdoc/utils/commons/path_utils.py +78 -0
- drf_to_mkdoc/utils/commons/schema_utils.py +230 -0
- drf_to_mkdoc/utils/endpoint_detail_generator.py +16 -35
- drf_to_mkdoc/utils/endpoint_list_generator.py +1 -1
- drf_to_mkdoc/utils/extractors/query_parameter_extractors.py +33 -30
- drf_to_mkdoc/utils/model_detail_generator.py +44 -40
- drf_to_mkdoc/utils/model_list_generator.py +25 -15
- drf_to_mkdoc/utils/schema.py +259 -0
- {drf_to_mkdoc-0.1.9.dist-info → drf_to_mkdoc-0.2.1.dist-info}/METADATA +16 -5
- {drf_to_mkdoc-0.1.9.dist-info → drf_to_mkdoc-0.2.1.dist-info}/RECORD +33 -16
- drf_to_mkdoc/management/commands/generate_docs.py +0 -138
- drf_to_mkdoc/utils/common.py +0 -353
- {drf_to_mkdoc-0.1.9.dist-info → drf_to_mkdoc-0.2.1.dist-info}/WHEEL +0 -0
- {drf_to_mkdoc-0.1.9.dist-info → drf_to_mkdoc-0.2.1.dist-info}/licenses/LICENSE +0 -0
- {drf_to_mkdoc-0.1.9.dist-info → drf_to_mkdoc-0.2.1.dist-info}/top_level.txt +0 -0
|
@@ -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"
|